From 2fb904a17f8800f04c0a13a240e69fdad32946fe Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 17:14:41 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20renderer=20sessionsBySid=20state?= =?UTF-8?q?=20table=20=E2=80=94=20P3=20slice=201=20(GUI=20multi-session=20?= =?UTF-8?q?rant=20P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 +- README.cn.md | 2 +- README.md | 2 +- emrg/gui/renderer/js/app.js | 75 ++++++++++++++++++++------- emrg/gui/test/renderer.smoke.test.js | 76 ++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 21 deletions(-) diff --git a/Agent.md b/Agent.md index 22f8897..ea9ce0b 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` (146: 43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); 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` (680) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (146: 43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — 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 0956280..cc900a4 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 测试(146 项:43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(150 项:43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 1e0f7bf..fdcfeb5 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 (146: 43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (150: 43 daemon_client + 17 conn-manager + 22 app-commands + 40 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; 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/renderer/js/app.js b/emrg/gui/renderer/js/app.js index e1657dc..da55374 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -14,8 +14,11 @@ const App = (() => { const state = { sessionId: null, sessions: [], - busy: false, - ownStreamRequestId: null, + // P3 slice 1(rant 15:07:19):会话级状态表——每会话独立 busy/ownStreamRequestId/ + // mode/autoScroll。busy/ownStreamRequestId/mode 为**激活会话条目**的实时视图 + // (defineProperty getter/setter → sidState(sessionId)),既有调用点零改动; + // 事件按 sid 路由时操作对应条目(后台会话的 done 不误清激活会话的 busy)。 + sessionsBySid: new Map(), // sid → { busy, ownStreamRequestId, mode, autoScroll } apiKeyConfigured: false, configExists: false, projectDir: "", @@ -32,6 +35,29 @@ const App = (() => { mode: "auto", }; + // P3 slice 1:会话条目访问器(get-or-create)。无 sid/未激活 → 归入当前激活会话。 + function sidState(sid) { + const key = sid || state.sessionId || "default"; + if (!state.sessionsBySid.has(key)) { + state.sessionsBySid.set(key, { busy: false, ownStreamRequestId: null, mode: "auto", autoScroll: true }); + } + return state.sessionsBySid.get(key); + } + + // 激活会话条目 = state.busy/ownStreamRequestId/mode 的事实源(P3 过渡期兼容层) + Object.defineProperty(state, "busy", { + get() { return sidState(state.sessionId).busy; }, + set(v) { sidState(state.sessionId).busy = v; }, + }); + Object.defineProperty(state, "ownStreamRequestId", { + get() { return sidState(state.sessionId).ownStreamRequestId; }, + set(v) { sidState(state.sessionId).ownStreamRequestId = v; }, + }); + Object.defineProperty(state, "mode", { + get() { return sidState(state.sessionId).mode; }, + set(v) { sidState(state.sessionId).mode = v; }, + }); + // ── 启动 ───────────────────────────────── async function boot() { try { @@ -535,7 +561,8 @@ const App = (() => { try { const res = await window.emrg.switchSession({ sessionId: sid }); state.sessionId = sid; - state.ownStreamRequestId = null; // G110:切会话清自有流标记 + // P3 slice 1:切换只移动激活指针——每会话条目保留自己的 busy/ownStreamRequestId + // (G110 旧语义=清全局自有流标记;per-sid 后由各会话条目自己管理,P4 切回继续生成) Chat.clear(); updateEmptyState(); if (res.error === "session_not_found") { @@ -970,10 +997,15 @@ const App = (() => { break; case "done": Chat.handleDone(data, sid); - if (data.request_id && (state.ownStreamRequestId === data.request_id || data.timeout)) { - state.busy = false; - state.ownStreamRequestId = null; - setComposerDisabled(false); + // P3 slice 1:done 释放**该事件所属会话**的 busy 锁(后台会话的广播 done + // 不误清激活会话);仅当该会话是激活会话时同步输入条 UI。 + { + const sst = sidState(sid); + if (data.request_id && (sst.ownStreamRequestId === data.request_id || data.timeout)) { + sst.busy = false; + sst.ownStreamRequestId = null; + if (!sid || sid === state.sessionId) setComposerDisabled(false); + } } break; case "tool_started": @@ -985,9 +1017,13 @@ const App = (() => { break; case "cancelled": Chat.clearTyping(sid); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清) - state.busy = false; - state.ownStreamRequestId = null; - setComposerDisabled(false); + // P3 slice 1:cancelled 释放该事件所属会话的锁(后台会话取消不误清激活会话) + { + const sst = sidState(sid); + sst.busy = false; + sst.ownStreamRequestId = null; + if (!sid || sid === state.sessionId) setComposerDisabled(false); + } break; case "error": handleError(data, sid); @@ -1010,10 +1046,14 @@ const App = (() => { case "disconnected": updateConnectionDot("red"); showBanner(EMRG_Copy.COPY.disconnected); - // G89:断连时恢复输入条(不能依赖 30s 超时兜底) - state.busy = false; - state.ownStreamRequestId = null; - setComposerDisabled(false); + // G89:断连时恢复输入条(不能依赖 30s 超时兜底)——仅当激活会话断连 + // P3 slice 1:断连释放该事件所属会话的锁 + { + const sst = sidState(sid); + sst.busy = false; + sst.ownStreamRequestId = null; + if (!sid || sid === state.sessionId) setComposerDisabled(false); + } // P3:广播分组缓存清理按会话隔离(DOM 保留;仅清该会话 Map 引用;无 sid → 默认桶) Chat.groupNodesFor(sid).clear(); // 进行中的工具行 → 结果未知(工具副作用不可重放) @@ -1047,11 +1087,12 @@ const App = (() => { } function handleError(data, sid) { + const sst = sidState(sid); if (data.error && String(data.error).includes("session busy")) { Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy, sid); - state.busy = false; - state.ownStreamRequestId = null; - setComposerDisabled(false); + sst.busy = false; + sst.ownStreamRequestId = null; + if (!sid || sid === state.sessionId) setComposerDisabled(false); } else { Chat.clearTyping(sid); // rant 14:11:流式错误时移除在途节点 typing 光标 Chat.addSystemMessage(_t("app.error", { msg: data.error || _t("app.unknownError") }), sid); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index bf6d6f4..178b547 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -984,3 +984,79 @@ test("P3: registerContainer 后该会话渲染进独立容器;无 sid 回退 assert.strictEqual(extra.children.length, 1, "unregistered container no longer receives"); assert.strictEqual(els["chat-view"].children.length, 2, "falls back to default container"); }); + +// ── P3 slice 1(rant 15:07:19):renderer sessionsBySid 会话级状态表 ───── + +test("P3 s1: state.busy/ownStreamRequestId 路由到激活会话条目(get-or-create)", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-1";' + + 'App.state.busy = true;' + + 'App.state.ownStreamRequestId = "req-1";', + ctx + ); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-1").busy, true, "busy stored in sess-1 entry"); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-1").ownStreamRequestId, "req-1"); + assert.strictEqual(ctx.App.state.busy, true, "state.busy reads active entry"); +}); + +test("P3 s1: 切换会话后 state.busy 指向新会话条目(各会话独立)", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-a";' + + 'App.state.busy = true;' + + 'App.state.sessionId = "sess-b";' + + 'App.state.busy = false;', + ctx + ); + const sa = ctx.App.state.sessionsBySid.get("sess-a"); + const sb = ctx.App.state.sessionsBySid.get("sess-b"); + assert.strictEqual(sa.busy, true, "sess-a stays busy after switching away"); + assert.strictEqual(sb.busy, false, "sess-b independent"); +}); + +test("P3 s1: done 带 sid → 只释放该会话条目;后台会话 done 不误清激活会话", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-active";' + + 'App.state.busy = true;' + + 'App.state.ownStreamRequestId = "req-active";' + + // 模拟后台会话的流:事件带 sid=sess-bg(非激活)→ 释放 bg 条目,不动 active + 'window.emrg.onEvent.calls = window.emrg.onEvent.calls || [];', + ctx + ); + // 直接驱动 handleEvent:后台会话 done + await vm.runInContext( + 'App.handleEvent({ type: "done", sid: "sess-bg", data: { request_id: "req-bg", done: true } });', + ctx + ); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-bg").busy, false, "bg entry released"); + // 激活会话未被误清(bg done 不匹配 active 的 rid——除非我们预设了 bg 的 rid) + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-active").busy, true, "active busy untouched by bg done"); + // 激活会话自己的 done(带激活 sid)→ 释放 + await vm.runInContext( + 'App.handleEvent({ type: "done", sid: "sess-active", data: { request_id: "req-active", done: true } });', + ctx + ); + assert.strictEqual(ctx.App.state.busy, false, "active released by own done"); + assert.strictEqual(els["input"].disabled, false, "composer re-enabled for active"); +}); + +test("P3 s1: cancelled 带 sid → 只清该会话条目;无 sid → 清激活会话", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-1";' + + 'App.state.busy = true;' + + 'App.handleEvent({ type: "cancelled", sid: "sess-2", data: {} });', + ctx + ); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-2").busy, false, "sess-2 cancelled released"); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-1").busy, true, "sess-1 (active) untouched"); + // 无 sid cancelled → 清激活会话 + await vm.runInContext('App.handleEvent({ type: "cancelled", data: {} });', ctx); + assert.strictEqual(ctx.App.state.busy, false, "no-sid cancelled clears active"); +});