Commit Graph
3 Commits
Author SHA1 Message Date
Brennan BensonandMerge Sim 72befaf360 feat(native-chat): show Claude subagent activity on the shared carrier (#18806)
* feat(native-chat): show Claude subagent activity on the shared carrier

Claude's `message:system:task_*` frames are classified `status-chrome` and
reach the transcript as nothing at all, so a turn that spawns subagents
renders as an idle turn. The journal translator now reads them into the
shared subagent-group carrier — no new UI, and the frames stay
`status-chrome` so nothing prints a raw opcode row.

`local_agent`, `local_workflow` and `local_bash` tasks share that channel
and all carry a `tool_use_id`, so `task_type` is the discriminator and a
backgrounded `sleep 20` stays out of the roster; `subagent_type` covers
releases that predate `task_type`. `skip_transcript` tasks never render,
`is_backgrounded` children survive the turn-end sweep, and a resumed task
re-announced under a fresh tool id is aliased onto its `task_id` rather
than duplicated.

A child still reported as working when the turn — or the session — ends
becomes `unverifiable`: contact was lost, which is not evidence it exited.

* fix(native-chat): stop the Claude subagent roster dropping its own rows

The roster published under the same coalescing key it appends the row
with, and the sink queue replaces any queued operation sharing a key
regardless of kind: once a write was in flight, each new append evicted
the pending publish and the next publish evicted that append, so the
body never reached the journal and `lastSerialized` had already moved
past it. Publish now takes the sink's own slot, as the Codex streams do.

A tombstoned row could never come back: the non-batch item-row builder
derived its revision from `items` alone, so a re-add was built at
revision 1 against a tombstone at 2 and the reducer discarded it
forever. It now takes the same `max(items, tombstones)` the batch
builder already used — reachable here because an announcement that
reveals a `local_bash` task empties and tombstones the group row that
a genuine subagent later in the turn reuses.

`settleTurn` swept whatever group the key named at the time it ran, so
children rostered before any turn key existed were never swept, and a
turn whose result never arrives was left working forever. The ending
turn's key is now an argument, a superseding turn start settles the
turn it replaces, and every turn end also sweeps the outside-turn
group. Teardown without an `ended` event, and eviction past the group
bound, both lose contact instead of stranding a row at `working`.

Label ordinals are a high-water mark now: releasing one on a re-label
handed the next child an ordinal that was already on screen.

* fix(native-chat): bound subagent-group blocks on every wire that carries one

Adding a fifth arm to `NativeChatBlock` made every consumer that assumed
four wrong. Two of them ended in `return block`, so they compiled while
handing a roster straight through: the mobile RPC sanitizer shipped it
unclipped past both mobile char caps, and the legacy transcript import
stored an untrusted roster unbounded. Both now clip each label and cap
the entry count the way they bound their other blocks.

The remaining three sites did not compile at all. The worker transcript
payload and the live-session benchmark get real arms rather than casts —
a cast would have turned the transcript one into a third silent
passthrough inside the wire byte budget — and the CLI worker output
renders a roster with its shared summary instead of `[image omitted]`.

The mobile sanitizer moves to a sibling module beside the image-block
one: the file sat exactly on the max-lines bound, and the block bounds
are a self-contained concern with their own caps.

Also caps the roster's `subagent_type` label fallback, which reached the
journal uncapped, and covers the new block type in the schema audit.

* test(native-chat): cover the capped subagent_type label

The roster stores the frame's label verbatim, so the cap on the
`subagent_type` fallback is the only thing bounding it.

* fix(native-chat): type the roster fixture so the suite typechecks

The mobile-cap test built its entries with an inferred `state: string`, which
is not a `NativeChatSubagentState` — the only typecheck failure on the branch.

* fix(native-chat): stop child traffic rostering an id Claude never announced

`observeChildActivity` minted a provisional row for any `parent_tool_use_id`
outside the excluded set. An id that was never announced is never excluded, so
a nested Task, a workflow child, or a grandchild parented to a tool id inside
the sidechain each produced a permanently unlabelled `subagent` row that could
only ever end `unverifiable`. The bounded exclusion set cannot cover an id no
frame ever declared, and in a long session it can forget a genuine exclusion.

Track instead whether this CLI announces tasks at all — set by ANY
`task_started`, including one the subagent filter rejects. Once it has, an
undeclared child is provably not a new subagent, so no row is created. The
provisional path now serves only releases that announce no task frames, which
is what its comment already said it was for.

The label-ordinal test moves to an announcement-driven removal, the scenario
that path now actually reaches; it still fails if `remove` releases the ordinal.

* fix(native-chat): outrank the tombstone when building one too

`buildJournalTombstoneRow` still built its revision from `items` alone, leaving
it asymmetric with the item builder. It is correct today only because
`upsertItem` clears the tombstone whenever a re-add wins — an invariant that
lives in the reducer and was not pinned. Apply the same `Math.max`, and pin the
invariant so the reducer cannot drop it silently.

* fix(native-chat): stop an unrelated turn end settling an outside-turn child

`settleTurn` swept the `outside-turn` group on every turn end, so a child
Claude announced while no turn was live — a frame trailing the previous
turn's result, or one that arrives before the first turn starts — was
marked `unverifiable` by the next, unrelated turn ending. That state is
terminal and latches, so the `task_updated: completed` that followed was
discarded: loss of contact was recorded as the child's outcome on
evidence that was never about that child.

A turn end now sweeps exactly the group its key names. `outside-turn`
belongs to no turn, so only an end with no key of its own reaches it, and
what no turn end reaches `settleSession` does — reliably, since teardown
without an `ended` event also routes through it. The cost is a child
outside every turn showing `working` a little longer; the alternative
prints a wrong outcome that nothing can revise.

Also pins that a subagent announced after a task the filter rejected
still rosters: the announcement path was never what the child-traffic
gate closes.

* fix(native-chat): bound a subagent entry's id, not just its label

Every site that bounds a `subagent-group` block clipped the label and
handed the id through whole. From the Claude producer the id is bounded
upstream, but the legacy transcript import reads an untrusted file, so an
oversized id survived into the journal and then out to every wire that
replays it — 64 entries of it, since only the entry count was capped.

Each site now clips the id with the helper it already uses for its other
bounded fields: the journal's inline-text bound on import, the
transcript payload's metadata clip, and the mobile char cap (renamed,
since it is no longer a label-only cap).

