Skip to content

emrg: a tool row shows which call of the batch it is, decided once in the daemon #2402

Closed

Description

@argszero

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_calls array; y = len(tool_calls), x counts from 1.
  • The daemon produces a ready-to-print string — "(2/3) ", with its own trailing space — and "" when y == 1. The client's whole job is f"{icon} {label}{name}": no if, no arithmetic, no len().
  • 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

  1. One helper, two callers. A single module-level function in emrg/server/daemon.py returns the whole batch's labels. The live broadcast and the history projection both call it; neither keeps its own copy of the rule.
  2. The live frame carries it. The tool_start broadcast gains call_label. tool_end does not — the card already holds the label, and a second source is a second spelling.
  3. The history projection carries it too, computed at read time. history_list's assistant entries project tool_calls as copies carrying call_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.
  4. The protocol defaults. ToolStart.call_label: str = "", read as d.get("call_label", ""), so an old frame or an old client is unaffected.
  5. Both clients pass it through. TUI: a call_label field on the card and one construction-site change each for live and replay. GUI: call_label on ToolStartData, callLabel on ToolRow, read in handleToolStart — the replay path already goes through the same handler, so it needs only the pass-through.
  6. 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, from callId ordering, or from anything else it can see. The evidence that this holds is acceptance item 4 below.
  • Do not put the label into arguments, intent or tool_name: that would change what the daemon's log prints, what intent means, 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

  1. Daemon, live frames. One response with three tool calls ⇒ the three tool_start frames carry "(1/3) ", "(2/3) ", "(3/3) " in order; one response with one call ⇒ "". Assert the frames, not the helper's return value.
  2. 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.
  3. TUI, rendered. The text of ToolCard(...).render(ctx)'s line contains "(2/3) " immediately before the name — assert the rendered string, not the field.
  4. GUI, rendered. A tool row rendered by TranscriptView shows (2/3) before tool-name, pinned in TranscriptView.test.tsx under npm test.
  5. Missing-field guards, one per client. With call_label absent 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.
  6. Mutation arms, each with its output quoted. (a) Force the daemon's labels to "" ⇒ items 1 and 2 go red. (b) Force y to 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) and npm test green in emrg/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

  1. argszero commented on Oct 20, 2026

    @argszero
    OwnerAuthor

    Handled by #2403

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions