diff --git a/Agent.md b/Agent.md index 9cc5e903..060bef6d 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1062) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1064) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 20 conn-manager + 22 app-commands + 129 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` 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 路径不受影响) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 0dad6b43..2d5eaad3 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -52,6 +52,26 @@ function main() { let openSessions = new Map(); let guiStateTimer = null; const GUI_STATE_DEBOUNCE_MS = 1000; + // Rant 2026-08-25T17:38:56(GUI 会话串线/空历史,根因 1+2):会话级命令的 cwd + // 必须取该会话**真实所属项目**——兜底 DEFAULT_CWD(homedir)会让所有 GUI 会话 + // 历史从 ~/.emrg/sessions/ 读取 → 一律空窗口,且 resume/task 在错误 cwd 误建 + // 0-message 幽灵会话。解析顺序:openSessions 簿记 projectPath → 全局 + // sessions_index.json(sid → 会话目录,cwd = 目录上溯 3 级)→ null(未知)。 + // 调用方对 null 决策:新会话兜底 DEFAULT_CWD,已存在会话交由 daemon not_found + // 处理(switchSession 已有 fallback),绝不隐式回退 homedir。 + function resolveSessionCwd(sessionId) { + const known = openSessions.get(sessionId)?.projectPath; + if (known) return known; + try { + const idx = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".emrg", "sessions_index.json"), "utf8")); + const dir = idx[sessionId]; + if (typeof dir === "string" && dir) { + // dir = /.emrg/sessions/ → cwd = 上溯 3 级(兼容 home 级会话 ~/.emrg/sessions/…) + return path.dirname(path.dirname(path.dirname(dir))); + } + } catch { /* index 缺失/损坏 → 未知(null) */ } + return null; + } // P2.3(rant 12:20:35):HTML 预览 WebContentsView——懒创建、单实例复用、右对齐 bounds 同步。 // renderer 崩溃 reload 后由 renderer 侧拉取(emrg:getPreviewState)恢复(main 是真相源)。 let previewView = null; // WebContentsView 实例(首次打开 HTML tab 才创建) @@ -300,7 +320,7 @@ vision = false } // P2:每会话独立连接——首条消息前自动打开(新会话不 resume,daemon 隐式订阅) // P5 slice 2:cwd 取该会话所属项目(跨项目会话用其项目路径,非全局 projectDir) - const sessionCwd = openSessions.get(sessionId)?.projectPath || DEFAULT_CWD; + const sessionCwd = resolveSessionCwd(sessionId) || DEFAULT_CWD; let conn = connManager?.get(sessionId); if (!conn || !conn.connected) { conn = await openSession(sessionId, sessionCwd, { resume: false }); @@ -388,7 +408,10 @@ vision = false // G110:切会话清空旧连接分组缓存(含 timer),防广播"幽灵"残留 connManager?.get(prevSid)?.clearGroups(); // P5 slice 2:跨项目打开——用该项目路径 resume(非全局 projectDir) - const targetPath = projectPath || openSessions.get(sessionId)?.projectPath || DEFAULT_CWD; + // Rant 2026-08-25T17:38:56 根因 2:fallback 链去掉 DEFAULT_CWD——未知会话 + // 交 daemon not_found 处理(下方已有 fallback 到最近会话),绝不隐式回退 + // homedir 误建幽灵会话。 + const targetPath = projectPath || resolveSessionCwd(sessionId); logger.info(`[gui:switch] openSession start sid=${sessionId} targetPath=${targetPath}`); try { await openSession(sessionId, targetPath); // 打开(新)会话连接 + resume_session 自动订阅 @@ -417,7 +440,7 @@ vision = false ipcMain.handle("emrg:deleteSession", async (_e, { sessionId }) => { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - await requireConn().sendCommandAndWait("delete_session", { session_id: sessionId, cwd: DEFAULT_CWD }, 5000); + await requireConn().sendCommandAndWait("delete_session", { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD }, 5000); connManager?.close(sessionId); // P2:删除会话 → 关闭该会话连接(若打开) openSessions.delete(sessionId); // P4:删除(删数据)→ 一并移出打开会话簿记 schedulePersistGuiState(); @@ -429,7 +452,7 @@ vision = false if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); const clean = String(title || "").trim().slice(0, 80); // 截断超长标题 if (!clean) throw new Error("empty title"); - const frame = await requireConn().sendCommandAndWait("rename_session", { session_id: sessionId, cwd: DEFAULT_CWD, title: clean }, 5000); + const frame = await requireConn().sendCommandAndWait("rename_session", { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD, title: clean }, 5000); // 跨项目会话重命名成功后立即同步侧边栏标题(rant 12:01:44) const v = openSessions.get(sessionId); if (v) { @@ -474,21 +497,23 @@ vision = false ipcMain.handle("emrg:clearSession", async (_e, { sessionId }) => { // GUI / 指令 P1:/clear — 清空当前会话(daemon 协议 clear_session 已存在) if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - await requireConn().sendCommandAndWait("clear_session", { session_id: sessionId, cwd: DEFAULT_CWD }, 5000); + await requireConn().sendCommandAndWait("clear_session", { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD }, 5000); return { ok: true }; }); ipcMain.handle("emrg:compactSession", async (_e, { sessionId }) => { // GUI / 指令 P1:/compact — 压缩当前会话历史(daemon 协议 compact 已存在) if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - await requireConn().sendCommandAndWait("compact", { session_id: sessionId, cwd: DEFAULT_CWD }, 5000); + await requireConn().sendCommandAndWait("compact", { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD }, 5000); return { ok: true }; }); ipcMain.handle("emrg:listHistory", async (_e, { sessionId, limit, offset } = {}) => { // GUI / 指令 P2:/rewind + rant 14:15:12 历史按需加载(limit/offset 可选) if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - const payload = { session_id: sessionId, cwd: DEFAULT_CWD }; + // Rant 2026-08-25T17:38:56 根因 1(P0):历史/记忆命令的 cwd 必须取会话真实 + // 项目路径——硬编码 DEFAULT_CWD 使所有 GUI 会话从 ~/.emrg/sessions/ 读取 → 空历史。 + const payload = { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD }; if (limit != null) payload.limit = limit; if (offset != null) payload.offset = offset; const frame = await requireConn().sendCommandAndWait("list_history", payload, 5000); @@ -503,7 +528,7 @@ vision = false } const frame = await requireConn().sendCommandAndWait( "rewind_session", - { session_id: sessionId, cwd: DEFAULT_CWD, record_index: recordIndex }, + { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD, record_index: recordIndex }, 5000 ); return { ok: true, removedCount: frame.removed_count ?? 0 }; @@ -511,7 +536,9 @@ vision = false ipcMain.handle("emrg:listMemories", async (_e, { scope = "project", sessionId } = {}) => { // GUI / 指令 P3:/memory — 列出记忆(daemon list_memories → memories_list) - const params = { scope, cwd: DEFAULT_CWD }; + // 记忆命令同样按会话真实项目取 cwd(scope=session 时用该会话,project 时用当前会话) + const memCwd = resolveSessionCwd(sessionId || currentSessionId) || DEFAULT_CWD; + const params = { scope, cwd: memCwd }; if (scope === "session") { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); params.session_id = sessionId; @@ -523,7 +550,7 @@ vision = false ipcMain.handle("emrg:readMemory", async (_e, { memoryId, scope = "project", sessionId } = {}) => { // GUI / 指令 P3:/memory — 读取单条记忆(daemon read_memory → memory_content) if (typeof memoryId !== "string" || !memoryId.trim()) throw new Error("invalid memory_id"); - const params = { scope, memory_id: memoryId.trim(), cwd: DEFAULT_CWD }; + const params = { scope, memory_id: memoryId.trim(), cwd: resolveSessionCwd(sessionId || currentSessionId) || DEFAULT_CWD }; if (scope === "session") { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); params.session_id = sessionId; @@ -1144,7 +1171,7 @@ vision = false if (connManager.daemonConn()?.connected) { // G41(P2 改写):恢复当前会话连接(若 daemon 重启后未由 recoverAll 重开) if (currentSessionId && !connManager.get(currentSessionId)) { - try { await openSession(currentSessionId, DEFAULT_CWD); } catch { /* 会话可能已删 */ } + try { await openSession(currentSessionId, resolveSessionCwd(currentSessionId) || DEFAULT_CWD); } catch { /* 会话可能已删 */ } } const sessions = await listSessions(); sendToRenderer("sessions", { sessions }); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 34661340..bede0624 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -258,7 +258,11 @@ def __init__(self, llm_config: LlmConfig) -> None: self._rants_log = runtime_dir / "rants.jsonl" # ── Phase 2 broadcast model (protocol-contract §2.6) ── - self._session_subscribers: dict[str, set] = {} # session_id → set[ws] + # Rant 2026-08-25T17:38:56 根因 3(P1):订阅按 session_id 键控、与 cwd 无关 + # → 错误 cwd 的客户端(GUI 幽灵连接)与真实连接同组,实时消息串线。改为 + # session_id → {ws: cwd},广播时按运行中任务的 cwd 过滤(见 _session_task_cwds)。 + self._session_subscribers: dict[str, dict] = {} # session_id → {ws: cwd_str} + self._session_task_cwds: dict[str, str] = {} # session_id → 运行中任务的 cwd self._session_busy: dict[str, bool] = {} # session_id → active task? # P1 queue-injection (rant 2026-08-10T21:55:37): per-session FIFO of # (TaskRequest, allow_tools) received while a tool loop is busy — @@ -751,8 +755,9 @@ async def _handle_client(self, ws) -> None: new_sid = data["session_id"] if new_sid != last_session_id: if last_session_id: # unsubscribe from previous session - self._session_subscribers.get(last_session_id, set()).discard(ws) - self._session_subscribers.setdefault(new_sid, set()).add(ws) + self._session_subscribers.get(last_session_id, {}).pop(ws, None) + # 订阅记录该连接的 cwd——广播按 (session, cwd) 过滤(rant 17:38:56 根因 3) + self._session_subscribers.setdefault(new_sid, {})[ws] = data.get("cwd") or last_cwd or "" last_session_id = new_sid if data.get("cwd"): last_cwd = data["cwd"] @@ -866,7 +871,7 @@ async def _handle_client(self, ws) -> None: pass # Phase 2 broadcast: unsubscribe on disconnect (protocol-contract §2.6.2) if last_session_id: - self._session_subscribers.get(last_session_id, set()).discard(ws) + self._session_subscribers.get(last_session_id, {}).pop(ws, None) self._all_connections.discard(ws) try: await ws.close() @@ -898,9 +903,21 @@ async def _send(self, ws, data: dict) -> bool: async def _broadcast(self, session_id: str, data: dict) -> None: """Send data to all subscribers of session_id (including the originator). + Rant 2026-08-25T17:38:56 根因 3(P1):同一 session_id 可能有不同 cwd 的 + 订阅者(GUI 幽灵连接 vs TUI 真实连接)。运行中任务的事件按任务 cwd 过滤, + 只投递给同一 (session, cwd) 的订阅者——幽灵连接不再收到真实会话的实时流。 + 无运行中任务(compact_result 等)时广播全部订阅者(内容非会话实时流, + 无串线风险)。 + Best-effort: a single dead subscriber must not affect the others. """ - for w in list(self._session_subscribers.get(session_id, ())): + subs = self._session_subscribers.get(session_id, {}) + task_cwd = self._session_task_cwds.get(session_id) + if task_cwd: + targets = [w for w, wcwd in subs.items() if wcwd == task_cwd] + else: + targets = list(subs) + for w in targets: try: await self._send(w, data) except Exception: @@ -2269,12 +2286,16 @@ async def _run_tool_loop_locked( ) -> None: """Run _run_tool_loop and release the session busy lock on exit.""" session_id = session.session_id + # Rant 2026-08-25T17:38:56 根因 3:广播按任务 cwd 过滤——记录本任务真实 cwd, + # 幽灵连接(错误 cwd 订阅)在任务期间收不到该会话实时流。 + self._session_task_cwds[session_id] = str(session.cwd) normal_end = False try: await self._run_tool_loop(req, ws, session, cancel_event, allow_tools) normal_end = True finally: self._session_busy[session_id] = False + self._session_task_cwds.pop(session_id, None) # P1 (rant 21:55:37): messages still queued when the loop ends are # not lost. We do NOT start a follow-up task here (_tool_task / # _cancel_event are read-loop locals — a hand-off would break @@ -3478,9 +3499,17 @@ async def _handle_resume_session( The client reads history.jsonl directly from disk for display. We only confirm the session exists — no records over the wire. + + Rant 2026-08-25T17:38:56 (GUI 会话串线/空历史,根因 2):此前只查 + session_dir.exists() —— 错误 cwd 的客户端(GUI homedir 兜底)会在 + ~/.emrg/sessions// 误建 0-message 幽灵会话,resume 照常返回成功 + (0 messages),真实会话所在项目反而永远打不开。修复:meta.json 缺失 + 或 message_count==0 时,按全局 sessions_index.json 校验请求 cwd 是否为 + 该会话真实项目路径;不匹配 → not_found + warning(幽灵目录不再掩盖错误)。 """ session_dir = cwd / ".emrg" / "sessions" / session_id - if not session_dir.exists(): + meta_path = session_dir / "meta.json" + if not session_dir.exists() or not meta_path.exists(): await self._send(ws, { "type": "resume_result", "session_id": session_id, @@ -3488,6 +3517,26 @@ async def _handle_resume_session( }) return + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + meta = {} + if meta.get("message_count", 0) <= 0: + canonical = self._canonical_session_cwd(session_id) + if canonical is None or str(Path(canonical).resolve()) != str(Path(cwd).resolve()): + logger.warning( + "resume rejected: ghost session %s (0 messages) at %s — " + "real session lives at %s (canonical cwd from sessions index)", + session_id, session_dir, canonical or "unknown", + ) + await self._send(ws, { + "type": "resume_result", + "session_id": session_id, + "error": f"Session {session_id} not found (no messages at this cwd; " + "real session lives in its project — open from the project's session list)", + }) + return + session = Session.load(session_id, cwd) await self._send(ws, { @@ -3502,6 +3551,26 @@ async def _handle_resume_session( }, }) + def _canonical_session_cwd(self, session_id: str) -> str | None: + """Resolve a session's canonical project cwd from the global sessions index. + + Index maps sid → absolute session dir (/.emrg/sessions/); + the cwd is the dir 3 levels up (covers home-level sessions too: + ~/.emrg/sessions/ → cwd = ~). + """ + try: + from emrg.sessions_index import sessions_index_path, _load + + data = _load(sessions_index_path()) + dir_str = data.get(session_id) + if not dir_str: + return None + p = Path(dir_str) + return str(p.parent.parent.parent) + except Exception: + logger.debug("canonical session cwd lookup failed", exc_info=True) + return None + # ── GUI workspace panel: list_files / read_file (rant 2026-08-11T12:20:35 P1.1) ── # 单目录条目上限:超出截断 + truncated 提示(与 ReadTool 的防爆理念一致) _MAX_LIST_ENTRIES = 5000 diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 0123fdb9..c6744973 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -782,6 +782,104 @@ async def _test(): await cleanup() asyncio.run(_test()) + def test_broadcast_cwd_filtered(self): + """Subscribers on the same session_id but a DIFFERENT cwd must NOT + receive a running task's stream (rant 2026-08-25T17:38:56 root cause 3). + + The GUI's homedir-fallback connection subscribed to the same session + id as the real TUI connection; without cwd scoping every broadcast + leaked across projects. Now broadcasts are filtered by the running + task's cwd: A's task (cwd_a) reaches only A, and B's later task + (cwd_b) reaches only B — proving the filter is symmetric and B is + alive, not simply disconnected. + """ + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + cwd_a = cwd / "proj_a" + cwd_b = cwd / "proj_b" + server, _, cleanup = await _boot_server(cwd) + try: + ws_a = await connect_to_server() + ws_b = await connect_to_server() + try: + # Both subscribe to the SAME session_id from DIFFERENT cwds + for ws, ws_cwd in ((ws_a, cwd_a), (ws_b, cwd_b)): + await ws.send(json.dumps({ + "type": "list_history", + "session_id": "s_cwd_filter", + "cwd": str(ws_cwd), + })) + await asyncio.wait_for(ws.recv(), timeout=5) # history_list + + async def _drain(ws): + """Empty the socket so later asserts are unambiguous.""" + while True: + try: + await asyncio.wait_for(ws.recv(), timeout=0.2) + except (asyncio.TimeoutError, ConnectionClosed): + return + + await _drain(ws_b) + + # A runs a task at cwd_a → stream must reach A only + server.llm.chat_stream = _make_fake_chat_stream() + await ws_a.send(json.dumps({ + "type": "task", + "id": "t-filter-a", + "session_id": "s_cwd_filter", + "cwd": str(cwd_a), + "prompt": "你好", + "stream": True, + "timestamp": "2026-08-02T00:00:00", + }, ensure_ascii=False)) + got_delta = got_tool = got_done = False + while True: + frame = json.loads(await asyncio.wait_for(ws_a.recv(), timeout=10)) + if frame.get("delta"): + got_delta = True + if "tool_name" in frame: + got_tool = True + if frame.get("done") and frame.get("request_id") == "t-filter-a": + got_done = True + break + assert got_delta and got_tool and got_done + # B (wrong cwd) must NOT see any of A's stream frames + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(ws_b.recv(), timeout=1.0) + + # Symmetric direction: B's task at cwd_b reaches B only + server.llm.chat_stream = _make_fake_chat_stream() # fresh round counter + await ws_b.send(json.dumps({ + "type": "task", + "id": "t-filter-b", + "session_id": "s_cwd_filter", + "cwd": str(cwd_b), + "prompt": "你好", + "stream": True, + "timestamp": "2026-08-02T00:00:00", + }, ensure_ascii=False)) + got_delta = got_tool = got_done = False + while True: + frame = json.loads(await asyncio.wait_for(ws_b.recv(), timeout=10)) + if frame.get("delta"): + got_delta = True + if "tool_name" in frame: + got_tool = True + if frame.get("done") and frame.get("request_id") == "t-filter-b": + got_done = True + break + assert got_delta and got_tool and got_done + # A (wrong cwd for B's task) must NOT see B's stream + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(ws_a.recv(), timeout=1.0) + finally: + await ws_a.close() + await ws_b.close() + finally: + await cleanup() + asyncio.run(_test()) + def test_task_queued_instead_of_busy_error(self): """A's task holds the session lock; B's task is queued (task_queued, NOT 'session busy'), then injected into A's turn at the stop boundary @@ -942,6 +1040,102 @@ async def _test(): asyncio.run(_test()) +class TestWSGhostSessionGuard: + """Resume must not bless 0-message ghost sessions (rant 2026-08-25T17:38:56 + root cause 2). + + Before the fix, a client hitting the wrong cwd (GUI homedir fallback) + caused a 0-message ghost session dir at the wrong project; resume_session + then reported success (0 messages), masking the real session. Now: + - missing session dir / meta.json → not found + - a 0-message session whose cwd differs from the canonical session cwd + (global sessions_index.json) → rejected with a clear error + - the canonical cwd still resumes with the real message count + """ + + def test_resume_rejects_ghost_at_wrong_cwd(self): + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + cwd_a = tmp / "proj_a" # canonical project of the real session + cwd_b = tmp / "proj_b" # wrong project (ghost dir present) + sid = "s_ghost_test" + + # Real session at proj_a: dir + meta.json with 2 messages + real_dir = cwd_a / ".emrg" / "sessions" / sid + real_dir.mkdir(parents=True, exist_ok=True) + (real_dir / "meta.json").write_text(json.dumps({ + "session_id": sid, + "message_count": 2, + "created_at": "2026-08-25T00:00:00Z", + "updated_at": "2026-08-25T00:00:00Z", + }), encoding="utf-8") + (real_dir / "history.jsonl").write_text( + '{"type":"message","role":"user","content":"hi"}\n' + '{"type":"message","role":"assistant","content":"hello"}\n', + encoding="utf-8", + ) + # Ghost dir at the wrong project: 0 messages (pre-fix failure) + ghost_dir = cwd_b / ".emrg" / "sessions" / sid + ghost_dir.mkdir(parents=True, exist_ok=True) + (ghost_dir / "meta.json").write_text(json.dumps({ + "session_id": sid, + "message_count": 0, + }), encoding="utf-8") + + # _canonical_session_cwd reads emrg.sessions_index.config_dir + # (NOT the daemon's patched copy) → redirect it to the isolated + # tmp dir and register the canonical location. + import emrg.sessions_index as si_mod + orig_si_cfg = si_mod.config_dir + si_mod.config_dir = lambda: tmp + + try: + _, _, cleanup = await _boot_server(tmp) + try: + # Index must be deterministic — write AFTER the daemon's + # startup rebuild so nothing prunes our entry. + si_mod._write({sid: str(real_dir)}, tmp / "sessions_index.json") + + ws = await connect_to_server() + try: + # 1) wrong cwd → rejected (ghost must not mask the real session) + await ws.send(json.dumps({ + "type": "resume_session", + "session_id": sid, + "cwd": str(cwd_b), + })) + resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + assert resp.get("type") == "resume_result" + assert "error" in resp, f"ghost session not rejected: {resp!r}" + # 2) canonical cwd → resumes with the real message count + await ws.send(json.dumps({ + "type": "resume_session", + "session_id": sid, + "cwd": str(cwd_a), + })) + resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + assert resp.get("type") == "resume_result" + assert "error" not in resp, f"canonical resume failed: {resp!r}" + assert resp["meta"]["message_count"] == 2 + # 3) unknown session → not found (pre-existing guard kept) + await ws.send(json.dumps({ + "type": "resume_session", + "session_id": "s_no_such_session", + "cwd": str(cwd_a), + })) + resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + assert resp.get("type") == "resume_result" + assert "error" in resp + finally: + await ws.close() + finally: + await cleanup() + finally: + si_mod.config_dir = orig_si_cfg + asyncio.run(_test()) + + class TestWSEvolutionSummary: """evolution_summary command (WorkBuddy P3, #502) — count + recent list.