From 168afe18db3ba94e6281cd66251afbc4f316630b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 11 Aug 2026 13:27:36 +0800 Subject: [PATCH] emrg: result-panel per-session artifact isolation (render gate + switch restore) --- Agent.md | 4 +-- README.cn.md | 2 +- README.md | 2 +- emrg/gui/renderer/js/result-panel.js | 24 +++++++++++++---- emrg/gui/test/renderer.smoke.test.js | 39 ++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/Agent.md b/Agent.md index 2d0abe8c..a1bc10de 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ 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` (193: 43 daemon_client + 19 conn-manager + 22 app-commands + 72 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (195: 43 daemon_client + 19 conn-manager + 22 app-commands + 74 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); 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) @@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (694) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (193: 43 daemon_client + 19 conn-manager + 22 app-commands + 72 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (195: 43 daemon_client + 19 conn-manager + 22 app-commands + 74 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — 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 00d74264..8d9634d6 100644 --- a/README.cn.md +++ b/README.cn.md @@ -147,7 +147,7 @@ EMRG 不只是追赶——它自己追上来。 贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。 -快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(193 项:43 daemon_client + 19 conn-manager + 22 app-commands + 72 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) +快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(195 项:43 daemon_client + 19 conn-manager + 22 app-commands + 74 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) --- diff --git a/README.md b/README.md index 1aaf6680..44642948 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ They're products. EMRG is an experiment in *closing the loop* — the AI improve Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md). -Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (193: 43 daemon_client + 19 conn-manager + 22 app-commands + 72 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) +Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (195: 43 daemon_client + 19 conn-manager + 22 app-commands + 74 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) --- diff --git a/emrg/gui/renderer/js/result-panel.js b/emrg/gui/renderer/js/result-panel.js index 1cc67059..3570d578 100644 --- a/emrg/gui/renderer/js/result-panel.js +++ b/emrg/gui/renderer/js/result-panel.js @@ -194,12 +194,13 @@ const ResultPanel = (() => { if ((sid || null) === currentSid) { renderTabbar(); activateTab(st.active, sid); } } - /** 切换会话 → Tab 状态按 sid 隔离(缺口 5) */ + /** 切换会话 → Tab/产物状态按 sid 隔离(缺口 5) */ function switchSession(sid) { currentSid = sid || null; stateFor(currentSid); renderTabbar(); activateTab(stateFor(currentSid).active); + renderArtifacts(); // 产物 pane 按当前会话桶恢复(后台 tool_finished 只入桶不渲染) } // ── 产物登记(P1 卡片渲染保留;P3.2 改 write/edit 文件登记) ── @@ -214,14 +215,27 @@ const ResultPanel = (() => { /** 登记一个产物条目(tool_finished 事件;sid = 事件桥会话,P3.2 消费) */ function addToolResult(data, sid) { const arr = artifactsFor(sid || currentSid); - arr.unshift({ + const record = { tool_name: data.tool_name || "tool", content: String(data.content || ""), error: !!data.error, elapsed: data.elapsed, - }); + }; + arr.unshift(record); if (arr.length > 100) arr.pop(); - renderCard(data); + // 后台会话:只入桶不渲染(防污染激活会话产物 pane;切回时由 renderArtifacts 恢复) + if ((sid || null) !== currentSid) return; + renderCard(record); + } + + /** 按当前会话桶重渲染产物 pane(switchSession/init 时从桶恢复 DOM,镜像 renderTabbar 模式) */ + function renderArtifacts() { + const list = listEl(); + if (!list) return; + list.innerHTML = ""; + const arr = artifactsFor(currentSid); + if (arr.length === 0) { renderEmpty(); return; } + for (const rec of arr.slice(0, MAX_ITEMS)) renderCard(rec); } function renderCard(data) { @@ -354,7 +368,7 @@ const ResultPanel = (() => { window.addEventListener("resize", updateResizerPos); renderTabbar(); activateTab(stateFor(currentSid).active); - renderEmpty(); + renderArtifacts(); } return { init, addToolResult, toggle, isCollapsed, switchSession, openFileTab, closeFileTab, activateTab, getWidth, setWidth }; diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 100c3a80..a4d48c07 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1025,6 +1025,45 @@ test("P2 框架:per-session Tab 状态隔离(切会话各显各的)", asyn assert.ok(els["result-tabbar"].children[0].classList.contains("active"), "s1 的激活 Tab 应保留"); }); +test("P2 框架:后台会话 tool_finished 只入桶不渲染(防污染激活 pane)", async () => { + const { ctx } = makeSandbox(); + await tick(); + await vm.runInContext('ResultPanel.switchSession("s1")', ctx); + // 后台会话 s2 的 tool_finished → 只入桶不渲染 + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "write", content: "Wrote file: /bg.txt", elapsed: 0.2 }, "s2")', ctx); + assert.strictEqual(vm.runInContext('document.getElementById("result-list").children.length', ctx), 0, "后台会话产物不得渲染到激活 pane"); + // 激活会话 s1 的 tool_finished → 渲染 + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "write", content: "Wrote file: /active.txt", elapsed: 0.3 }, "s1")', ctx); + assert.strictEqual(vm.runInContext('document.getElementById("result-list").children.length', ctx), 1, "激活会话产物应渲染"); +}); + +test("P2 框架:switchSession 按 sid 重渲染产物 pane(桶→DOM 恢复)", async () => { + const { ctx } = makeSandbox(); + await tick(); + // 两会话各自登记(初始非激活 → 只入桶) + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "bash", content: "s1-out", elapsed: 0.1 }, "s1")', ctx); + await vm.runInContext('ResultPanel.addToolResult({ tool_name: "bash", content: "s2-out", elapsed: 0.2 }, "s2")', ctx); + // 递归收集 list 文本(mock 元素 textContent 是属性,不入 innerHTML) + const collectText = `(function(){ + function collect(node) { + let out = node.textContent || ""; + for (const c of node.children || []) out += collect(c); + return out; + } + return collect(document.getElementById("result-list")); + })()`; + // 切到 s2 → pane 只显示 s2 的记录 + await vm.runInContext('ResultPanel.switchSession("s2")', ctx); + const text2 = vm.runInContext(collectText, ctx); + assert.ok(String(text2).includes("s2-out"), "s2 pane 应显示 s2 产物"); + assert.ok(!String(text2).includes("s1-out"), "s2 pane 不得显示 s1 产物"); + // 切回 s1 → pane 只显示 s1 的记录(s2 卡片不残留) + await vm.runInContext('ResultPanel.switchSession("s1")', ctx); + const text1 = vm.runInContext(collectText, ctx); + assert.ok(String(text1).includes("s1-out"), "s1 pane 应显示 s1 产物"); + assert.ok(!String(text1).includes("s2-out"), "s1 pane 不得显示 s2 产物"); +}); + test("工具调用上限中断 → 系统提示可继续(对齐 TUI,跨项目教训)", async () => { const { ctx, els } = makeSandbox({}); await tick();