From cf5c07590dcfc7b32c0821f569896844f2ff581b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 20 Oct 2026 13:15:16 +0800 Subject: [PATCH] emrg: parallel tool calls carry a batch label (#2402) When the model issues several tool calls in one round, every card now shows which call of the batch it is, as "(n/m)" before the tool name. The label is computed at read time from the call's position in the round, never written into the saved record, so replaying an old session relabels it rather than trusting a stored string. Both clients render the same field: the TUI tool card and the GUI transcript row. Closes #2402 --- Agent.md | 2 +- emrg/client/app.py | 7 + emrg/client/python_tui/widgets/tool_card.py | 10 +- .../src/components/TranscriptView.test.tsx | 31 ++ .../src/components/TranscriptView.tsx | 1 + .../renderer/src/lib/historyReplay.test.ts | 54 +++ emrg/gui/renderer/src/lib/historyReplay.ts | 17 +- emrg/gui/renderer/src/lib/transcript.ts | 11 + emrg/protocol.py | 8 + emrg/server/daemon.py | 46 ++- tests/test_tool_call_batch_label.py | 322 ++++++++++++++++++ 11 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 tests/test_tool_call_batch_label.py diff --git a/Agent.md b/Agent.md index cbea86b2c..117e7b018 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 a118febb4..232c02b9e 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 8805e7359..fa8184bdb 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 273ee5a50..742caf477 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 a37f72542..336e7f7dd 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 241808274..f47abd7ec 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 c70e5588a..184fa4dac 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 e7d958d3a..2d80a93c3 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 6a750ec94..a903a33de 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 a122dc4f0..ce9811ba3 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 000000000..c3486c6f7 --- /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 == ""