* fix(native-chat): surface an adverse subagent outcome in the fallback sentence

The roster row's plain-text stand-in counted only `working`, so a fan-out whose
children all latched `unverifiable` (or `failed`, or `stopped`) rendered as
"Ran 3 subagents" — a completion claim. Mobile and paired web have no roster
renderer, so that write-time-frozen sentence is the entire row there, and
collapsing `unverifiable` into something that reads like success is exactly what
the SSH execution boundary forbids.

It now appends the worst adverse count, worst-first across failed/stopped/
unverifiable, and shows it even while siblings still work — matching the Codex
lane's shared `subagentGroupFallbackText` verbatim so collapsing the two copies
later is a deletion, not a behaviour change.

Also bounds the provisional entry id. `observeChildActivity` wrote the
`parent_tool_use_id` straight into the entry's durable id with no length cap,
while the announced path already rejects an over-long id via `claudeTaskId`.
Both now share `isBoundedClaudeTaskId`, and the provisional path rejects rather
than truncates, as the announced one does.

* fix(native-chat): stop a subagent label ordinal and a clipped roster key colliding

- claimLabel probes the labels the group actually rendered instead of a
  per-base counter, so a generated `Audit 2` cannot duplicate a provider's
  own `Audit 2`.
- Bound `NativeChatSubagentEntry.id` with a head plus a digest of the whole
  id at every site that bounds it. The id is the roster key: a prefix clip
  merged two distinct children onto one entry.
- Correct a stale journal-reducer test comment: tombstone cleanup is a
  map-state invariant, no longer load-bearing for revision ordering.

* fix(claude): merge duplicate unhandled-provider-frame imports

* fix(claude): preserve subagent lifecycle and bounded invocation identity

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 21:44:10 -07:00
Brennan Benson 4a6de51ad8 fix(native-chat): enforce each pending send's own boundary in glue matching (STA-4477) (#14935)
* fix(native-chat): enforce each pending send's own boundary in glue matching

Glue matching filtered candidate rows against the OLDEST still-open send and
then matched the entire open queue against them. A prompt queued after a glued
row landed could therefore be judged "already delivered" by that older row and
pruned — the queued prompt disappeared with no bubble and no transcript turn.

Each send now carries its own transcript boundary into the match:
`gluedCandidateRows` tags every candidate row with the set of pending indices
it actually landed after, and the matcher stops a run at the first send the row
predates rather than skipping over it (adjacency is what makes a row glue).
Exact single matches still belong to the occurrence path, unchanged.

`native-chat-pending.ts` sat at 299 of its 300 effective-line budget, so the
slash-command marker cache — a separate rule that never took part in pending
pruning — moves verbatim to `native-chat-command-marker.ts`. Pure move: no
behavior change, imports only. (max-lines is never bumped or disabled.)

Refs STA-4477. Original PR #14663.

* test(native-chat): cover the glue adjacency break and unmask the render path

The `break` on a send the row cannot represent is the fix's central semantic
choice, and swapping it for `continue` was passing the whole suite: nothing
exercised a queue whose middle send is unrepresentable. Add that case.

The mixed-age case also asserted both call sites in one `it`, so a prune-path
failure masked the render-path assertion — and the render path is the one that
makes a queued bubble visually vanish. Split it.

Skip the per-send boundary scans when fewer than two sends are open: the glue
matcher already returns nothing there, so a lone queued echo was walking the
transcript twice per render for a discarded result.

* fix(native-chat): migrate the live-session benchmark off the renamed glue exports

Renaming the glue matcher's exports left this caller behind, and it crashed at
runtime after printing six result rows:

  TypeError: matchingNativeChatUserTexts is not a function

No gate caught it. config/scripts/** is in no tsconfig include and the file is
not a *.test.ts, so neither typecheck nor vitest ever loads it.

The empty-pending arm passes no pending sends, so the matcher takes its
empty-queue exit without ever reading the rows — which is also why the renderer
skips candidate-row construction entirely in that case. Escaping the row scan
directly keeps what this arm actually measures identical to before, rather than
fabricating per-row boundary sets that no production path builds.
2026-08-17 12:02:47 -07:00
Neil 75f5e2d964 perf(renderer): reuse prepared native chat messages (#13519) 2026-08-10 01:00:20 -07:00