diff --git a/Agent.md b/Agent.md index cbea86b2..117e7b01 100644 --- a/Agent.md +++ b/Agent.md @@ -55,7 +55,7 @@ One line per command: what it answers. Run the daemon in the foreground with `py - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (161: 98 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (710: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 26 TranscriptView + 22 history + 23 historyReplay + 38 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 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 + 56 daemonBridge + 9 DaemonBridgeProvider + 48 Shell + 24 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (714: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 38 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 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 + 56 daemonBridge + 9 DaemonBridgeProvider + 48 Shell + 24 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each answers a question no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (two PRs together; every step of an order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/client/app.py b/emrg/client/app.py index a118febb..232c02b9 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -281,6 +281,10 @@ def _cards_from_tool_calls(tool_calls) -> list[ToolCard]: status="pending", tool_call_id=str(call.get("id") or ""), arguments=args, + # The daemon projects `call_label` into every history entry it returns, + # so a resumed session shows the same label the live one did — including + # a session recorded before the field existed (rant 2026-10-20T12:22:21). + call_label=str(call.get("call_label") or ""), )) return cards @@ -1534,6 +1538,9 @@ async def _reconnect(): status="running", tool_call_id=ts.tool_call_id, arguments=ts.arguments, + # Printed as handed over: which call of its batch this is was + # decided in the daemon (rant 2026-10-20T12:22:21). + call_label=ts.call_label, ) chat.add(card) _last_center = f"running {ts.tool_name}..." diff --git a/emrg/client/python_tui/widgets/tool_card.py b/emrg/client/python_tui/widgets/tool_card.py index 8805e735..fa8184bd 100644 --- a/emrg/client/python_tui/widgets/tool_card.py +++ b/emrg/client/python_tui/widgets/tool_card.py @@ -68,6 +68,12 @@ class ToolCard(Widget): output: str = "" tool_call_id: str = "" arguments: dict = field(default_factory=dict) + #: Which call of its batch this is — `"(2/3) "`, trailing space included, or `""` + #: for a single call. **Printed, never computed here**: the daemon decides what a + #: batch is (`daemon._tool_call_labels`) so the two clients cannot disagree + #: (rant 2026-10-20T12:22:21). Empty for every frame and record that predates it, + #: which is why a turn from before this change renders exactly as it used to. + call_label: str = "" elapsed: float = 0.0 _dirty: bool = True @@ -102,8 +108,8 @@ def render(self, ctx: RenderContext) -> list[Line]: # nothing left to expand into — the detail lives in the selector, and a # chevron that promised an interaction the TUI does not have would be the # same defect the removed Tab handler was. - header = (f"{self.icon} {self.name}: {self.command}" - if self.command else f"{self.icon} {self.name}") + header = (f"{self.icon} {self.call_label}{self.name}: {self.command}" + if self.command else f"{self.icon} {self.call_label}{self.name}") lines = [Line( spans=[Span(text=header, style=style_str)], style=ctx.style, diff --git a/emrg/gui/renderer/src/components/TranscriptView.test.tsx b/emrg/gui/renderer/src/components/TranscriptView.test.tsx index 273ee5a5..742caf47 100644 --- a/emrg/gui/renderer/src/components/TranscriptView.test.tsx +++ b/emrg/gui/renderer/src/components/TranscriptView.test.tsx @@ -400,6 +400,37 @@ describe("TranscriptView", () => { expect(full.textContent!.endsWith("…")).toBe(false); }); + it("同批第几个显示在工具名之前,daemon 给什么就打印什么(rant 2026-10-20T12:22:21)", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.handleToolStart( + { tool_call_id: "t1", tool_name: "bash", request_id: "r1", intent: "check config", call_label: "(2/3) " }, + "s1", + ); + const { container } = setup(store, "s1"); + const row = container.querySelector(".tool-row")!; + const label = row.querySelector(".tool-call-label"); + // 逐字比较:toHaveTextContent 会归一空白,正好抹掉那个尾随空格——而那个空格是 + // 协议的一部分(label 自带尾随空格,客户端不再自己拼)。 + expect(label!.textContent).toBe("(2/3) "); + // 位置:label 紧贴在工具名之前,不是名字后面、也不是 intent 后面 + const name = row.querySelector(".tool-name")!; + expect(label!.nextElementSibling).toBe(name); + expect(name).toHaveTextContent("bash"); + expect(row.textContent!.indexOf("(2/3) ")).toBeLessThan(row.textContent!.indexOf("bash")); + }); + + it("没有 call_label 时行与改动前逐字相同:不出现 (1/1)、不出现空括号", () => { + const store = createTranscriptStore({ t: (k) => k }); + store.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1", intent: "check config" }, "s1"); + const { container } = setup(store, "s1"); + const row = container.querySelector(".tool-row")!; + // 缺字段 = 连节点都没有(空 span 也会让「逐字相同」不成立) + expect(row.querySelector(".tool-call-label")).toBeNull(); + expect(row.querySelector(".tool-name")).toHaveTextContent(/^bash$/); + expect(row.textContent).not.toContain("(1/1)"); + expect(row.textContent).not.toContain("("); + }); + it("失败工具行:failed label(tool.failText)且无耗时", () => { const store = createTranscriptStore({ t: (k) => k }); store.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1"); diff --git a/emrg/gui/renderer/src/components/TranscriptView.tsx b/emrg/gui/renderer/src/components/TranscriptView.tsx index a37f7254..336e7f7d 100644 --- a/emrg/gui/renderer/src/components/TranscriptView.tsx +++ b/emrg/gui/renderer/src/components/TranscriptView.tsx @@ -413,6 +413,7 @@ function ToolRowView({ > {row.status === "running" ? : null} {ok ? ✓ : null} + {row.callLabel ? {row.callLabel} : null} {row.toolName} {label} {row.intent ? {row.intent} : null} diff --git a/emrg/gui/renderer/src/lib/historyReplay.test.ts b/emrg/gui/renderer/src/lib/historyReplay.test.ts index 24180827..f47abd7e 100644 --- a/emrg/gui/renderer/src/lib/historyReplay.test.ts +++ b/emrg/gui/renderer/src/lib/historyReplay.test.ts @@ -185,6 +185,60 @@ describe("replayHistoryRecords is equivalent to the live stream", () => { expect(group.group.rows.map((r) => r.intent)).toEqual(["list the files", "read README"]); }); + /** 所有工具行的 callLabel,按出现顺序——不假设是否已并进 group(取决于结果是否先到)。 */ + function rowsWithLabel(replay: ReturnType): Array { + const out: Array = []; + for (const entry of replay.getEntries(SID)) { + if (entry.kind === "tool-row") out.push((entry as { row: { callLabel?: string } }).row.callLabel); + if (entry.kind === "tool-group") { + for (const row of (entry as { group: { rows: Array<{ callLabel?: string }> } }).group.rows) { + out.push(row.callLabel); + } + } + } + return out; + } + + it("carries the daemon's batch label out of tool_calls[].call_label (rant 2026-10-20T12:22:21)", () => { + // daemon 的历史投影在每一项 tool_calls 上算好 call_label;回放只透传,不自己数。 + const records: HistoryRecord[] = [ + { record_index: 0, kind: "message", role: "user", content: "q" }, + { + record_index: 1, + kind: "message", + role: "assistant", + content: "", + tool_calls: [ + { id: "c1", function: { name: "bash", arguments: '{"command":"ls"}' }, call_label: "(1/2) " }, + { id: "c2", function: { name: "read", arguments: '{"path":"a"}' }, call_label: "(2/2) " }, + ], + }, + ]; + const replay = createTranscriptStore(); + replayHistoryRecords(records, SID, replay); + expect(rowsWithLabel(replay)).toEqual(["(1/2) ", "(2/2) "]); + }); + + it("leaves callLabel undefined when the record carries none — the client never counts", () => { + const records: HistoryRecord[] = [ + { record_index: 0, kind: "message", role: "user", content: "q" }, + { + record_index: 1, + kind: "message", + role: "assistant", + content: "", + tool_calls: [ + { id: "c1", function: { name: "bash", arguments: '{"command":"ls"}' } }, + { id: "c2", function: { name: "read", arguments: '{"path":"a"}' } }, + ], + }, + ]; + const replay = createTranscriptStore(); + replayHistoryRecords(records, SID, replay); + // 两条调用、没有字段 ⇒ 两条都不许冒出 (1/2):客户端不自己算 + expect(rowsWithLabel(replay)).toEqual([undefined, undefined]); + }); + it("does not invent an empty assistant bubble for a tool-only round", () => { const live = createTranscriptStore(); feedLive(live, SID, { user: "q", rounds: [{ text: "", tools: [{ id: "c1", name: "bash", intent: "go", output: "ok" }] }, { text: "done", tools: [] }] }); diff --git a/emrg/gui/renderer/src/lib/historyReplay.ts b/emrg/gui/renderer/src/lib/historyReplay.ts index c70e5588..184fa4da 100644 --- a/emrg/gui/renderer/src/lib/historyReplay.ts +++ b/emrg/gui/renderer/src/lib/historyReplay.ts @@ -32,7 +32,16 @@ export interface HistoryMessageRecord { role?: string; content?: string; timestamp?: string; - tool_calls?: Array<{ id?: string; function?: { name?: string; arguments?: string } }>; + /** + * `call_label` 不在落盘记录里:daemon 在 **`list_history` 读取时**把它算进每一项的副本 + * (`daemon._tool_call_labels`),所以这个字段只在「已被投影过的记录」上出现。类型放宽 + * 是因为这就是回放看到的东西,不是要写进磁盘(rant 2026-10-20T12:22:21)。 + */ + tool_calls?: Array<{ + id?: string; + function?: { name?: string; arguments?: string }; + call_label?: string; + }>; } /** 落盘记录里的一条工具结果(daemon `include_records` 的 `kind: "tool_result"`)。 */ @@ -75,7 +84,7 @@ export interface ReplaySink { sid?: string | null, ): void; handleToolStart( - data: { request_id?: string; tool_call_id: string; tool_name: string; intent?: string; arguments?: unknown }, + data: { request_id?: string; tool_call_id: string; tool_name: string; intent?: string; arguments?: unknown; call_label?: string }, sid?: string | null, ): void; handleToolEnd( @@ -212,6 +221,10 @@ export function replayHistoryRecords( // 于是「打开旧会话」的折叠行与展开详情与实时不一致。归一成实时那一形状后透传 // (截断/非法 JSON 由 toolCallArguments 与 formatToolArguments 兜底)。 arguments: toolCallArguments(tc?.function?.arguments), + // daemon 的历史投影在每一项 tool_calls 上算好了 `call_label`(读时算的, + // 旧记录也有)——回放只是透传,走的是同一个 handleToolStart + // (rant 2026-10-20T12:22:21)。 + call_label: tc?.call_label, }, sid, ); diff --git a/emrg/gui/renderer/src/lib/transcript.ts b/emrg/gui/renderer/src/lib/transcript.ts index e7d958d3..2d80a93c 100644 --- a/emrg/gui/renderer/src/lib/transcript.ts +++ b/emrg/gui/renderer/src/lib/transcript.ts @@ -28,6 +28,13 @@ export interface ToolRow { toolName: string; status: "running" | "done" | "failed"; intent?: string; + /** + * 这条调用是同一批(一条 assistant 响应)里的第几个 —— `"(2/3) "`,自带尾随空格; + * 只有一条调用时是 `""`。**由 daemon 算好,这里只负责打印**:客户端自己数就会是同一个 + * 问题的第二个答案,而错的 `(2/3)` 和对的看起来一模一样(rant 2026-10-20T12:22:21)。 + * 旧帧与旧记录没有这个字段 ⇒ `undefined` ⇒ 渲染结果与改动前逐字相同。 + */ + callLabel?: string; /** * 工具入参(rant 2026-09-30T09:17:54 → #1787)。实时是 daemon 解析好的对象;回放那一路 * 记录里存的是 `function.arguments` 的 **JSON 字符串**,但 `historyReplay.toolCallArguments` @@ -161,6 +168,8 @@ export interface ToolStartData { intent?: string; /** daemon 的 `tool_start` 帧本就带 `arguments`(解析好的对象)——此前在这里被丢掉。 */ arguments?: unknown; + /** 同批第几个,daemon 算好的成品字符串(`"(2/3) "` / `""`)——rant 2026-10-20T12:22:21。 */ + call_label?: string; } export interface ToolEndData { tool_call_id: string; @@ -469,6 +478,8 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip status: "running", intent: data.intent, arguments: data.arguments, + // daemon 给什么就存什么,不数、不拼(rant 2026-10-20T12:22:21)。 + callLabel: data.call_label, outputExpanded: false, fullExpanded: false, }; diff --git a/emrg/protocol.py b/emrg/protocol.py index 6a750ec9..a903a33d 100644 --- a/emrg/protocol.py +++ b/emrg/protocol.py @@ -88,6 +88,13 @@ class ToolStart: tool_call_id: str = "" arguments: dict = field(default_factory=dict) intent: str = "" + #: Which call of its batch this is, as a finished string — `"(2/3) "` — or `""` + #: when the response issued a single call. Composed by the daemon + #: (`daemon._tool_call_labels`) and printed verbatim by every client: a client + #: that counted would be a second answer to a question the daemon already + #: answered (rant 2026-10-20T12:22:21). Absent on an older frame ⇒ `""`, which + #: is the same thing a single call shows. + call_label: str = "" @classmethod def from_dict(cls, d: dict) -> ToolStart: @@ -97,6 +104,7 @@ def from_dict(cls, d: dict) -> ToolStart: tool_call_id=d.get("tool_call_id", ""), arguments=d.get("arguments", {}), intent=d.get("intent", ""), + call_label=d.get("call_label", ""), ) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index a122dc4f..ce9811ba 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -566,6 +566,33 @@ def _abort_run_sentence(record: dict, round_num: int) -> str: ) +def _tool_call_labels(tool_calls) -> list[str]: + """The label each call of one assistant response carries, ready to print. + + **This is the only place the rule lives.** A client that counted the calls + itself would be a second answer to the same question, and the two would drift + the first time either side changed — invisibly, because a wrong `(2/3)` looks + exactly like a right one. So the daemon decides here, hands out a finished + string, and every renderer prints what it was given. + + A batch is **one assistant response's `tool_calls` array**: `y` is its length + and `x` counts from 1. The string carries its own trailing space, so a row is + `f"{icon} {label}{name}"` with no branch and no arithmetic on the client side. + + A response that issued a single call gets `""` — `(1/1)` would be noise on + every ordinary turn, where the absence of a label already means "one call". + + Calls whose permission was refused, whose execution failed, or whose tool name + was unknown are all still calls of this response: they are counted, because the + host reading the transcript wants to see the batch the model emitted, not the + subset that happened to succeed. + """ + total = len(tool_calls or []) + if total < 2: + return [""] * total + return [f"({index}/{total}) " for index in range(1, total + 1)] + + def build_shell_tool( sandbox_config: Optional[SandboxConfig] = None, platform_name: str | None = None, @@ -3431,7 +3458,17 @@ async def _process_message( } tool_calls = r.get("tool_calls") if tool_calls: - item["tool_calls"] = tool_calls + # Copies carrying the batch label, never a mutation of the + # record: the label is computed here, at read time, so a + # session written before this existed replays with it too + # (rant 2026-10-20T12:22:21). A disk write would have made + # the answer a property of when the session was recorded. + labels = _tool_call_labels(tool_calls) + item["tool_calls"] = [ + {**call, "call_label": labels[index]} + if isinstance(call, dict) else call + for index, call in enumerate(tool_calls) + ] history_messages.append(item) elif rtype == "summary": # One per compaction (#2351): the live stream announced it @@ -4805,7 +4842,11 @@ async def _run_tool_loop( # Execute each tool. tool_calls are already embedded in the # assistant message above, so we only persist tool_results here. - for tc in openai_tool_calls: + # One batch, one computation (rant 2026-10-20T12:22:21): the label for + # every call of this response is decided before the loop, so the frame + # carries a finished string and no client has to count anything. + call_labels = _tool_call_labels(openai_tool_calls) + for call_index, tc in enumerate(openai_tool_calls): tc_id = tc["id"] tc_name = tc["function"]["name"] tc_args_str = tc["function"]["arguments"] @@ -4830,6 +4871,7 @@ async def _run_tool_loop( "tool_call_id": tc_id, "arguments": args, "intent": args.get("intent") or "", + "call_label": call_labels[call_index], }) # The pieces of a call the model must not choose (D1). diff --git a/tests/test_tool_call_batch_label.py b/tests/test_tool_call_batch_label.py new file mode 100644 index 00000000..c3486c6f --- /dev/null +++ b/tests/test_tool_call_batch_label.py @@ -0,0 +1,322 @@ +"""A tool row says which call of its batch it is — and only the daemon decides that. + +Rant 2026-10-20T12:22:21. The host asked that a tool call's display carry `(x/y)` +so a batch of calls from one LLM response can be told apart from calls that arrived +one at a time, and asked for one implementation: **the daemon decides what a batch +is and hands each call a finished string**; the clients print what they are given. + +That requirement is why this file asserts what it does. A helper's return value +proves only that the helper is right; it says nothing about whether the frames and +the projected history — the two things clients actually receive — carry the label. +So the subjects here are: + +* **the live frame, read off the wire.** `_run_tool_loop` is driven with a stubbed + LLM that emits a three-call response, and the `tool_start` broadcasts are read in + order. A label computed but never attached to the frame fails here, which a unit + test of the helper would have passed. +* **the history projection, read through `list_history`.** A session recorded + **before** the field existed is projected through the real message handler, so the + assertion is about what a reopening client receives — including for a session + written earlier, which is the whole point of computing the label at read time + rather than persisting it. +* **the TUI's rendered line.** `ToolCard.render` is asked for its text, because a + card that stores a label and does not draw it is the failure mode a field test + cannot see (the same lesson as the message-time rant: assert the rendering, not + the field). +* **the absence, twice.** A frame and a record with no `call_label` must render + **exactly** as they do without this feature — and specifically must not invent + `(1/1)`, which is what a client that counted the calls itself would show. + +No daemon is started, stopped or restarted: `_run_tool_loop` and `_process_message` +are called directly, with the broadcast and the websocket stubbed. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest +from rich.style import Style + +from emrg.client.app import _cards_from_tool_calls +from emrg.client.python_tui.widgets.base import RenderContext +from emrg.client.python_tui.widgets.tool_card import ToolCard +from emrg.config import LlmConfig +from emrg.protocol import TaskRequest, ToolStart +from emrg.server import daemon as daemon_mod +from emrg.server.daemon import EmrgServer +from emrg.server.tool_types import ToolResult +from emrg.session import Session + + +def _ctx(width: int = 100) -> RenderContext: + return RenderContext(width=width, style=Style.null()) + + +def _make_server() -> EmrgServer: + server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) + server._projects_log = Path(tempfile_dir()) / "projects.yml" + return server + + +def tempfile_dir() -> str: + import tempfile + + return tempfile.mkdtemp() + + +class _StubTool: + """A tool that executes nothing and reports success, so a batch can be driven. + + The subject is the label, not the tool: naming a real tool would drag the + sandbox, the platform's shell dialect and an actual subprocess into a test + about a string. + """ + + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def execute(self, args): + self.calls.append(dict(args)) + return ToolResult(content="ok") + + +def _drive_tool_loop(tmp_path, monkeypatch, calls: int) -> list[dict]: + """Run one tool loop whose first round emits `calls` tool calls; return the frames.""" + monkeypatch.setattr(daemon_mod, "_PLANTED_FIRE_MARKER_PATH", + tmp_path / "planted-fire-heartbeat") + monkeypatch.setattr(daemon_mod, "_PLANTED_FIRE_ROUND_COMPLETE_PATH", + tmp_path / "planted-fire-round-complete") + server = _make_server() + session = Session.create_with_id(f"batch-label-{calls}", tmp_path) + stub = _StubTool() + server.tools.get = lambda name: stub # type: ignore[method-assign] + + state = {"round": 0} + + async def fake_stream(messages, tools=None): + state["round"] += 1 + if state["round"] == 1: + yield {"content": None, "tool_calls": [ + { + "index": index, + "id": f"call_{calls}_{index}", + "function": {"name": "read", "arguments": '{"intent": "x"}'}, + } + for index in range(calls) + ], "finish_reason": "tool_calls", "usage": None} + else: + yield {"content": "done", "tool_calls": None, "finish_reason": "stop", + "usage": {"prompt_tokens": 1, "completion_tokens": 1}} + + frames: list[dict] = [] + + async def fake_broadcast(session_id, payload): + frames.append(payload) + + server.llm.chat_stream = fake_stream + server._broadcast = fake_broadcast + req = TaskRequest(id="req-batch", session_id=session.session_id, prompt="go") + asyncio.run(server._run_tool_loop(req, None, session)) + return frames + + +class _FakeWs: + """A subscriber that records the frames the daemon sends it.""" + + def __init__(self) -> None: + self.sent: list[dict] = [] + + async def send(self, data) -> None: + self.sent.append(json.loads(data)) + + +def _history_messages(tmp_path, records: list[dict]) -> list[dict]: + """Project `records` through the real `list_history` handler.""" + server = _make_server() + session = Session.create_with_id("batch-label-history", tmp_path) + for record in records: + session.append_message(record) + ws = _FakeWs() + asyncio.run(server._process_message( + {"type": "list_history", "session_id": session.session_id, "cwd": str(tmp_path), + # Records mode: the projection — and therefore the label — lives on this + # payload, which is what a client replaying a session asks for. + "include_records": True}, + ws, + )) + frames = [f for f in ws.sent if f.get("type") == "history_list"] + assert frames, f"no history_list frame was sent: {ws.sent}" + return frames[0]["messages"] + + +def _tool_call(index: int) -> dict: + return { + "id": f"call_old_{index}", + "type": "function", + "function": {"name": "read", "arguments": '{"intent": "x"}'}, + } + + +# ── the live frames ──────────────────────────────────────────────────────── + + +def test_a_three_call_response_labels_each_start_frame_in_order(tmp_path, monkeypatch): + """`(1/3) `, `(2/3) `, `(3/3) ` on the frames the client reads — not on a helper.""" + frames = _drive_tool_loop(tmp_path, monkeypatch, calls=3) + + starts = [f for f in frames if f.get("type") == "tool_start"] + assert [f["call_label"] for f in starts] == ["(1/3) ", "(2/3) ", "(3/3) "], ( + f"the frames carry {[f.get('call_label') for f in starts]!r}" + ) + assert [f["tool_call_id"] for f in starts] == ["call_3_0", "call_3_1", "call_3_2"] + + +def test_a_single_call_response_carries_no_label(tmp_path, monkeypatch): + """A batch of one shows nothing: `(1/1)` would be noise on every ordinary turn.""" + frames = _drive_tool_loop(tmp_path, monkeypatch, calls=1) + + starts = [f for f in frames if f.get("type") == "tool_start"] + assert len(starts) == 1, f"expected one tool_start, got {len(starts)}" + assert starts[0]["call_label"] == "" + + +def test_the_end_frame_does_not_carry_a_second_copy_of_the_label(tmp_path, monkeypatch): + """One source, not two: the card already holds the label, so `tool_end` adds none. + + A second carrier is a second thing to keep in step, and the client would then + have two ways to spell the same row (rant 2026-10-20T12:22:21). + """ + frames = _drive_tool_loop(tmp_path, monkeypatch, calls=2) + + ends = [f for f in frames if f.get("type") == "tool_end"] + assert len(ends) == 2, f"expected two tool_end frames, got {len(ends)}" + assert all("call_label" not in f for f in ends), ( + "tool_end carries call_label — the label belongs to the start frame only" + ) + + +# ── the history projection ───────────────────────────────────────────────── + + +def test_history_projection_labels_a_batch_recorded_without_one(tmp_path): + """A session recorded before this change replays with `(x/y)`: computed at read time.""" + messages = _history_messages(tmp_path, [{ + "type": "message", "role": "assistant", "content": None, + "tool_calls": [_tool_call(0), _tool_call(1), _tool_call(2)], + }]) + + assistant = [m for m in messages if m.get("kind") == "message"] + assert len(assistant) == 1, messages + projected = assistant[0]["tool_calls"] + assert [c["call_label"] for c in projected] == ["(1/3) ", "(2/3) ", "(3/3) "] + assert [c["id"] for c in projected] == ["call_old_0", "call_old_1", "call_old_2"] + + +def test_the_projection_copies_the_record_instead_of_mutating_it(tmp_path): + """The label is a reading, not a write: the stored record keeps its own shape. + + Persisting it would make the answer a property of when the session was + recorded — the defect this whole design avoids. + """ + server = _make_server() + session = Session.create_with_id("batch-label-purity", tmp_path) + session.append_message({ + "type": "message", "role": "assistant", "content": None, + "tool_calls": [_tool_call(0), _tool_call(1)], + }) + ws = _FakeWs() + asyncio.run(server._process_message( + {"type": "list_history", "session_id": session.session_id, "cwd": str(tmp_path), + # Records mode: the projection — and therefore the label — lives on this + # payload, which is what a client replaying a session asks for. + "include_records": True}, + ws, + )) + + on_disk = session._read_history() + stored = [r for r in on_disk if r.get("tool_calls")][0]["tool_calls"] + assert all("call_label" not in call for call in stored), ( + f"the projection wrote into the stored record: {stored!r}" + ) + + +def test_history_projection_leaves_a_single_call_unlabelled(tmp_path): + messages = _history_messages(tmp_path, [{ + "type": "message", "role": "assistant", "content": None, + "tool_calls": [_tool_call(0)], + }]) + + projected = [m for m in messages if m.get("kind") == "message"][0]["tool_calls"] + assert [c["call_label"] for c in projected] == [""] + + +# ── the TUI's rendered line ──────────────────────────────────────────────── + + +def _rendered(card: ToolCard) -> str: + """The text the card really draws, joined from its spans.""" + lines = card.render(_ctx()) + return "".join(span.text for span in lines[0].spans) + + +def test_the_tui_row_prints_the_label_before_the_name(): + card = ToolCard(name="bash", command="run the tests", status="done", call_label="(2/3) ") + + rendered = _rendered(card) + + assert "(2/3) " in rendered, rendered + assert rendered.index("(2/3) ") < rendered.index("bash"), ( + f"the label must precede the name: {rendered!r}" + ) + + +def test_a_row_without_a_label_renders_exactly_as_before(): + """The absence guard: no placeholder, no `(1/1)`, no empty brackets.""" + rendered = _rendered(ToolCard(name="bash", command="run the tests", status="done")) + + assert rendered == "✓ bash: run the tests", rendered + assert "(1/1)" not in rendered + + +def test_a_replayed_batch_labels_its_cards_from_the_projected_record(tmp_path): + """The TUI's replay path takes the label off the record, and computes nothing. + + Two halves in one reading: a projected record produces labelled cards, and a + record with **no** `call_label` produces cards whose rendering is byte-identical + to the pre-change one — including that no client-invented `(1/1)` appears. + """ + projected = [ + {**_tool_call(0), "call_label": "(1/2) "}, + {**_tool_call(1), "call_label": "(2/2) "}, + ] + cards = _cards_from_tool_calls(projected) + labelled = [_rendered(c) for c in cards] + assert all("(1/2) " in text or "(2/2) " in text for text in labelled), labelled + assert labelled[0].index("(1/2) ") < labelled[0].index("read"), labelled + + # The absence half: a record with no `call_label` must render exactly as a card + # built the way this feature did not exist — which is the proof that the client + # computes nothing of its own. + bare = _cards_from_tool_calls([_tool_call(0), _tool_call(1)]) + for index, card in enumerate(bare): + baseline = ToolCard(name="read", command=card.command, status="pending", + tool_call_id=card.tool_call_id, arguments=card.arguments) + assert _rendered(card) == _rendered(baseline), ( + f"row {index} renders differently once the field is absent: " + f"{_rendered(card)!r} vs {_rendered(baseline)!r}" + ) + assert "(1/1)" not in _rendered(card) + assert "(1/2)" not in _rendered(card) + + +def test_the_protocol_defaults_the_label_for_a_frame_that_predates_it(): + """An older frame has no field; it must read as a single call, not crash.""" + frame = ToolStart.from_dict({ + "request_id": "r", "tool_name": "bash", "tool_call_id": "c", + "arguments": {}, "intent": "x", + }) + + assert frame.call_label == ""