diff --git a/Agent.md b/Agent.md index 6e72d9d8..e6169d14 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - 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` (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -94,7 +94,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (508) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + 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 路径不受影响) diff --git a/README.cn.md b/README.cn.md index fbe78f10..f0ac6103 100644 --- a/README.cn.md +++ b/README.cn.md @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(58 项:22 daemon_client + 7 integration + 25 renderer smoke + 4 app-commands;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(91 项:22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 804bc7e7..133ee551 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 70f1dd00..0b20c7eb 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -556,21 +556,31 @@ vision = false // G122:message_delta 16ms 批量推送 let deltaBuf = []; let deltaTimer = null; + // rant 14:11:冲刷 delta 缓冲——终态事件(done/error/cancelled)直通不走缓冲, + // 若残留 delta 在 16ms 定时器之后才 flush,会晚于终态到达渲染层 → + // handleDelta 找不到 group 节点 → 建孤儿节点(误标"来自其他客户端")+ 光标永不消失。 + const flushDeltaBuf = () => { + if (deltaTimer) { + clearTimeout(deltaTimer); + deltaTimer = null; + } + if (deltaBuf.length && win && !win.isDestroyed()) { + const chunks = deltaBuf; + deltaBuf = []; + win.webContents.send("emrg:event", { type: "message_delta", data: { chunks } }); + } + }; client.onEvent((type, data) => { if (type === "message_delta") { deltaBuf.push(data); if (!deltaTimer) { - deltaTimer = setTimeout(() => { - const chunks = deltaBuf; - deltaBuf = []; - deltaTimer = null; - if (win && !win.isDestroyed()) { - win.webContents.send("emrg:event", { type: "message_delta", data: { chunks } }); - } - }, 16); + deltaTimer = setTimeout(flushDeltaBuf, 16); } return; } + if (type === "done" || type === "error" || type === "cancelled") { + flushDeltaBuf(); // 终态前先清空缓冲:delta 保证不晚于终态(webContents.send 保序) + } if (type === "done") { // 仅自有流的 done 释放 G65 锁(广播 done 不影响);timeout 兜底同样只清自有 if (data.request_id === ownStreamRequestId || (data.timeout && ownStream)) { diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 267fb132..73de2d60 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -935,6 +935,7 @@ const App = (() => { ResultPanel.addToolResult(data); break; case "cancelled": + Chat.clearTyping(); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清) state.busy = false; state.ownStreamRequestId = null; setComposerDisabled(false); @@ -1003,6 +1004,7 @@ const App = (() => { state.ownStreamRequestId = null; setComposerDisabled(false); } else { + Chat.clearTyping(); // rant 14:11:流式错误时移除在途节点 typing 光标 Chat.addSystemMessage(_t("app.error", { msg: data.error || _t("app.unknownError") })); } } diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js index 1bafb51e..4699000c 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -9,6 +9,8 @@ const Chat = (() => { const groupNodes = new Map(); // tool_call_id → 工具行 DOM 节点 const toolRows = new Map(); + // rant 14:11:已 done 的 request_id(UUID 不复用)——残留 delta 直接丢弃,防孤儿节点 + const doneRids = new Set(); /** 复制代码按钮(设计 §3.3):事件委托在聊天区,CSP 无内联 handler */ function initCodeCopy() { @@ -53,6 +55,8 @@ const Chat = (() => { function append(node) { $("chat-view").appendChild(node); scrollToBottom(); + // rant 14:11:任何消息增删都重新评估欢迎屏显隐(此前只在切会话时评估 → 首条消息后欢迎屏不隐藏) + App.updateEmptyState?.(); } function scrollToBottom() { @@ -64,6 +68,8 @@ const Chat = (() => { $("chat-view").innerHTML = ""; groupNodes.clear(); toolRows.clear(); + doneRids.clear(); + App.updateEmptyState?.(); // rant 14:11:清空(切会话/新会话)也同步欢迎屏显隐 } /** 用户消息:右对齐柔和气泡 */ @@ -96,7 +102,7 @@ const Chat = (() => { function handleDelta(chunks) { for (const chunk of chunks) { const rid = chunk.request_id; - if (!rid) continue; + if (!rid || doneRids.has(rid)) continue; // rant 14:11:已 done 的流丢弃残留 delta,不建孤儿节点 let node = groupNodes.get(rid); if (!node) { const isOwn = App.state.ownStreamRequestId === rid; @@ -110,9 +116,21 @@ const Chat = (() => { } } + /** 取消/错误收尾:移除所有在途节点的 typing 光标(cancelled 事件无 request_id,只能全清) */ + function clearTyping() { + for (const node of groupNodes.values()) { + const body = node.querySelector(".msg-body") || node; + body.classList.remove("typing"); + } + } + /** done:整体 Markdown 渲染(requestIdleCallback 调度,G127) */ function handleDone(data) { const rid = data.request_id; + if (rid) { + doneRids.add(rid); + if (doneRids.size > 500) doneRids.clear(); // UUID 不复用,超限即清防长期运行增长 + } const node = groupNodes.get(rid); if (node) { const body = node.querySelector(".msg-body") || node; @@ -216,6 +234,7 @@ const Chat = (() => { handleDone, handleToolStart, handleToolEnd, + clearTyping, get groupNodes() { return groupNodes; }, diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index e5e56cd9..a1c2404d 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -210,6 +210,58 @@ test("流式 delta 追加 + 工具行 running→done 状态流转", async () => assert.ok(r.toolRowClass.includes("done"), `工具行应 done,实际 ${r.toolRowClass}`); }); +test("rant 14:11:首条消息后欢迎屏立即隐藏(append 同步 updateEmptyState)", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = vm.runInContext(`(function() { + App.state.sessionId = "s1"; + EMRG_Chat.addUserMessage("hello"); + return { + emptyHidden: $("empty-state").classList.contains("hidden"), + msgCount: $("chat-view").children.length, + }; + })()`, ctx); + assert.strictEqual(r.msgCount, 1, "消息应已追加"); + assert.strictEqual(r.emptyHidden, true, "有消息时欢迎屏应隐藏(欢迎屏不得叠在消息区上方)"); +}); + +test("rant 14:11:done 后残留 delta 被丢弃,不建孤儿节点", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = vm.runInContext(`(function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "你" }]); + EMRG_Chat.handleDone({ request_id: "rid-1" }); + const afterDone = $("chat-view").children.length; + // 模拟 16ms 批量定时器在 done 之后才 flush 的残留 delta(G122 竞态) + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "残留" }]); + const afterStale = $("chat-view").children.length; + return { afterDone, afterStale }; + })()`, ctx); + assert.strictEqual(r.afterDone, 1, "done 前 1 个节点"); + assert.strictEqual(r.afterStale, 1, "残留 delta 应被丢弃,不得产生孤儿节点(否则误标来自其他客户端 + 光标残留)"); +}); + +test("rant 14:11:cancelled 事件清除在途节点 typing 光标", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = vm.runInContext(`(function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "部分" }]); + const node = $("chat-view").children[0]; + const body = node.querySelector(".msg-body") || node; + body.classList.add("typing"); + const typingBefore = body.classList.contains("typing"); + App.handleEvent({ type: "cancelled" }); + const typingAfter = body.classList.contains("typing"); + return { typingBefore, typingAfter }; + })()`, ctx); + assert.strictEqual(r.typingBefore, true, "取消前应处于 typing 态"); + assert.strictEqual(r.typingAfter, false, "取消后 typing 光标(▍)应移除"); +}); + test("多模型管理:modelDetails 加载渲染 + saveSettings 传 models 数组", async () => { let saved = null; const { ctx, els } = makeSandbox({