diff --git a/Agent.md b/Agent.md index 0bf1fe4..20815f9 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` (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (175: 43 daemon_client + 19 conn-manager + 22 app-commands + 56 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 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) @@ -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` (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (175: 43 daemon_client + 19 conn-manager + 22 app-commands + 56 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 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 40020a5..1579341 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 测试(172 项:43 daemon_client + 17 conn-manager + 22 app-commands + 55 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(175 项:43 daemon_client + 19 conn-manager + 22 app-commands + 56 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 5278347..b1c4b60 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 (172: 43 daemon_client + 17 conn-manager + 22 app-commands + 55 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (175: 43 daemon_client + 19 conn-manager + 22 app-commands + 56 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; 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/conn-manager.js b/emrg/gui/conn-manager.js index 469f94b..42cad02 100644 --- a/emrg/gui/conn-manager.js +++ b/emrg/gui/conn-manager.js @@ -111,10 +111,16 @@ class ConnManager { // close(sid):conn.close(断开 ws)→ 移除。返回是否有关闭对象。 // 标记 _intentionalClose:主动关闭(切走/删除)不触发 renderer 断线横幅 // (桥检查该标记;真断连/daemon 重启的 disconnected 照常转发)。 + // P6(rant 15:07:19 边界):关闭在忙连接先 cancel 再 close——流式进行中 + // (ownStream)先发 cancel 让 daemon 停流,再断 ws,防半途断线留脏状态 + // (fire-and-forget:断连/ws 已 null 时忽略,不阻塞同步 close 语义)。 close(sid) { const entry = this._conns.get(sid); if (!entry) return false; this._cancelSingleRetry(sid); // 主动关闭 → 取消该会话的独立退避 + if (entry.conn.ownStream && entry.conn.ws) { + try { entry.conn.sendCommand("cancel"); } catch { /* 断连时忽略 */ } + } entry.conn._intentionalClose = true; entry.conn.close(); this._conns.delete(sid); diff --git a/emrg/gui/main.js b/emrg/gui/main.js index d1b00e0..513175a 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -13,7 +13,7 @@ const { spawn } = require("child_process"); const { parse: parseToml, stringify: stringifyToml } = require("smol-toml"); const { generateSessionId, SESSION_ID_RE } = require("./daemon_client"); const { ConnManager } = require("./conn-manager"); -const { guiStatePath, sanitizeOpenSessions, saveGuiState } = require("./gui-state"); +const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("./gui-state"); const APP_VERSION = require("./package.json").version; // ── 单实例锁(G85/G120:第二个实例退出并 focus 已有窗口)── @@ -299,6 +299,14 @@ vision = false ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + // P6(rant 15:07:19 边界):projectPath 校验(跨项目打开时传项目路径) + if (projectPath !== undefined && (typeof projectPath !== "string" || !projectPath.trim())) { + throw new Error("invalid project path"); + } + // P6(rant 15:07:19 上限 20):显式打开新会话超限 → 提示不自动关(已打开 sid 复用不拦) + if (openSessions.size >= DEFAULT_CAP && !openSessions.has(sessionId)) { + throw new Error(`too many open sessions (${DEFAULT_CAP}) — close some first`); + } // G65:自有流运行中禁止切会话(每连接独立锁,查当前激活连接) if (connManager?.get(currentSessionId)?.ownStream) throw new Error("stream in progress — cannot switch"); const prevSid = currentSessionId; @@ -359,6 +367,10 @@ vision = false })); ipcMain.handle("emrg:newSession", async (_e, { projectPath } = {}) => { + // P6(rant 15:07:19 边界):projectPath 校验(新建会话指定项目时) + if (projectPath !== undefined && (typeof projectPath !== "string" || !projectPath.trim())) { + throw new Error("invalid project path"); + } // G14/G81:本地生成 session_id(无 new_session 消息) const sid = generateSessionId(); // 同步 main 侧会话状态:重连后 resume 正确会话(G41)+ 窗口标题(G109) diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index fd08faa..91aacad 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -628,7 +628,8 @@ const App = (() => { Sidebar.highlight(sid); setComposerDisabled(false); // 防御性:独立调用 switchSession 也确保输入框可用 } catch (e) { - Chat.addSystemMessage(_t("app.switchFailed", { msg: e.message })); + // P6(rant 15:07:19 上限 20):超限提示本地化(main 抛 too many open sessions) + Chat.addSystemMessage(/too many open sessions/i.test(e.message || "") ? _t("app.tooManyOpenSessions") : _t("app.switchFailed", { msg: e.message })); } } diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 6ccea2a..8122e78 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -312,6 +312,7 @@ const I18N = (() => { "app.switched": "已切换对话。", "app.sessionDisconnected": "该会话连接已断开,正在自动重连…", "app.switchFailed": "切换对话失败了:{msg}", + "app.tooManyOpenSessions": "打开的会话已达上限(20),请先关闭一些再打开。", "app.newFailed": "新建对话失败了:{msg}", "app.deleteFailed": "删除失败了:{msg}", "app.rename": "✏️ 重命名", @@ -632,6 +633,7 @@ const I18N = (() => { "app.switched": "Conversation switched.", "app.sessionDisconnected": "This session's connection is lost — reconnecting automatically…", "app.switchFailed": "Failed to switch: {msg}", + "app.tooManyOpenSessions": "Too many open sessions (20) — close some first.", "app.newFailed": "Failed to create conversation: {msg}", "app.deleteFailed": "Delete failed: {msg}", "app.rename": "✏️ Rename", diff --git a/emrg/gui/test/conn-manager.test.js b/emrg/gui/test/conn-manager.test.js index b008cb6..0b75aa5 100644 --- a/emrg/gui/test/conn-manager.test.js +++ b/emrg/gui/test/conn-manager.test.js @@ -180,6 +180,29 @@ test("P2 close: 主动关闭标记 _intentionalClose 且不触发重启恢复", assert.strictEqual(recoverCalls, 0, "intentional close must not trigger restart recovery"); }); +test("P6 close: 在忙连接(ownStream)先 cancel 再 close", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + // 停用自动恢复(本测试专注 close 行为;单连接全关会触发重启判定) + manager.recoverAll = async () => {}; + const busy = await driveOpen(manager, "sess-busy", "/proj/a"); + busy.conn.ownStream = true; + busy.sessionWs.sent.length = 0; // 清空历史帧,聚焦 close 行为 + assert.strictEqual(manager.close("sess-busy"), true); + const lastFrame = JSON.parse(busy.sessionWs.sent.at(-1)); + assert.strictEqual(lastFrame.type, "cancel", "busy close must send cancel before disconnect"); + assert.strictEqual(busy.conn.connected, false, "close must disconnect the ws"); +}); + +test("P6 close: 空闲连接 close 不 cancel", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + manager.recoverAll = async () => {}; + const idle = await driveOpen(manager, "sess-idle", "/proj/a"); + idle.sessionWs.sent.length = 0; + assert.strictEqual(manager.close("sess-idle"), true); + assert.strictEqual(idle.sessionWs.sent.length, 0, "idle close must not send cancel"); + assert.strictEqual(idle.conn.connected, false, "close must disconnect the ws"); +}); + test("P2 open: 断连残留连接 → 关闭重开(不返回 stale conn)", async () => { const manager = new ConnManager({ projectDir: tmpHome }); const s1 = await driveOpen(manager, "sess-1", "/proj/a"); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index d086b8a..390dc0d 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1457,3 +1457,22 @@ test("P5 slice 2: 删除项目 — 受保护 emrg 提示不可删;普通项目 assert.strictEqual(removeCalls[0] && removeCalls[0].name, "mem", "project name passed"); assert.strictEqual(removeCalls[0] && removeCalls[0].path, "/p/mem", "project path passed"); }); + +// ── P6(rant 15:07:19):上限 20 超限提示本地化 + 边界 ── + +test("P6: switchSession 超限(too many open sessions)→ 本地化提示(非英文原始错误)", async () => { + const { ctx, els } = makeSandbox({ + switchSession: async () => { throw new Error("too many open sessions (20) — close some first"); }, + }); + await tick(); + await vm.runInContext('App.switchSession("s-over", { projectPath: "/p/x" })', ctx); + await tick(); + // Chat.addSystemMessage 渲染进 chat-view 容器(系统消息节点 textContent) + const texts = els["chat-view"].children.map((c) => c.textContent || ""); + const last = texts[texts.length - 1] || ""; + assert.ok( + last.includes("上限") || last.includes("Too many open sessions"), + "localized over-limit message shown, got: " + last + ); + assert.ok(!last.includes("close some first"), "raw english error must not leak: " + last); +}); diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index ab1169a..1e4d81a 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -392,6 +392,7 @@ When reading rants, follow these rules: > - GUI open-sessions sidebar + gui_state restore (#639 GUI 多会话 rant 2026-08-10T15:07:19 P4 slice 2 读路径/侧边栏:main.js `restoreOpenSessions`(init 读 gui_state.json → 重开有效条目 cap 20 + resume 重订阅 → 失效跳过 + 重写盘 → activeSid 恢复/回退最近有效 → 窗口标题同步)+ `broadcastOpenSessions`(打开/激活/关闭/删除/新建每次变更推 `open_sessions` 事件给 renderer)+ init 返回 `open_sessions` + `active_sid`(renderer 直接采用恢复激活会话,免 switchSession IPC 往返);sidebar.js `renderOpenSessions`(侧边栏顶部跨项目打开会话区:项目名/会话标题、lastActive 倒序、激活高亮、点击切换、右键关闭保留数据/重命名/删除)+ highlight 扩展覆盖;app.js `state.openSessions` + `open_sessions` 事件 + `closeOpenSession`(断开+释放容器+保留磁盘数据;关激活会话 → 切最近打开会话否则新建)+ showOpenSessionsMenu;index.html `#open-sessions` + i18n zh/en + CSS;+4 测试 renderer.smoke 46→50 + mock 升级(querySelectorAll DFS 类选择器 + classList.toggle 忠实 force);GUI 163→167 三文档同步;680 pytest 全绿;P4 完成,P5-P6 待续) ✅ > - GUI open-session dialog project→session (#641 GUI 多会话 rant 2026-08-10T15:07:19 P5 slice 1:打开会话弹窗两步——`showOpenSessionDialog`(listProjects → 项目行含路径 hint + 底部"+ 新建项目…")+ `showProjectSessions`(listProjectSessions(cwd=projectPath) → created_at 倒序 → 点击 switchSession 复用连接);main.js `emrg:listProjectSessions`/`emrg:registerProject` IPC(G121 目录可写校验;list_sessions(cwd) 轻量命令 → daemon 隐式 `_touch_project` 注册,零 daemon 改动,**不调 init_auto_evolve** 防意外建演化任务);preload 暴露两 API;app.js `/open` 指令(commands 15→16)+ bindUi 初始化;i18n zh/en 10 条 + cmd.open.hint;+2 测试 renderer.smoke 50→52(项目→会话下钻 / 无项目提示);GUI 167→169 三文档同步;680 pytest 全绿;P5 剩余=slice 2 新建会话 + 删除项目) ✅ > - GUI new-session dialog + delete-project protected guard (#642 GUI 多会话 rant 2026-08-10T15:07:19 P5 slice 2:**新建会话弹窗** `showNewSessionDialog`(listProjects 活跃序 → 点选即 `App.newSession({projectPath})`;底部"+ 新建项目…" → pickProjectDir → registerProject → 同路径新建)+ index.html `#new-session-dialog` + 打开弹窗顶部"+ 新建会话…"入口;**删除项目**:打开弹窗项目行右侧删除按钮 → 受保护守卫(内置 project `emrg` / 内置 task `emrg-task` 提示"系统项目不可删除",不调 API;`.emrg` 非内置可删)→ 确认弹窗(数据保留可恢复)→ main.js `emrg:removeProject` IPC(关闭该项目已打开会话连接 + 移出簿记 + 写盘 + 激活被关 → renderer 切相邻/新建 + 广播 open_sessions)→ daemon `remove_project`(P1 已备);**slice-1 补洞**:switchSession/sendMessage 带 per-session projectPath(跨项目 resume 用项目 cwd 非全局 projectDir);newSession 接受 projectPath(首条消息前即记簿记);i18n zh/en 11 条;+3 测试 renderer.smoke 52→55(新建会话选项目 / 新建项目→注册→新建 / 删除项目受保护+普通确认);GUI 169→172 三文档同步;680 pytest 全绿;P5 完成,P6 收尾待续) ✅ +> - GUI multi-session P6 finalize (#643 GUI 多会话 rant 2026-08-10T15:07:19 收尾边界:①**关闭在忙连接先 cancel 再 close**——ConnManager.close() 检测 `conn.ownStream && conn.ws` 先发 cancel(fire-and-forget 吞断连异常,不阻塞同步 close 语义),防流式半途断线留脏状态;②**上限 20 超限提示不自动关**——switchSession 显式打开新会话时 `openSessions.size >= DEFAULT_CAP && !openSessions.has(sid)` → 抛 "too many open sessions (20) — close some first"(已打开 sid 复用不拦;sendMessage/newSession 创建路径不拦);renderer 识别该错误 → 本地化 `app.tooManyOpenSessions` zh/en(不再漏英文原始错误);③**projectPath 校验**——switchSession/newSession IPC 收 projectPath 时校验 string 非空;+3 测试(conn-manager 17→19:忙 close 发 cancel / 空闲 close 不 cancel;renderer.smoke 55→56:超限本地化提示);GUI 172→175 三文档同步;680 pytest 全绿;P1-P6 全部完成,验收清单剩余宿主实测项) ✅ #### 2.2 Latest GitHub code changes