diff --git a/Agent.md b/Agent.md index 4328d2ac..4ae5c4fa 100644 --- a/Agent.md +++ b/Agent.md @@ -119,9 +119,9 @@ 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` (1222) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (95: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api + 4 boot-contract + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (490: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 19 transcript + 10 TranscriptView + 15 history + 22 composer + 38 Composer + 6 LinkDialog + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` +Python: `uv run pytest tests/ -v` (1223) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (497: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 19 transcript + 10 TranscriptView + 15 history + 22 composer + 38 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` 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 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/client/app.py b/emrg/client/app.py index 76e5ee43..4820f563 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -571,6 +571,16 @@ async def _reconnect(): term.render() continue + if data.get("type") == "turn_start": + # Rant 2026-09-02T10:36:26:daemon 权威 turn 开始帧——把本地计时 + # 对齐到实际执行时刻(排队请求目前从发送时刻起算,计时偏大)。 + # 仅当本端 busy(该 turn 属于本会话)时对齐;started_at 为 epoch 秒。 + started = data.get("started_at") + if busy and isinstance(started, (int, float)) and started > 0: + _request_start = float(started) + logger.info("turn_start aligned: _request_start=%.3f", _request_start) + continue + resp = TaskResponse.from_dict(data) if resp.delta and resp.content: if need_new_assistant: @@ -701,11 +711,18 @@ async def _reconnect(): f"Compact complete — {compacted} messages compressed into summary.\n" f"Summary: {summary[:200]}..." ) - busy = False + # Rant 2026-09-02T10:31:11:auto-compact 可在 tool loop round 之间 + # 触发(turn 未结束)——此时 compact_result 是旁路事件,绝不能把 + # busy 置 False(否则 _run_elapsed_timer 的 while busy 退出,计时器 + # 死亡、状态栏 elapsed 被清空、终端标题残留停住)。仅当 turn 空闲 + # (手动 /compact)时才按终态处理。msg_count 两种情况下都应减去。 + if not busy: + busy = False + status.elapsed = "" + status.update(center=server_id or "emrg") msg_count = max(0, msg_count - compacted) _update_left_extra() - status.elapsed = "" - status.update(center=server_id or "emrg"); term.render() + term.render() continue # Sessions list @@ -1248,6 +1265,9 @@ async def handle_key(data: bytes) -> bool: _elapsed_task.cancel() _elapsed_task = None status.elapsed = "" + # Rant 2026-09-02T10:31:11:与 done 路径(:617)一致的标题复位——ESC 中断 + # 漏清 term.set_title 会让标题残留 [m:ss] 计时停住。 + term.set_title(f"{session_title or session_id} @ {project_name}") # Send cancel to daemon so it stops tool/LLM processing await conn.send_command("cancel") chat.add("system", "⏸ Interrupted — response stopped. You can continue.") diff --git a/emrg/gui/renderer/src/components/DialogHost.test.tsx b/emrg/gui/renderer/src/components/DialogHost.test.tsx index 97f3c19d..6f23f98e 100644 --- a/emrg/gui/renderer/src/components/DialogHost.test.tsx +++ b/emrg/gui/renderer/src/components/DialogHost.test.tsx @@ -28,6 +28,7 @@ function appState(over: Partial = {}): DaemonAppState { sessions: [], openSessions: [], busyBySid: {}, + turnStartBySid: {}, ownStreamRidBySid: {}, disconnectedBySid: {}, upgradeBanner: null, diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 667c98c6..9acf9d77 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -133,6 +133,16 @@ export function Shell() { busyStartRef.current = null; setElapsed(0); }, [activeBusy]); + + // Rant 2026-09-02T10:36:26:侧边栏每个运行会话的 [m:ss] 计时——任意会话 busy 时 + // 共享一个 1s tick 触发重渲染(Sidebar 读 Date.now() 计算 elapsed;多会话复用同一定时器)。 + const [, setSidebarTick] = useState(0); + const hasRunningSessions = Object.keys(appState.turnStartBySid).length > 0; + useEffect(() => { + if (!hasRunningSessions) return; + const iv = setInterval(() => setSidebarTick((t) => t + 1), 1000); + return () => clearInterval(iv); + }, [hasRunningSessions]); const activeSessionEntry = appState.openSessions.find((o) => o.sid === activeSid); const activeKnown = appState.sessions.find((s) => s.session_id === activeSid); const activeTitle = activeSessionEntry?.title || activeKnown?.title || t("app.unnamed"); @@ -655,6 +665,7 @@ export function Shell() { knownSessions={appState.sessions} activeSid={activeSid} activeView={activeView} + turnStartBySid={appState.turnStartBySid} onSelect={selectSession} onSwitchView={switchView} onNewChat={() => dialogHost.current?.openNewSession()} diff --git a/emrg/gui/renderer/src/components/Sidebar.tsx b/emrg/gui/renderer/src/components/Sidebar.tsx index ed5c89bc..b444434e 100644 --- a/emrg/gui/renderer/src/components/Sidebar.tsx +++ b/emrg/gui/renderer/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { useI18n } from "../lib/i18n"; import { + formatElapsed, isActive, resolveEntryTitle, sessionLabel, @@ -41,6 +42,8 @@ export interface SidebarProps { onContextMenu?: (entry: OpenSessionEntry, event: React.MouseEvent) => void; /** 可注入的标题格式化函数(测试用;默认 sessionLabel) */ labelFn?: (project: string, title: string, sid: string) => string; + /** 每会话 turn 开始时刻(epoch ms;rant 2026-09-02T10:36:26 — busy 会话显示 [m:ss] 计时) */ + turnStartBySid?: Record; /** 新对话按钮 → NewSessionDialog(Batch 5 slice 4) */ onNewChat?: () => void; /** 打开会话按钮 → OpenSessionDialog(Batch 5 slice 4) */ @@ -58,6 +61,7 @@ export function Sidebar({ onSelect, onContextMenu, labelFn = sessionLabel, + turnStartBySid = {}, onNewChat, onOpenChat, activeView = "sessions", @@ -65,6 +69,7 @@ export function Sidebar({ }: SidebarProps) { const { t } = useI18n(); const entries = sortOpenSessions(openSessions); + const nowMs = Date.now(); // vanilla #side-nav:五个导航按钮(sessions/projects/tasks/rants/settings) const navRail = ( @@ -123,6 +128,9 @@ export function Sidebar({ const title = resolveEntryTitle(entry, knownSessions); const label = labelFn(entry.projectName || "", title, entry.sid); const active = isActive(entry.sid, activeSid); + // Rant 2026-09-02T10:36:26:daemon turn_start 权威计时——正在运行的会话 + // 在标题后显示 [m:ss](与 TUI 状态栏同格式同基准)。 + const timer = formatElapsed(turnStartBySid[entry.sid], nowMs); return (
- {label} + + {label} + {timer ? {timer} : null} +
); })} diff --git a/emrg/gui/renderer/src/lib/daemonBridge.test.ts b/emrg/gui/renderer/src/lib/daemonBridge.test.ts index 7ba6e5b5..c31f3ad1 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.test.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.test.ts @@ -106,6 +106,41 @@ describe("createDaemonBridge", () => { expect(entriesText(transcript, "s2")).toContain("s:queued:3"); }); + it("turn_start → busy + turnStartBySid(epoch ms 换算,含后台/其他客户端 turn)", () => { + const { emit, bridge } = setup(); + emit({ type: "turn_start", data: { started_at: 1756785600.5 }, sid: "s1" }); + const st = bridge.store.get(); + expect(st.busyBySid["s1"]).toBe(true); + expect(st.turnStartBySid["s1"]).toBe(1756785600500); + }); + + it("turn_end → 清 busy + turnStartBySid(幂等,与 done 重复不炸)", () => { + const { emit, bridge } = setup(); + emit({ type: "turn_start", data: { started_at: 1756785600.5 }, sid: "s1" }); + emit({ type: "turn_end", data: {}, sid: "s1" }); + let st = bridge.store.get(); + expect(st.busyBySid["s1"]).toBe(false); + expect(st.turnStartBySid["s1"]).toBeUndefined(); + // 幂等:done 后再 turn_end 不抛错、状态保持 + emit({ type: "turn_end", data: {}, sid: "s1" }); + st = bridge.store.get(); + expect(st.busyBySid["s1"]).toBe(false); + expect(st.turnStartBySid["s1"]).toBeUndefined(); + }); + + it("done / cancelled → 顺带清 turn 计时(幂等兜底)", () => { + const { emit, bridge } = setup(); + emit({ type: "turn_start", data: { started_at: 1756785600 }, sid: "s1" }); + emit({ type: "done", data: { request_id: "r1" }, sid: "s1" }); + let st = bridge.store.get(); + expect(st.turnStartBySid["s1"]).toBeUndefined(); + emit({ type: "turn_start", data: { started_at: 1756785601 }, sid: "s1" }); + emit({ type: "cancelled", data: {}, sid: "s1" }); + st = bridge.store.get(); + expect(st.busyBySid["s1"]).toBe(false); + expect(st.turnStartBySid["s1"]).toBeUndefined(); + }); + it("error → 错误系统消息 + 释放锁", () => { const { emit, bridge, transcript } = setup(); bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "x" }] }, sid: "s1" }); diff --git a/emrg/gui/renderer/src/lib/daemonBridge.ts b/emrg/gui/renderer/src/lib/daemonBridge.ts index 206771be..0f5e8f00 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.ts @@ -88,6 +88,8 @@ export interface DaemonAppState { openSessions: OpenSessionEntry[]; /** 每会话 busy 锁(P3 slice 1:done/cancelled 按 sid 释放,不误清激活会话) */ busyBySid: Record; + /** 每会话 turn 开始时刻(epoch ms;rant 2026-09-02T10:36:26 daemon turn_start 权威广播) */ + turnStartBySid: Record; /** 每会话 own stream request id(决定"来自其他客户端"标签) */ ownStreamRidBySid: Record; /** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */ @@ -112,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore { sessions: [], openSessions: [], busyBySid: {}, + turnStartBySid: {}, ownStreamRidBySid: {}, disconnectedBySid: {}, upgradeBanner: null, @@ -176,6 +179,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { const k = KEY(sid); store.update((s) => ({ ...s, busyBySid: { ...s.busyBySid, [k]: busy } })); } + + /** 清除该会话的 turn 计时(turn_end/done/cancelled/disconnected 幂等调用)。 */ + function clearTurnTimer(sid: string | null): void { + const k = KEY(sid); + const { [k]: _drop, ...rest } = store.get().turnStartBySid; + if (_drop !== undefined || store.get().busyBySid[k]) { + store.update((s) => ({ + ...s, + busyBySid: { ...s.busyBySid, [k]: false }, + turnStartBySid: rest, + })); + } + } function sidOwnRid(sid: string | null, rid: string | null): void { const k = KEY(sid); store.update((s) => ({ ...s, ownStreamRidBySid: { ...s.ownStreamRidBySid, [k]: rid } })); @@ -235,12 +251,38 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { const { type, data } = frame; const sid = frame.sid ?? null; switch (type) { + case "turn_start": { + // Rant 2026-09-02T10:36:26:daemon 权威 turn 开始(含后台演化/其他客户端 + // turn)——记 start 时刻并置 busy;计时基准与 TUI 同一来源。 + const startedAt = (data as { started_at?: number }).started_at; + const k = KEY(sid); + if (typeof startedAt === "number" && startedAt > 0) { + store.update((s) => ({ + ...s, + busyBySid: { ...s.busyBySid, [k]: true }, + turnStartBySid: { ...s.turnStartBySid, [k]: startedAt * 1000 }, + })); + } + break; + } + case "turn_end": { + // daemon 权威 turn 结束——清 busy + 计时(与 done/cancelled 幂等)。 + const k = KEY(sid); + const { [k]: _drop, ...rest } = store.get().turnStartBySid; + store.update((s) => ({ + ...s, + busyBySid: { ...s.busyBySid, [k]: false }, + turnStartBySid: rest, + })); + break; + } case "message_delta": transcript.handleDelta(data.chunks || [data], sid); break; case "done": transcript.handleDone(data as DoneData, sid); releaseOwnStream(sid, (data as DoneData).request_id, Boolean((data as DoneData).timeout)); + clearTurnTimer(sid); break; case "tool_started": transcript.handleToolStart(data as ToolStartData, sid); @@ -251,6 +293,7 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { case "cancelled": transcript.clearTyping(sid); releaseOwnStream(sid, null, true); + clearTurnTimer(sid); break; case "task_queued": transcript.addSystemMessage(tt("app.queued", { pos: (data as QueuedData).position ?? 0 }), sid); @@ -319,6 +362,7 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { sidBusy(sid, false); sidOwnRid(sid, null); sidDisconnected(sid, true); + clearTurnTimer(sid); queuedSends.delete(KEY(sid)); if (!sid) { store.update((s) => ({ ...s, connected: false })); diff --git a/emrg/gui/renderer/src/lib/sidebar.test.ts b/emrg/gui/renderer/src/lib/sidebar.test.ts index bb2f861d..9f641c31 100644 --- a/emrg/gui/renderer/src/lib/sidebar.test.ts +++ b/emrg/gui/renderer/src/lib/sidebar.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + formatElapsed, isActive, resolveEntryTitle, sessionLabel, @@ -91,3 +92,27 @@ describe("isActive", () => { expect(isActive(undefined, undefined)).toBe(false); }); }); + +describe("formatElapsed(rant 2026-09-02T10:36:26:与 TUI 状态栏同格式 [m:ss])", () => { + const T0 = 1_756_800_000_000; // 任意 epoch ms 基准 + + it("无 startedAt(非运行)→ 空串", () => { + expect(formatElapsed(undefined, T0)).toBe(""); + expect(formatElapsed(0, T0)).toBe(""); + }); + + it("<60s → [0:ss](补零)", () => { + expect(formatElapsed(T0, T0 + 5000)).toBe("[0:05]"); + expect(formatElapsed(T0, T0 + 59_000)).toBe("[0:59]"); + }); + + it("≥60s → [m:ss](m 不补零,与 TUI divmod 一致)", () => { + expect(formatElapsed(T0, T0 + 60_000)).toBe("[1:00]"); + expect(formatElapsed(T0, T0 + 97_800)).toBe("[1:37]"); // 宿主实测样例 97.8s + expect(formatElapsed(T0, T0 + 3_600_000)).toBe("[60:00]"); + }); + + it("负 elapsed(时钟偏差)钳制为 0", () => { + expect(formatElapsed(T0 + 5000, T0)).toBe("[0:00]"); + }); +}); diff --git a/emrg/gui/renderer/src/lib/sidebar.ts b/emrg/gui/renderer/src/lib/sidebar.ts index 97e3160b..ee46969e 100644 --- a/emrg/gui/renderer/src/lib/sidebar.ts +++ b/emrg/gui/renderer/src/lib/sidebar.ts @@ -57,3 +57,16 @@ export function sortOpenSessions(entries: OpenSessionEntry[]): OpenSessionEntry[ export function isActive(sid: string | null | undefined, activeSid: string | null | undefined): boolean { return !!sid && sid === activeSid; } + +/** + * 运行计时格式化(rant 2026-09-02T10:36:26):与 TUI 状态栏一致的 `[m:ss]`。 + * startedAtMs/nowMs 均为 epoch 毫秒;startedAtMs 缺失(非运行中)→ ""(不显示)。 + * 负 elapsed(时钟偏差)钳制为 0。 + */ +export function formatElapsed(startedAtMs: number | undefined, nowMs: number): string { + if (!startedAtMs || startedAtMs <= 0) return ""; + const elapsed = Math.max(0, Math.floor((nowMs - startedAtMs) / 1000)); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + return `[${mins}:${secs.toString().padStart(2, "0")}]`; +} diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 9b59224d..b6e0efc7 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -2330,12 +2330,29 @@ async def _run_tool_loop_locked( # Rant 2026-08-25T17:38:56 根因 3:广播按任务 cwd 过滤——记录本任务真实 cwd, # 幽灵连接(错误 cwd 订阅)在任务期间收不到该会话实时流。 self._session_task_cwds[session_id] = str(session.cwd) + # Rant 2026-09-02T10:36:26:权威 turn 生命周期广播——started_at 为任务 + # 实际开始执行时刻(队列等待之后)。TUI/GUI 据此对齐各自计时(排队请求 + # 不再从发送时刻起算);覆盖所有 turn 来源:TUI/GUI 请求、演化任务 + # (emrg-evolution-*)、upgrade 会话(都经此 locked 包装执行)。 + await self._broadcast(session_id, { + "type": "turn_start", + "session_id": session_id, + "started_at": time.time(), + }) 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 + # Rant 2026-09-02T10:36:26:turn 生命周期结束——客户端清除该会话 + # 运行计时(与 done/cancelled 帧幂等,仅作权威清理信号)。 + # 必须在 _session_task_cwds 清空之前广播:turn 帧属于任务流, + # 需按任务 cwd 过滤(错误 cwd 的幽灵连接收不到,rant 2026-08-25T17:38:56)。 + await self._broadcast(session_id, { + "type": "turn_end", + "session_id": session_id, + }) 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 / diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 3bc2fbd8..76253363 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -551,6 +551,91 @@ async def _test(): await cleanup() asyncio.run(_test()) + def test_turn_start_end_broadcast_lifecycle(self): + """Rant 2026-09-02T10:36:26: daemon broadcasts authoritative turn + lifecycle frames (TUI/GUI timer alignment baseline). + + turn_start (started_at) precedes the turn's output; turn_end follows + done. A task queued while the session is busy executes as its own turn + only after the first turn's turn_end (queued_requeue → re-send) — + its turn_start arrives then, proving started_at is the actual + execution start, not the send time (queued requests must not + over-count their elapsed timer). + """ + import time as _time + + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + server, _, cleanup = await _boot_server(cwd) + try: + async def slow_chat_stream(messages, tools=None): + yield {"content": "处理中", "tool_calls": None, "finish_reason": None, "usage": None} + await asyncio.sleep(0.4) + yield {"content": "完成", "tool_calls": None, "finish_reason": "stop", "usage": None} + server.llm.chat_stream = slow_chat_stream + async def no_drain(session, messages): + return 0, False + server._inject_pending_messages = no_drain # type: ignore[assignment] + ws_a = await connect_to_server() + ws_b = await connect_to_server() + try: + task = { + "type": "task", "id": "t-turn-a", "session_id": "s_turn", + "cwd": str(cwd), "prompt": "hi", "stream": True, + "timestamp": "2026-09-02T00:00:00", + } + await ws_a.send(json.dumps(task, ensure_ascii=False)) + # Turn A: turn_start must arrive with a valid started_at + start_a = await _recv_until( + ws_a, lambda f: f.get("type") == "turn_start", + what="turn_start A") + ts_a = start_a.get("started_at") + assert isinstance(ts_a, (int, float)) and ts_a > 0 + assert ts_a <= _time.time() + 1 + await asyncio.sleep(0.2) # let A's lock settle + task_b = {**task, "id": "t-turn-b"} + await ws_b.send(json.dumps(task_b, ensure_ascii=False)) + resp = json.loads(await asyncio.wait_for(ws_b.recv(), timeout=5)) + assert resp.get("type") == "task_queued" + # Turn A ends → done then turn_end + await _recv_until( + ws_a, + lambda f: f.get("done") and f.get("request_id") == "t-turn-a", + what="turn A done") + te_a = await _recv_until( + ws_a, lambda f: f.get("type") == "turn_end", + what="turn_end A") + assert te_a.get("session_id") == "s_turn" + # Re-send B after queued_requeue → executes as its own turn + requeue = await _recv_until( + ws_b, lambda f: f.get("type") == "queued_requeue", + what="queued_requeue") + assert "t-turn-b" in requeue.get("request_ids", []) + await ws_b.send(json.dumps(task_b, ensure_ascii=False)) + start_b = await _recv_until( + ws_b, lambda f: f.get("type") == "turn_start", + what="turn_start B") + ts_b = start_b.get("started_at") + assert isinstance(ts_b, (int, float)) and ts_b > 0 + # The queued turn's started_at is the actual execution + # start (after turn A's turn_end), not B's send time. + assert ts_b >= ts_a + await _recv_until( + ws_b, + lambda f: f.get("done") and f.get("request_id") == "t-turn-b", + what="turn B done") + te_b = await _recv_until( + ws_b, lambda f: f.get("type") == "turn_end", + what="turn_end B") + assert te_b.get("session_id") == "s_turn" + finally: + await ws_a.close() + await ws_b.close() + finally: + await cleanup() + asyncio.run(_test()) + def test_tool_intent_logged_and_ignored_by_execution(self): """Rant 2026-08-19T10:35:24: the agent's per-call `intent` is carried in the tool_start broadcast and logged, but NOT passed to the tool @@ -847,6 +932,9 @@ async def _drain(ws): # 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) + # A's own turn_end lifecycle frame arrives after done — + # consume it so the symmetric direction below starts clean. + await _drain(ws_a) # Symmetric direction: B's task at cwd_b reaches B only server.llm.chat_stream = _make_fake_chat_stream() # fresh round counter