emrg: a tool row shows which call of the batch it is, decided once in the daemon #2402
Description
A tool call's row shows the tool's name and what the call is for, but nothing says whether it arrived in the same LLM response as its neighbours or in a separate one. When a model issues three calls at once, the transcript shows three identical-looking rows and the host cannot tell a batch from three consecutive single calls — a distinction that decides whether the model was working in parallel or serialising.
What is expected
A row whose call came in a batch says which call of that batch it is, immediately before the tool name:
TUI ✓ (1/3) bash: run the tests
✓ (2/3) read: read the implementation
✓ (3/3) edit: change it
GUI ✓ (1/3) bash run the tests
✓ (2/3) read read the implementation
✓ (3/3) edit change it
A batch of one shows nothing — no (1/1), no empty parentheses, no placeholder: the row must read exactly as it does today, character for character.
The rule: the daemon decides, once
Both clients render what the daemon hands them; neither counts, computes or composes anything. This is the requirement, not a preference: two implementations of "which call of which batch" is two answers that will disagree, and the disagreement will be invisible until a host reads a wrong number.
- The batch is one assistant response's
tool_callsarray;y = len(tool_calls),xcounts from 1. - The daemon produces a ready-to-print string —
"(2/3) ", with its own trailing space — and""wheny == 1. The client's whole job isf"{icon} {label}{name}": noif, no arithmetic, nolen(). - Calls whose permission was refused, whose execution failed, or whose tool name was unknown all count toward the batch. They are calls of the same response; only their outcome differs.
What must be true when this is done
- One helper, two callers. A single module-level function in
emrg/server/daemon.pyreturns the whole batch's labels. The live broadcast and the history projection both call it; neither keeps its own copy of the rule. - The live frame carries it. The
tool_startbroadcast gainscall_label.tool_enddoes not — the card already holds the label, and a second source is a second spelling. - The history projection carries it too, computed at read time.
history_list's assistant entries projecttool_callsas copies carryingcall_label— never a mutation of the stored record. Sessions recorded before this change then show(x/y)as well, with no migration and no client-side recomputation. - The protocol defaults.
ToolStart.call_label: str = "", read asd.get("call_label", ""), so an old frame or an old client is unaffected. - Both clients pass it through. TUI: a
call_labelfield on the card and one construction-site change each for live and replay. GUI:call_labelonToolStartData,callLabelonToolRow, read inhandleToolStart— the replay path already goes through the same handler, so it needs only the pass-through. - Detail views stay bare. The expanded detail panel's name row and the TUI detail selector keep the plain tool name; only the collapsed row carries the label.
What this must not do
- No client may derive x/y from
tool_calls.length, fromcallIdordering, or from anything else it can see. The evidence that this holds is acceptance item 4 below. - Do not put the label into
arguments,intentortool_name: that would change what the daemon's log prints, whatintentmeans, and what the tools receive. - Do not change the meaning or order of
tool_name,intent,arguments, and do not touch the GUI's tool-group summary bar. - Do not change the persisted record schema and do not write a migration script — the label is a projection.
- Never
(1/1), never an empty pair of parentheses for a single call.
Evidence that this is done
- Daemon, live frames. One response with three tool calls ⇒ the three
tool_startframes carry"(1/3) ","(2/3) ","(3/3) "in order; one response with one call ⇒"". Assert the frames, not the helper's return value. - Daemon, history projection. An assistant record holding three tool calls ⇒ every projected item carries its
call_label; a record on disk without the field ⇒ the projection still carries it, proving it is computed at read time. - TUI, rendered. The text of
ToolCard(...).render(ctx)'s line contains"(2/3) "immediately before the name — assert the rendered string, not the field. - GUI, rendered. A tool row rendered by
TranscriptViewshows(2/3)beforetool-name, pinned inTranscriptView.test.tsxundernpm test. - Missing-field guards, one per client. With
call_labelabsent from the frame and from the record, the rendered output is byte-identical to today's and contains no(1/1), no( / ), no empty parentheses. This assertion is the proof that the clients do not compute the label. - Mutation arms, each with its output quoted. (a) Force the daemon's labels to
""⇒ items 1 and 2 go red. (b) Forceyto 1 ⇒ items 1, 3 and 4 go red. (c) Add a client-side counting implementation ⇒ item 5 must go red; if it stays green, that guard is not testing what it claims.
Acceptance
uv run --no-sync pytest tests/fully green (the whole suite, not a targeted subset) andnpm testgreen inemrg/gui/renderer.- A real multi-call turn quoted as evidence: the TUI's three rendered lines and the GUI's rendered row set, both showing
(1/3) … (3/3). - A session recorded before this change opens with
(x/y)visible — proof that the read-time projection works. - A single-call turn's row is unchanged, character for character.
Activity
argszero commentedon Oct 20, 2026
Handled by #2403
emrg: parallel tool calls carry a batch label (#2402) (#2403)