diff --git a/Agent.md b/Agent.md index d1d01162..9485b0c0 100644 --- a/Agent.md +++ b/Agent.md @@ -85,8 +85,8 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (262: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - - React migration Batch 0 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (46 methods) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3) + - Unit tests `npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t) - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -120,7 +120,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` Python: `uv run pytest tests/ -v` (1095) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (262: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite (Batch 0): `cd emrg/gui/renderer && npm run typecheck && npm test` (19 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke) + `npm run build` → `renderer/dist/` +GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (34 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/src/lib/commands.test.ts b/emrg/gui/renderer/src/lib/commands.test.ts new file mode 100644 index 00000000..fa2573e2 --- /dev/null +++ b/emrg/gui/renderer/src/lib/commands.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { COMMANDS, getCompletions, hintText, parseInput } from "./commands"; +import type { TranslateFn } from "./utils"; + +/** hint 键 → 固定文案的测试翻译函数(i18n 未就绪路径的替身) */ +const fakeT: TranslateFn = (key) => `[${key}]`; + +describe("commands registry", () => { + it("contains all 15 TUI commands + /open (rant 19:44 acceptance + P5 extension)", () => { + const expected = [ + "/clear", "/compact", "/delete", "/help", "/image", "/memory", "/model", + "/open", "/rant", "/rename", "/resume", "/rewind", "/sessions", "/skills", "/trigger", "/version", + ]; + for (const cmd of expected) { + expect(COMMANDS[cmd]).toBeDefined(); + } + expect(Object.keys(COMMANDS).length).toBe(16); + // 每条指令都有 hint + phase 1-4 + for (const [cmd, meta] of Object.entries(COMMANDS)) { + expect(meta.hint.length).toBeGreaterThan(0); + expect(meta.phase).toBeGreaterThanOrEqual(1); + expect(meta.phase).toBeLessThanOrEqual(4); + } + }); +}); + +describe("parseInput", () => { + it("plain message", () => { + expect(parseInput("你好,帮我写周报")).toEqual({ type: "message" }); + expect(parseInput(" 你好 ")).toEqual({ type: "message" }); + }); + it("known command (case-insensitive, with args)", () => { + expect(parseInput("/clear")).toEqual({ type: "command", cmd: "/clear", args: [] }); + expect(parseInput("/Clear")).toEqual({ type: "command", cmd: "/clear", args: [] }); + expect(parseInput("/rant 希望支持主题切换")).toEqual({ + type: "command", + cmd: "/rant", + args: ["希望支持主题切换"], + }); + }); + it("unknown command", () => { + expect(parseInput("/foobar")).toEqual({ type: "unknown", cmd: "/foobar" }); + }); +}); + +describe("getCompletions", () => { + it("empty prefix returns all 16", () => { + expect(getCompletions("", fakeT).length).toBe(16); + }); + it("/r prefix filters to /rant /rename /resume /rewind", () => { + const cmds = getCompletions("/r", fakeT).map((i) => i.cmd).sort(); + expect(cmds).toEqual(["/rant", "/rename", "/resume", "/rewind"].sort()); + }); + it("exact match returns single entry with hint", () => { + const c = getCompletions("/clear", fakeT); + expect(c.length).toBe(1); + expect(c[0].cmd).toBe("/clear"); + expect(c[0].hint.length).toBeGreaterThan(0); + }); + it("no match returns empty", () => { + expect(getCompletions("/zzz", fakeT).length).toBe(0); + }); +}); + +describe("hintText", () => { + it("returns translated hint when t available", () => { + const t: TranslateFn = (key) => (key === "cmd.clear.hint" ? "清空会话" : key); + expect(hintText("/clear", t)).toBe("清空会话"); + }); + it("falls back to the dict key when t is unavailable", () => { + const t: TranslateFn = (key) => key; + expect(hintText("/clear", t)).toBe("cmd.clear.hint"); + }); + it("returns empty string for unknown command", () => { + expect(hintText("/nope", fakeT)).toBe(""); + }); +}); diff --git a/emrg/gui/renderer/src/lib/commands.ts b/emrg/gui/renderer/src/lib/commands.ts new file mode 100644 index 00000000..d943a918 --- /dev/null +++ b/emrg/gui/renderer/src/lib/commands.ts @@ -0,0 +1,68 @@ +import type { TranslateFn } from "./utils"; + +/** + * commands.ts — / 指令注册表与解析器(vanilla renderer/js/commands.js 迁移,Batch 1)。 + * 纯逻辑、不绑 DOM:React 组件与测试均可直接 import。 + * 契约与 vanilla 版完全一致(COMMANDS 16 条 / parseInput 三态 / getCompletions 前缀过滤)。 + */ +export interface CommandMeta { + hint: string; + phase: number; +} + +/** 指令注册表:cmd → { hint, phase }(hint 为 i18n 词典键,显示时经 t() 解析) */ +export const COMMANDS: Record = { + "/clear": { hint: "cmd.clear.hint", phase: 1 }, + "/compact": { hint: "cmd.compact.hint", phase: 1 }, + "/version": { hint: "cmd.version.hint", phase: 1 }, + "/help": { hint: "cmd.help.hint", phase: 1 }, + "/image": { hint: "cmd.image.hint", phase: 1 }, + "/delete": { hint: "cmd.delete.hint", phase: 2 }, + "/rename": { hint: "cmd.rename.hint", phase: 2 }, + "/resume": { hint: "cmd.resume.hint", phase: 2 }, + "/rewind": { hint: "cmd.rewind.hint", phase: 2 }, + "/sessions": { hint: "cmd.sessions.hint", phase: 2 }, + "/open": { hint: "cmd.open.hint", phase: 4 }, + "/model": { hint: "cmd.model.hint", phase: 3 }, + "/memory": { hint: "cmd.memory.hint", phase: 3 }, + "/skills": { hint: "cmd.skills.hint", phase: 3 }, + "/rant": { hint: "cmd.rant.hint", phase: 4 }, + "/trigger": { hint: "cmd.trigger.hint", phase: 4 }, +}; + +export type ParseResult = + | { type: "message" } + | { type: "command"; cmd: string; args: string[] } + | { type: "unknown"; cmd: string }; + +/** + * 解析输入:/ 开头且匹配注册表 → { type:"command", cmd, args }; + * / 开头未匹配 → { type:"unknown", cmd };否则 { type:"message" }。 + */ +export function parseInput(text: unknown): ParseResult { + const t = String(text ?? "").trim(); + if (!t.startsWith("/")) return { type: "message" }; + const [cmd, ...args] = t.split(/\s+/); + const key = cmd.toLowerCase(); + if (COMMANDS[key]) return { type: "command", cmd: key, args }; + return { type: "unknown", cmd: key }; +} + +/** 解析 i18n hint:词典键 → 当前语言文案(t 由调用方注入,i18n 未就绪时回退键本身) */ +export function hintText(cmd: string, t: TranslateFn): string { + const meta = COMMANDS[cmd]; + if (!meta) return ""; + try { + return t(meta.hint); + } catch { + return meta.hint; + } +} + +/** / 前缀补全:返回匹配的指令(含 hint),供补全菜单过滤 */ +export function getCompletions(prefix: string, t: TranslateFn): Array<{ cmd: string; hint: string; phase: number }> { + const p = String(prefix ?? "").toLowerCase(); + return Object.entries(COMMANDS) + .filter(([cmd]) => cmd.startsWith(p)) + .map(([cmd, meta]) => ({ cmd, hint: hintText(cmd, t), phase: meta.phase })); +} diff --git a/emrg/gui/renderer/src/lib/copywriting.test.ts b/emrg/gui/renderer/src/lib/copywriting.test.ts new file mode 100644 index 00000000..8bfd7886 --- /dev/null +++ b/emrg/gui/renderer/src/lib/copywriting.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { buildCopy, createCopywriting, toolPhrases } from "./copywriting"; +import type { TranslateFn } from "./utils"; + +/** 最小词典 + 计数翻译函数:验证 key 透传与参数插值 */ +function makeT(counts: Record): TranslateFn { + const dict: Record = { + "tool.bash.doing": "运行中", + "tool.bash.done": "已完成", + "tool.fallback.doing": "处理中", + "tool.fallback.done": "完成", + "tool.failText": "操作失败", + "copy.disconnected": "连接断开", + "copy.growthCount": "已进化 {n} 次", + "copy.aboutEvolution": "进化 {n} 次", + "copy.aboutEvolutionEmpty": "尚未进化", + }; + return (key, params) => { + counts[key] = (counts[key] ?? 0) + 1; + let s = dict[key] ?? key; + if (params) { + s = s.replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m)); + } + return s; + }; +} + +describe("toolPhrases", () => { + it("known tool → doing/done phrases", () => { + const t = makeT({}); + expect(toolPhrases("bash", t)).toEqual({ doing: "运行中", done: "已完成" }); + }); + it("missing tool → fallback phrases", () => { + const t = makeT({}); + expect(toolPhrases(null, t)).toEqual({ doing: "处理中", done: "完成" }); + expect(toolPhrases("", t)).toEqual({ doing: "处理中", done: "完成" }); + }); +}); + +describe("buildCopy", () => { + it("builds the copy dict with interpolated functions", () => { + const t = makeT({}); + const copy = buildCopy(t); + expect(copy.disconnected).toBe("连接断开"); + expect(copy.growthCount(5)).toBe("已进化 5 次"); + expect(copy.aboutEvolution(3)).toBe("进化 3 次"); + expect(copy.aboutEvolution(0)).toBe("尚未进化"); + }); +}); + +describe("createCopywriting", () => { + it("exposes COPY, TOOL_FAIL_TEXT and refresh() rebuilds on locale switch", () => { + const zhT = makeT({}); + const copy = createCopywriting(zhT); + expect(copy.COPY.disconnected).toBe("连接断开"); + expect(copy.TOOL_FAIL_TEXT).toBe("操作失败"); + expect(copy.toolPhrases("bash").done).toBe("已完成"); + + // locale 切换:注入不同 t 的实例 → refresh 重建(vanilla refresh() 语义) + const enT: TranslateFn = (key) => (key === "copy.disconnected" ? "Disconnected" : key); + const en = createCopywriting(enT); + expect(en.COPY.disconnected).toBe("Disconnected"); + en.refresh(); + expect(en.COPY.disconnected).toBe("Disconnected"); + }); +}); diff --git a/emrg/gui/renderer/src/lib/copywriting.ts b/emrg/gui/renderer/src/lib/copywriting.ts new file mode 100644 index 00000000..2d6c8a84 --- /dev/null +++ b/emrg/gui/renderer/src/lib/copywriting.ts @@ -0,0 +1,89 @@ +import type { TranslateFn } from "./utils"; + +/** + * copywriting.ts — 去黑话文案映射(vanilla renderer/js/copywriting.js 迁移,Batch 1)。 + * 纯逻辑:t 由调用方注入(i18n context),不依赖 window.EMRG_I18N。 + * 契约与 vanilla 版一致:toolPhrases / buildCopy / refresh / TOOL_FAIL_TEXT。 + */ +export interface ToolPhrases { + doing: string; + done: string; +} + +export interface CopyDict { + disconnected: string; + reconnected: string; + sessionBusy: string; + sendFailed: string; + deleteConfirmTitle: string; + deleteConfirmBody: string; + noSessions: string; + aboutEvolution: (n: number) => string; + growthCount: (n: number) => string; + growthNote: string; + evolutionToastTitle: string; + evolutionToastMsg: (n: number) => string; + evolutionToastSee: string; + evolutionToastDismiss: string; +} + +/** 工具名 → 进行中 / 完成 短语(渐进披露:默认折叠,点开展示原始输出) */ +export function toolPhrases(name: string | undefined | null, t: TranslateFn): ToolPhrases { + const base = name || "fallback"; + return { + doing: t(`tool.${base}.doing`), + done: t(`tool.${base}.done`), + }; +} + +/** 系统状态文案(locale 敏感:切换语言后 refresh 重建;含 #501 P3 成长卡/toast 键) */ +export function buildCopy(t: TranslateFn): CopyDict { + return { + disconnected: t("copy.disconnected"), + reconnected: t("copy.reconnected"), + sessionBusy: t("copy.sessionBusy"), + sendFailed: t("copy.sendFailed"), + deleteConfirmTitle: t("copy.deleteConfirmTitle"), + deleteConfirmBody: t("copy.deleteConfirmBody"), + noSessions: t("copy.noSessions"), + aboutEvolution: (n) => + n ? t("copy.aboutEvolution", { n }) : t("copy.aboutEvolutionEmpty"), + // WorkBuddy P3(#501):成长卡 + 进化 toast + growthCount: (n) => t("copy.growthCount", { n }), + growthNote: t("copy.growthNote"), + evolutionToastTitle: t("copy.evolutionToastTitle"), + evolutionToastMsg: (n) => t("copy.evolutionToastMsg", { n }), + evolutionToastSee: t("copy.evolutionToastSee"), + evolutionToastDismiss: t("copy.evolutionToastDismiss"), + }; +} + +/** 实例工厂:持有当前 COPY + failText,refresh() 重建(locale 切换时调用) */ +export interface Copywriting { + toolPhrases: (name: string | undefined | null) => ToolPhrases; + COPY: CopyDict; + refresh: () => void; + /** 工具失败文案(字符串属性,chat 区直接赋值用) */ + TOOL_FAIL_TEXT: string; +} + +export function createCopywriting(t: TranslateFn): Copywriting { + let COPY = buildCopy(t); + let failText = t("tool.failText"); + + function refresh(): void { + COPY = buildCopy(t); + failText = t("tool.failText"); + } + + return { + toolPhrases: (name) => toolPhrases(name, t), + get COPY() { + return COPY; + }, + refresh, + get TOOL_FAIL_TEXT() { + return failText; + }, + }; +} diff --git a/emrg/gui/test/preload-api.test.js b/emrg/gui/test/preload-api.test.js new file mode 100644 index 00000000..aaf247cf --- /dev/null +++ b/emrg/gui/test/preload-api.test.js @@ -0,0 +1,144 @@ +"use strict"; +/** + * preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。 + * + * 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload + * API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅 + * (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个 + * API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是 + * "window.emrg 方法集合原样保留",本测试把整个契约钉死: + * + * 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名) + * 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移) + * 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移) + * + * 实现:静态解析 preload.js 的 api 对象字面量(vm 执行会因 require("electron") + * 失败,故用正则提取 api 块的键与调用),与冻结的期望清单比对。 + */ + +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); + +const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8"); + +/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */ +const EXPECTED_INVOKE_METHODS = [ + "init", + "sendMessage", + "listSessions", + "restartDaemon", + "switchSession", + "newSession", + "deleteSession", + "closeSession", + "getOpenSessions", + "renameSession", + "setModel", + "clearSession", + "compactSession", + "listHistory", + "rewindSession", + "listMemories", + "readMemory", + "listFiles", + "readFile", + "previewHtml", + "closePreview", + "panelResized", + "getPreviewState", + "listSkills", + "listProjects", + "listProjectSessions", + "registerProject", + "removeProject", + "listTasks", + "triggerTask", + "taskCreate", + "taskUpdate", + "taskDelete", + "taskTemplateList", + "taskTemplateCreate", + "taskTemplateUpdate", + "taskTemplateDelete", + "sendRant", + "listRants", + "evolutionSummary", + "githubStatus", + "githubConnect", + "githubDisconnect", + "githubConnectWeb", + "openExternal", + "listModels", + "openFile", + "saveSettings", + "getSettings", + "cancel", + "pickProjectDir", + "log", +]; + +const EXPECTED_EVENT_SUBSCRIPTIONS = ["onEvent"]; + +/** 提取 api 对象字面量块(const api = { ... };) */ +function extractApiBlock() { + const m = PRELOAD.match(/const api = \{([\s\S]*?)\n\};/); + assert.ok(m, "preload.js 中未找到 const api = { ... } 对象"); + return m[1]; +} + +/** 解析 api 块 → { invoke: Map<方法名, 频道>, subs: Map<方法名, 事件频道> } */ +function parseApiBlock(block) { + const invoke = new Map(); + const subs = new Map(); + for (const line of block.split("\n")) { + // 普通 invoke 方法: name: (...) => ipcRenderer.invoke("emrg:xxx", ...) + const invokeMatch = line.match( + /^\s{2}(\w+):\s*\([^)]*\)\s*=>\s*ipcRenderer\.invoke\("([^"]+)"\s*[,)]/, + ); + if (invokeMatch) { + invoke.set(invokeMatch[1], invokeMatch[2]); + continue; + } + // onEvent 订阅: onEvent: (cb) => { ... ipcRenderer.on("emrg:event", ...) } + const subMatch = line.match(/^\s{2}(\w+):\s*\([^)]*\)\s*=>\s*\{/); + if (subMatch) { + subs.set(subMatch[1], ""); + } + } + // onEvent 的订阅频道从函数体内提取 + const onEventBody = block.match(/onEvent:[\s\S]*?ipcRenderer\.on\("([^"]+)"\s*,/); + if (onEventBody) subs.set("onEvent", onEventBody[1]); + return { invoke, subs }; +} + +const api = parseApiBlock(extractApiBlock()); + +test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => { + // 52 invoke 方法全部存在(防误删/改名) + for (const name of EXPECTED_INVOKE_METHODS) { + assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`); + } + assert.strictEqual(api.invoke.size, EXPECTED_INVOKE_METHODS.length, "invoke 方法数不符(新方法需同步更新本测试的冻结清单)"); + // onEvent 订阅存在 + for (const name of EXPECTED_EVENT_SUBSCRIPTIONS) { + assert.ok(api.subs.has(name), `缺少订阅方法 ${name}`); + } + // 没有多余/未知的方法(防意外新增破坏契约——新增 API 必须显式更新冻结清单) + const allNames = new Set([...api.invoke.keys(), ...api.subs.keys()]); + const expectedAll = new Set([...EXPECTED_INVOKE_METHODS, ...EXPECTED_EVENT_SUBSCRIPTIONS]); + for (const name of allNames) { + assert.ok(expectedAll.has(name), `api 中出现未在冻结清单中的方法 ${name}`); + } +}); + +test("every invoke method maps to the emrg: channel (no channel drift)", () => { + for (const [name, channel] of api.invoke) { + assert.strictEqual(channel, `emrg:${name}`, `方法 ${name} 的 IPC 频道 ${channel} 应为 emrg:${name}`); + } +}); + +test("onEvent subscribes to the emrg:event broadcast channel", () => { + assert.strictEqual(api.subs.get("onEvent"), "emrg:event", "onEvent 应订阅 emrg:event 广播频道"); +});