feat(service,panel): every spoken reply folds under its turn

Merged
DA
5d ago
+585-65

A follow-up in the Unified agents rollout, after #4337.

Why

On a call into an agent, the tab folds a turn under the voice's spoken reply only when the reply's stored metadata names the turn (read_from). The writer adds read_from only for words written within two minutes after the turn settled. Two kinds of reply stayed unfolded, so the answer was read twice:

  • a reply said while its turn still ran. This is the usual case when a call joins a busy agent's running turn (#4260).
  • a reply said more than two minutes after the turn settled.

What changed

The agent page names each reply's turn (readFrom). I chose the page-row contract #4337's worker suggested over widening what the writer stores.

  • transcript.ts reads the ask binding of each voice line's delegation_id on every page, through the new askTurnsOf in coding-agent-store.ts. The line then carries readFrom: { turnId, afterStep? }, where afterStep is asks.joined_after_step for an ask that joined a typed turn under way.
  • Each turn's journal carries its turnId, so the panel can match a reply to its turn. The wire field already existed and was documented for task pages only; its doc now covers agent pages too.
  • codingAgentMessageSchema gains an optional readFrom.

Why this contract and not stored read_from:

  • No time limit, and it covers older rows. The binding is read on every page, so it works however long after the turn the words were said. It also works for rows already written and for words said before the ask bound to its turn ("Checking now").
  • It carries the join step, which a journal id alone cannot.
  • No new stored metadata key. Stored assistant metadata is read strictly, so a new key would make rows unreadable to a rolled-back deployment.

The writer's read_from and its window are unchanged. The panel still falls back to read_from when a service older than this sends no readFrom.

The panel folds from the join on. spokenMessages handles two cases:

  • The ask was the turn's whole ask: the turn folds whole under the reply, as before.
  • The ask joined the turn under way: the reply stands right after the ask's line among the turn's work. Once the turn ends, only the work from the join step on folds under the reply, including its written answer and "Worked for". The earlier work draws as it would on a turn nobody read aloud. This uses the new saidFrom in turnRows (turn-rows.ts).

While the turn still runs, nothing folds, and the reply reads inline after the ask. A line with no known turn stays where it was said.

Not covered

  • The call's divider still stands above a busy turn whose early work came before the call. Dividers are placed per row, and the call's first line now sits inside the turn. Out of scope here; a follow-up could place the divider before the joined line.
  • A voice line with no delegation_id still names no turn. That happens when the developer's latest line was never handed to the brain, even if the brain's follow was reading a turn at the time. Linking it would mean threading the follow's run into the voice ledger.

Tests

Each of the first two kinds fails on main:

  • coder-host.test.ts: a spoken ask joins a typed turn under way. The voice answers while the turn runs, and again three minutes after it settled; both lines name { turnId, afterStep: 1 }, and the journal names the same turnId. A line whose ask never bound names nothing.
  • agent-call-fold.test.ts:
    • On a call into a busy agent, the reply stands after the joined ask inside the turn. The pre-join work stays its own fold, and only the post-join work, its answer and "Worked for 1m 10s" fold under the reply.
    • A reply named by readFrom alone, as a late one is, folds its turn.
    • A voice line with no turn stays where it was said, and the turn is drawn unfolded. This is a guard, so it passes on main too.
  • coding-agent-store.test.ts: askTurnsOf returns bound asks with their join step, nothing for an unbound ask, and nothing for another account.

Ran locally:

  • vitest over packages/service/src/agents/coder-host, coding-agents-app, packages/service/src/tasks, packages/panel/src/tasks, packages/features/src and the store tests: all green.
  • Typecheck of features, database, panel and service.
  • biome and oxlint on the changed files.
  • pnpm check:affected ran lint and the discarded-effect check green, but its parallel @luke/service typecheck was OOM-killed in this sandbox. The same typecheck passed when run alone.

Made with Luke


Devin Review