mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(native-chat): show Codex subagent activity instead of opcode rows (#18773)
* feat(native-chat): show Codex subagent activity instead of opcode rows
Codex spawns subagents and reports their lifecycle, but Orca rendered only
gray `codex · item:subAgentActivity` opcode rows. Build the real display: one
summary row per spawn group with a live working count and token usage.
State is accumulated from `subAgentActivity.kind` alone. A live probe against
app-server 0.152.1 showed `agentsStates` arrives empty even in a real subagent
run, and that every activity item is delivered twice (item/started and
item/completed), so every transition is idempotent and terminal states latch.
Children never receive `thread/started`, so there is no nickname, role, or
depth to read; the row labels from the trailing segment of `agentPath`.
Two sweeps keep a row from claiming work forever: the parent turn's terminal
event settles still-running children, and session start marks a pre-restart
roster unverifiable rather than exited, since Codex resume replays no
non-message items and no event can ever settle them.
The roster rides a new NativeChatBlock variant paired with a plain-text twin.
A journal item kind could not be used: that union is closed, and an unknown
kind parses as malformed, which is the corrupt-journal class that can hide the
chat tab. Block types are explicitly admissible when unknown, so an older
client drops the block and renders the sentence.
MessageRow moves out of NativeChatMessageList to keep both files under the
max-lines budget without a disable.
* feat(native-chat): give the subagent summary row its bot glyph
The row led with a glyph that swapped on state — a check once every child
completed, a group icon otherwise — so a group appeared to change identity
the moment it settled. Per the approved mock, the glyph names the category
and never moves: state is carried by the status dot and the tone of the
words beside it.
Use lucide `bot`, the same glyph the individual `subAgentActivity` rows take
in the eight-category vocabulary, so the summary reads as their parent. Slot
and glyph are the mock's 16px/14px, muted by default, and the svg is
`aria-hidden` — the headline is what a screen reader announces, so the icon
never stands alone.
* fix(native-chat): correct the Codex subagent roster's build, journal write, and failure reporting
* Restore the exhaustive block handling that adding `subagent-group` to
`NativeChatBlock` broke. `formatWorkerTranscriptMessage` and `boundBlock`
both fell through to `image-ref` field access, so `tsc -p` failed for the
CLI and node projects and `build:cli` could not emit. Both now guard on
`image-ref` explicitly and give the roster block its own branch.
* Stop the roster's publish from evicting its own append. The sink queue
coalesces by `coalescingKey` alone with no op-kind check, so passing the
append's key to `tryPublish` spliced the queued append out and the row
never reached the journal — permanently, since `lastSerialized` was
already set. `tryPublish()` now takes no argument, matching every other
call site. The regression test's fake sink honours the key, which the
previous fake did not.
* Keep `collabAgentToolCall` substantive. Only the MultiAgentV2 path emits
`subAgentActivity`, so a V1 turn has no roster row; suppressing its collab
tool calls too would have left a V1 fan-out showing nothing at all.
* Surface a settled failure while siblings still work. The summary now
reports the worst adverse outcome independently of the group verdict, so
the row shows `3 working +1 failed` with a failed-coloured dot instead of
a neutral pulsing dot. The plain-text twin names it too.
* Treat `/morpheus` as a child. Only `/root` is the turn itself; the old
segment-count test silently dropped a valid single-segment agent.
* Refresh token-usage recency on update so an active thread is not evicted
as the oldest entry, and scope the `agentsStates` comment to the V2 path.
* fix(native-chat): stop the subagent roster announcing a new duration every second
The roster row is an `aria-live="polite"` region and it contains the elapsed
clock, which reticks once a second for as long as the fan-out runs. A screen
reader therefore reads out a fresh duration every second, burying the state
changes the live region exists to report — the headline, the verdict, and the
`+1 failed` alert.
No other live region in the transcript does this. `NativeChatToolRun`'s live
button holds only the active tool label, and in `NativeChatWorkingStatus` the
variant that shows a duration is precisely the one with no `aria-live`.
Hide the clock from the accessibility tree only while it is moving. Once the
group settles the duration is fixed, so it stays readable and costs no
announcements.
* fix(native-chat): retry a refused roster publish, and stop two wrong readings
Four defects from a third review pass over the Codex subagent roster.
`write()` set `lastSerialized` before the append and rolled it back only when
the APPEND was refused. A refused PUBLISH left it set, so an identical replay
short-circuited and the revision was never published again. The repo's own
pattern is the opposite: `codex-structured-item-streams.ts` advances
`checkpointLengths` only once the append AND the publish are both accepted.
Roll back on either half.
That alone did not cover the sweep, which is the LAST event a group ever gets:
its `changed` guard skips the write on a retry because every child has already
latched, stranding the settled roster's final revision. Write when the previous
attempt was refused part-way, too.
`formatWorkerTranscriptMessage` read `block.agents` as its exhaustive fallback.
The journal schema deliberately admits block types this build does not know and
`client.call` casts the RPC result instead of validating it, so a newer remote
host's block reached that line and threw `agents is not iterable`, taking down
the whole `worker read`. It printed a harmless `[image omitted]` before. Match
`subagent-group` explicitly and degrade the unknown case.
The elapsed clock measured to `now` whenever no child carried a terminal
timestamp. That is exactly the roster restored from the journal after the host
died: the reconciler latches `unverifiable` without a `settledAt`, so a child
that ran four seconds reported the time since the crash as its run length, on a
row that is not even counting. Show no duration when none is known.
Also restores package.json to origin/main: the merge had deleted one of main's
two duplicate `bench:terminal-partial-escape-tail` keys. Behaviour-preserving
(JSON is last-wins and the deleted line was the dead one), but unrelated to this
PR and better left to its own change. No gate rejects duplicate JSON keys.
The new refusal tests also cover the append-side rollback, which had none.
* fix(native-chat): stop the subagent roster vanishing from every settled turn
`NativeChatToolRun` bailed out for a completed turn whose activity disclosure
is collapsed before it reached the branch that draws a roster-only run. That
guard exists to push TOOL activity behind the turn-status disclosure, and it
fires on exactly the shape a spawn group has: a roster message carries no tool
blocks, so `selectActiveToolCall` returns null and `isSettled` is true, while
the list passes `expandOverride={expandedTurnIds.has(turnKey)}` — false until
the reader opens that turn — and `activeTurnIsWorking={false}`.
That is the default state of every finished turn in the transcript, so the one
compact row this feature exists to leave behind ("Ran 3 subagents") disappeared
the moment its turn ended. Worse, `MessageRow` counts a spawn group as
renderable specifically so the row survives, then rendered a wrapper around a
component that returned null — the empty ghost bubble its own guard is written
to prevent.
Order the roster branch before the disclosure guard. A roster has no tool
activity to hide, and the guard's reasoning ("a failed child command looked
like the whole response was still running") does not reach it. Runs that do
carry tool blocks still fall through to the guard unchanged, and in practice a
roster never shares a message with them: it is its own `role: 'system'` journal
row and `isToolOnlyMessage` is false for it, so `foldToolMessages` never merges
tool blocks into it.
Also drop childless groups when building the rows, so `subagentRows.length`
stays an honest test of "something will draw" — the roster-only branch returns
a margin-bearing wrapper on the strength of it, and a group with no children
renders null.
Both tests fail with their fix reverted; the existing NativeChatToolRun suite
still passes, so the completed-turn disclosure behaviour is unchanged.
* test(native-chat): cover the subagent roster at the message-list level
Every defect this feature has shipped so far lived in the assembly between
rows, and the row-level suites kept passing through all of them. Loop 4's
regression — a settled roster swallowed by the completed-turn disclosure —
was found by reading the code, not by a test, and an independent visual-proof
run observed the same symptom in the real UI and routed around it rather than
reporting it. `NativeChatToolRun` rendered alone is handed `expandOverride`
and `activeTurnIsWorking` by the test author, so it agrees with whatever the
caller was assumed to pass.
Drive the real component instead. The roster is its own `role: 'system'`
journal row carrying the producer's two blocks (structured + plain-text twin),
so what reaches the DOM depends on `foldToolMessages`, the turn-key mapping
and the disclosure state `NativeChatMessageList` owns — none of which a row
test exercises.
Three cases, on one assembled transcript that holds tool calls AND a roster:
- a settled turn with activity collapsed, the resting state of the whole
transcript, still shows the row (fails with loop 4's reorder reverted);
- tool activity stays behind that disclosure and appears only on expand,
and expanding draws no second roster (fails with the guard removed);
- a working turn reads as a live spawn.
The first also pins that the plain-text twin is dropped rather than printed
beside the row it stands in for.
Timestamps are explicit and ascending: the list re-sorts by (timestamp, id),
so rows sharing a millisecond tie-break alphabetically and the user turn can
sort last, stranding the roster outside its own turn and reconciling live
children to `unverifiable`.
No production code changed.
* fix(native-chat): make "counts as renderable" and "actually draws" agree for a spawn group
`MessageRow` counts any `subagent-group` block as renderable, but
`NativeChatSubagentRun` renders null for a childless roster. A group with
`agents: []` therefore mounted a row that drew nothing — an empty div that still
costs the transcript one `gap-5` slot. The Codex producer never writes one (every
`write()` call site operates on a group that already holds an entry), but the
block schema admits `agents: []` with no `.min(1)`, and the wire is where such a
shape would arrive.
Narrow `subagentGroupBlocks` — whose only production caller IS that renderable
check — to the groups that will draw, behind a named `isRenderableSubagentGroup`
that `NativeChatToolRun` now shares in place of its own copy of the predicate, so
the two guards cannot drift apart again. A childless group carrying its
plain-text twin now prints the twin, which is what the twin is for; a bare one
skips the row entirely.
Also correct four comments that had stopped describing the code:
- the roster header called `agentsStates` "always empty", contradicting the
probe note in `codex-subagent-activity.ts` — it is empty on the MultiAgentV2
path that emits these items, and the V1 path does populate it;
- `tokensByThread` was documented "retained UNCONDITIONALLY" while
`handleTokenUsage` LRU-caps it 65 lines below;
- the sweep is not "the LAST event a group ever gets": neither `settleTurn` nor
`settleSession` removes the group, so a later `thread/tokenUsage/updated`
naming a swept child still writes it. The retry condition is right; only its
stated reason was wrong;
- the `subAgentActivity` classification is not reached "for every event — and
every one of them arrives twice". `handleSubagentItem` intercepts those items
before `items.handle`, so the live path never consults the catalog;
`restoreThread` replays them straight through, and is the real consumer.
Comment-only apart from the childless-group guard.
* fix(cli): stop `worker read` printing the subagent roster sentence twice
The producer ALWAYS writes a roster block beside a plain-text twin carrying the
same sentence, for clients that cannot draw the block. The renderer honours that
contract from one side — it draws the block and drops the twin. The CLI honoured
neither side: it printed the twin as prose AND rendered the block as
`[subagents] <same sentence>`, so a real roster message read
[system] Ran 2 subagents (1 failed)
[subagents] Ran 2 subagents (1 failed)
Take the mirror of the renderer's rule, which is the cleaner half for a text
client: the twin IS the sentence, so print it and drop the block it stands in
for. A block that arrives WITHOUT its twin — a shape the wire admits and no
producer writes — still stands in for itself, because dropping it
unconditionally would lose the roster entirely. Either way the sentence prints
exactly once, off the same `subagentGroupFallbackText` helper both sides use.
Unreachable through `readWorkerTranscript` today, whose provider rollout decoder
never emits a `subagent-group` block — but the formatter is the CLI's contract
for any transcript source, and the shape is already producible.
The test pinned a TWIN-LESS group, a body `codexSubagentGroupBody` never writes:
it asserted the exact double-print this fixes was correct output, and would have
blessed either behaviour. Rebuild the fixture as the producer's real two-block
row, with the sentence taken from the shared helper rather than hardcoded so it
cannot drift, and assert the sentence appears exactly once. The twin-less shape
keeps a test of its own, labelled as the wire-only fallback it is.
Also record why `settleTurn` keys on the RAW `turnId` while `groupFor` remaps
off-primary activity onto the primary's active turn. The asymmetry is
load-bearing, not an oversight: were `settleTurn` to remap, a child thread
ending its own turn would sweep the parent group and settle every still-working
sibling to `unverifiable`. The lookup missing is the intended no-op.
* fix(native-chat): add the subagent roster's localization keys and narrow its twin filters
The roster row called 16 `components.native-chat.subagents.*` keys that were
never added to the catalog, failing the localization gate. Synced en.json; the
English strings are the component's own inline fallbacks, so nothing renders
differently.
Also tightens the twin/block handoff on both readers. The renderer dropped
every text block once a roster was present, which is safe only because Codex
writes a roster as its own message — the block is provider-agnostic, so a lane
folding prose in beside one would have lost it on desktop while mobile kept it.
And both readers decided "the twin is already printing" by recomputing the
sentence and comparing bytes, which a roster from a newer build never matches:
its unknown state normalizes to `unverifiable` here, so the CLI printed the
roster twice with two different verdicts. Both now recognize a twin by shape.
* test(native-chat): pin the roster twin recognizer against prose
Both readers use it to decide the twin is already printing, so a false positive
eats a message's real prose and a false negative prints the roster twice.
* docs(codex): restore the roster's evictionated trigger to its KNOWN LIMITATION
The previous rewrite dropped both triggers the old comment named and kept only
the restart one, but eviction is the reachable half: `groupFor` caps `groups` at
MAX_CODEX_SUBAGENT_GROUPS and drops the oldest-INSERTED entry (it returns an
existing group without re-inserting, so this is not LRU), which can evict a
still-live group in-process. The row identity is keyed on the group id alone, so
the next activity item rebuilds that row from one child — the same N-to-1
rewrite, with no restart, and with the sweep skipped so the children never latch
`unverifiable`. Also softens "every real turn id is freshly minted" to the
provider assumption it is: turn ids are read verbatim off provider frames and
nothing in this repo mints or asserts them.
* docs(codex): justify the subagent wire notes from the live probe alone
The roster and disposition comments explained themselves in terms of a
provider-internal path taxonomy rather than anything this repo can observe.
Restate them from the evidence Orca actually has: the live app-server probe
saw `agentsStates` arrive empty, so nothing reads it; and `collabAgentToolCall`
stays substantive because nothing guarantees a session reports subagent work as
`subAgentActivity` at all — one that only emits the collab tool call gets no
roster row, and suppressing that too would leave its fan-out blank.
Same behaviour, same tests; comments and one test name only.
* fix(native-chat): stop the roster's durable twin from claiming live subagents
The spawn-group row is written once and revised in place, but the row itself
is durable and replayed on every reconnect. Its plain-text twin — the only
thing a client that cannot draw the block ever sees — froze a live count into
that row: `Kicked off 4 subagents — 2 working`. The desktop renderer never
shows it, and reconciles the block's `working` to `unverifiable` outside the
live turn. A text-only reader does neither. When the writing process dies
mid-flight the turn-end sweep never runs, so the sentence keeps asserting two
running children forever, with nothing left that could re-check them. That is
the collapse `docs/reference/ssh-execution-boundary.md` forbids: loss of
contact reported as a live state.
Fix it at the source rather than per client: the durable sentence now states
only what survives its process — that the group was spawned, plus whatever
outcome had latched. `Kicked off` vs `Ran` stays, because it reports whether an
outcome was recorded at write time; saying `Ran` while children were in flight
would assert they exited, the same error inverted. The adverse count stays so a
failing fan-out still reads as failing. Reconciliation stays in the renderer,
where the block still needs it.
The twin recognizer keeps matching the legacy `— N working` shape: journals
already hold those sentences and their rows replay forever, so dropping the
branch would print every one of them twice, once as the block and once as prose
the reader meant to drop.
Also align the two functions that read `agentPath`. The root check compared the
raw string while the label normalized separators, so `/root/` was both the turn
itself and a child of it — a phantom row labelled `root` inflating the group by
one. Compare normalized segments instead, keeping `/morpheus` a child. And a
trailing segment with nothing visible in it survives the empty-segment filter
and would draw a nameless row, so it now reads as no label and falls back to the
placeholder.
* fix(codex): key the subagent label collision ordinal on what the row draws
`codexSubagentLabel` tested the trailing segment trimmed but returned it
untrimmed, and `claimLabel` keys its collision ordinal on that string. Two
children at `/root/read` and `/root/ read ` therefore both drew as `read`
with no ordinal — the one thing the ordinal exists to prevent. Return the
trimmed segment so labels that render identically collide.
Also correct the legacy-clause note on the twin recognizer. It claimed shipped
journals hold the old `— N working` sentence; the feature is unreleased, so the
only journals holding one are dev worktrees of this branch. The branch still
earns its place — those rows replay too, and it adds no false-positive surface
the bare shape does not already carry — but the stated reason was wrong.
* test(native-chat): retire the subagent-visibility guards now the roster renders
Two tests from the sibling item-coverage PR asserted that subagent items stay
on the generic gray row, explicitly gated on "until a real renderer exists".
This branch is that renderer, so both guards fire on merge — the handoff they
were written to mark rather than a regression.
They now pin the other side of it: subAgentActivity is suppressed because the
spawn-group roster renders it, and collabAgentToolCall deliberately stays
visible, since nothing guarantees a session reports subagent work as
subAgentActivity at all.
Git merged both files without conflict; only running the suite surfaced this.
* fix(native-chat): let a subagent swept at turn end still report what it did
The turn-end sweep marks still-running children `unverifiable`, and the
producer latched on any state that was not `working` — so `unverifiable`
latched too. A subagent that outlived its turn then reported `completed`, the
latch refused it, and a child that finished successfully read as one we never
saw finish, permanently.
One predicate was doing two jobs. `isTerminalSubagentState` is right for
counting — `unverifiable` is not working — and wrong for latching, because
`unverifiable` records that we stopped being able to see the child, not what
it did. Split them: a child's own verdict latches, the sweep's guess does not.
The reverse stays refused. Nothing returns to `working` once we have given up
on it, so a straggler progress tick cannot re-light a settled row.
Neither the latch nor the sweep was wrong alone, and both were tested; the
defect lived only in their interaction, and only when a subagent outlives its
turn — which the probe that drove this design never produced, because the
parent it captured waited on its child.
* fix: drop the @pnpm/exe lockfile drift a merge staged
`git add -A` swept up the pnpm-lock.yaml mutation that every pnpm invocation
leaves in this repo. Nineteen lines, thirteen of them @pnpm/exe, and it fails
sixteen unrelated CI checks — native smoke, typecheck, packaging, xterm patch
sync — none of which name the lockfile.
* fix(native-chat): restore the item fall-through an inline dropped
Inlining the subagent routing helper lost its null check: the roster returning
null means it did not claim the item, and the translator must keep looking.
Returning unconditionally once any thread item parsed swallowed every ordinary
item — twelve settlement tests, none of them about subagents.
* fix(orchestration): rebind the subagent block arm to the renamed bound state
Main renamed clipMetadata's second parameter from a warnings set to a
TranscriptBoundState. The subagent-group arm still passed `warnings`, and git
merged both sides without a conflict because the lines never overlapped — the
rename and the new arm are in different hunks. Typecheck was the only thing
that could catch it, and did.
* fix(codex): publish the turn tail for a subagent item the roster claims
Main's #19055 added a `subAgentActivity` arm to the provider activity table,
which is reached only through `publishActivity`. The roster's admission returned
above that call, so every `subAgentActivity` item bypassed it and a fan-out that
reports nothing else left the turn tail stuck on the previous frame's text.
`publishActivity` already no-ops on a refused admission and on a non-primary
thread, so routing the roster's admission through it is safe.
Also corrects a docstring the frames extraction copy-pasted onto
`settleOversizedNotification`.
* fix(native-chat): bound the subagent roster on every boundary that carries it
The spawn-group arm was the one collection in the worker-transcript payload with
no cap, and the one block type mobile's `sanitizeBlock` forwarded verbatim. The
producer's `MAX_CODEX_SUBAGENTS_PER_GROUP` does not reach either boundary: the
journal schema declares no maximum on `agents`, and a remote host may run a build
with a different cap. Both transports now cap the roster and bound `id`, `label`
and the open `state` string; `label` and `id` also take the standard inline bound
on the journal write path, where every other provider string already does.
A token count is now persisted onto its entry at write time. `write` rebuilt
`tokens` from the LRU-capped thread map on every write, so an eviction silently
retracted a count the durable row had already shown.
Adds the first coverage of the three roster caps, including the group eviction
that rewrites a row from N children down to one.
* fix(native-chat): keep the roster drawn beside tool calls and its clock honest
The roster-only escape is keyed on `blocks.length === 0`, so a spawn group
sharing its message with tool-call blocks fell through to the settled-turn guard,
which returned bare null and took the roster with it — the exact regression the
escape above was written to avoid, after the message row had already counted the
group as renderable. Unreachable for Codex today; the block type is deliberately
provider-agnostic, so it is live for the Claude lane.
The elapsed clock also froze at a sibling's timestamp on a partial sweep: in a
group where one child completed and another is unaccounted for, the ended turn
left `working === 0` with the completed child's `settledAt`, and the row showed
that child's duration as the group's run length. No clock is drawn while any
child is `unverifiable` with no terminal timestamp.
* perf(native-chat): bound the roster's provider strings without digesting them
`boundInlineText` computes a sha256 and a Buffer BEFORE it checks the length,
so the roster paid two digests per child on every write even when nothing was
truncated — and `write()` runs on every claimed activity item (each delivered
twice) and again from `handleTokenUsage`, which streams. A same-process A/B over
a 64-child group: 76.5 us/write before, 2.0 us/write after (plain, unbounded row
is 1.2 us).
The cap changes with the mechanism. 16 KB is the tool-output bound; both readers
of this row already clip the same fields to 512, so the producer was admitting
~2 MB per durable roster row for consumers to throw ~97% of away. One
`MAX_SUBAGENT_FIELD_CHARS` now serves the producer and both readers, and the
marker is an ellipsis rather than the tool-output truncation sentence — `id` is
the roster key and the renderer's React key.
Also raises the orchestration arm's per-group bound from 20 to the producer's
64, matching the mobile arm: a 21-64 child group is routinely producible here,
so that arm clipped children and warned while its sibling clipped none. The
slice and warning stay as the transport's own defence against a remote host with
a larger cap.
* fix(orchestration): suppress one roster block per twin, not all of them
`hasTwin` was a single boolean over the whole message, so a message carrying two
`subagent-group` blocks and one plain-text twin printed one sentence and dropped
the second roster with no marker. Count the twins and claim one per group
instead. Not reachable from this branch's producer, which writes one group per
journal item, but the surrounding reasoning is explicitly about wire shapes the
producer never writes and this is the adjacent one it missed.
* fix(native-chat): loop-3 fixes to the Codex subagent worklog
Five defects loop 2's own fixes introduced.
Twin claiming was order-blind: the count-based claim silenced whichever
roster block came first, so a lone twin belonging to a LATER group erased
an earlier group's roster and printed the later sentence twice. Exact-text
claims are now settled for every group before any leftover twin is claimed
by position; the positional fallback stays for a newer build's frozen twin,
which can never equal a recomputed sentence.
`boundSubagentField` sliced UTF-16 units and could leave a lone high
surrogate in a durable row, and the clip removed exactly the tail that told
two children apart — `id` is the renderer's React key and `claimLabel`
writes its repeat ordinal at the end. It now backs off a split pair and
reserves the child index inside the bound, so both readers' re-clip cannot
cut the disambiguator off again.
`MAX_SUBAGENT_FIELD_CHARS`'s doc claimed a `groupId` bound the producer
never applies; the doc now says so and why. The worker-transcript metadata
cap is a separate literal again: it governs message ids, turn ids, tool-call
names and image urls, so a roster-motivated change must not move it.
* fix(native-chat): never infer a lost subagent from a turn boundary
QA drove a real Codex session with three live `spawn_agent` children and sent
a mid-turn correction. The roster row immediately read "Ran 3 subagents /
3 unverifiable" with no clock, while all three were still running — they
reported `completed` 57-87s after that turn ended.
Both sites rested on the same false premise: that a turn ending means no
event will ever settle a child. Children outlive their turn and keep
reporting into the same group.
- Renderer: drop `reconcileSubagentRoster`. Nothing plumbed to the component
distinguishes a row written by a dead host from a turn that merely ended —
journal render items carry no epoch, and a new epoch deletes the rows of the
one it supersedes — so the row now draws the state the journal recorded.
Under-claiming beats over-claiming.
- Main: stop sweeping on `turn/completed`. That sweep wrote `unverifiable`
into the DURABLE journal, which mobile reads with no reconciliation.
`turn/completed` is Codex's only turn-end notification, so an abort cannot
be told apart from a clean finish; the safe default is not to sweep.
`settleSession` — the provider actually being gone — is unchanged and is now
the only sweep. `unverifiable` stays non-latching so a late verdict still lands.
* test(native-chat): pin the roster at the seam the QA defect came from
The mid-turn correction opens a new turn, so the fan-out's row stops being
the current turn and the list hands the roster `activeTurnIsWorking={false}`.
Asserted through the list, not the component, because that prop is what
carried the wrong claim.
* fix(native-chat): settle a roster the dying host never got to sweep
`settleSession` only fires when the provider goes away while this process is
alive. If the host itself dies, nothing sweeps and nothing reconciles on
restore, so a `subagent-group` row persisted as `working` claimed live children
forever — the mirror of the defect the previous commit fixed, and the same
`ssh-execution-boundary.md` violation in the other direction.
Reconciled host-side, at journal open, not in the renderer: mobile shows only
the durable text twin and reconciles nothing, so a renderer-only fix would
leave it claiming live children indefinitely. Opening the journal is also the
one moment a host can honestly say the previous writer is gone.
- `staleSubagentRosterRevisions` rewrites every child still reading `working`
to `unverifiable` and regenerates the twin from the same summary, so the
block and the sentence cannot disagree.
- No terminal timestamp: the child stopped being observable at an unknown
moment, and stamping the reopen would report the downtime as its run length.
- Revises in place under the parsed identity, so a reopen upserts the row
rather than appending a duplicate, and a second reopen writes nothing.
- Skipped on a corrupt load: that journal is still owed a rebuild from provider
history, and content past the repair's free sequence retires the demand.
Reconciles journal ROWS, not roster state — the producer's in-process group map
is untouched, so the roster's known seeding limitation is unchanged, as is
`canReplaceSubagentState`: `unverifiable` still does not latch.
---------
Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
bffdad9f05
commit
0252fe5c36
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { OrchestrationFleetWorker } from '../../../shared/orchestration-fleet-projection'
|
||||
import { subagentGroupFallbackText } from '../../../shared/native-chat-subagent-summary'
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatMessage,
|
||||
NativeChatSubagentEntry
|
||||
} from '../../../shared/native-chat-types'
|
||||
import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output'
|
||||
import { formatWorkerRead, formatWorkerStart } from './worker-output'
|
||||
|
||||
@@ -288,3 +294,207 @@ function workerReadResult(
|
||||
type WorkerReadResultWithoutContext<T> = T extends unknown
|
||||
? Omit<T, 'dispatchId' | 'status'>
|
||||
: never
|
||||
|
||||
function transcriptRead(
|
||||
blocks: NativeChatBlock[],
|
||||
role: NativeChatMessage['role'] = 'assistant'
|
||||
): OrchestrationWorkerReadResult {
|
||||
const message: NativeChatMessage = {
|
||||
id: 'm1',
|
||||
role,
|
||||
blocks,
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
}
|
||||
return {
|
||||
dispatchId: 'd1',
|
||||
source: 'transcript',
|
||||
sourceIdentity: 'pane:1',
|
||||
provider: 'codex',
|
||||
transcript: { messages: [message], nextCursor: '1', limited: false, returnedMessageCount: 1 },
|
||||
cursor: '1',
|
||||
status: { worker: 'running', terminal: 'running' },
|
||||
fallbackReason: null,
|
||||
warnings: []
|
||||
}
|
||||
}
|
||||
|
||||
const ROSTER: readonly NativeChatSubagentEntry[] = [
|
||||
{ id: 'child-1', label: 'read', state: 'working' },
|
||||
{ id: 'child-2', label: 'edit', state: 'failed' }
|
||||
]
|
||||
|
||||
function occurrences(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1
|
||||
}
|
||||
|
||||
describe('formatWorkerRead', () => {
|
||||
// The replay case this row is durable for: SQLite-backed, re-sent on every
|
||||
// reconnect, and read here by a client that draws no roster block, runs no
|
||||
// reconciliation, and cannot re-check whether those children still exist. A
|
||||
// sentence frozen mid-flight outlives the process that wrote it, so it must
|
||||
// not keep asserting a liveness only that process could have observed —
|
||||
// `docs/reference/ssh-execution-boundary.md` calls that loss of contact
|
||||
// reported as a live state.
|
||||
it('replays a mid-flight roster row without claiming a child is still working', () => {
|
||||
const midFlight: readonly NativeChatSubagentEntry[] = [
|
||||
{ id: 'child-1', label: 'read', state: 'working' },
|
||||
{ id: 'child-2', label: 'search', state: 'working' },
|
||||
{ id: 'child-3', label: 'edit', state: 'failed' }
|
||||
]
|
||||
|
||||
const output = formatWorkerRead(
|
||||
transcriptRead([
|
||||
{ type: 'text', text: subagentGroupFallbackText(midFlight) },
|
||||
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...midFlight] }
|
||||
])
|
||||
)
|
||||
|
||||
expect(output).toContain('[assistant] Kicked off 3 subagents (1 failed)')
|
||||
expect(output).not.toMatch(/\bworking\b/)
|
||||
})
|
||||
|
||||
// The body `codexSubagentGroupBody` actually writes: the plain-text twin, then
|
||||
// the block it stands in for. The twin exists for clients that cannot draw the
|
||||
// block, so a client printing the block must not print the twin beside it —
|
||||
// the renderer drops the twin for the same reason, from the other side.
|
||||
it('prints the roster sentence once for the two-block row the producer writes', () => {
|
||||
const sentence = subagentGroupFallbackText(ROSTER)
|
||||
const output = formatWorkerRead(
|
||||
transcriptRead(
|
||||
[
|
||||
{ type: 'text', text: sentence },
|
||||
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] }
|
||||
],
|
||||
'system'
|
||||
)
|
||||
)
|
||||
|
||||
expect(output).toContain(`[system] ${sentence}`)
|
||||
expect(occurrences(output, sentence)).toBe(1)
|
||||
})
|
||||
|
||||
// Suppression is per twin, not per message. One twin beside two roster blocks
|
||||
// silenced BOTH groups and printed one sentence, so the second roster vanished
|
||||
// with no marker — the same silent drop the missing-twin case above avoids.
|
||||
it('stands in for the second roster block when only one twin accompanies two', () => {
|
||||
const other: readonly NativeChatSubagentEntry[] = [
|
||||
{ id: 'child-3', label: 'plan', state: 'completed' }
|
||||
]
|
||||
const output = formatWorkerRead(
|
||||
transcriptRead(
|
||||
[
|
||||
{ type: 'text', text: subagentGroupFallbackText(ROSTER) },
|
||||
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] },
|
||||
{ type: 'subagent-group', groupId: 'thread:turn-2', agents: [...other] }
|
||||
],
|
||||
'system'
|
||||
)
|
||||
)
|
||||
|
||||
expect(occurrences(output, subagentGroupFallbackText(ROSTER))).toBe(1)
|
||||
expect(output).toContain(`[subagents] ${subagentGroupFallbackText(other)}`)
|
||||
})
|
||||
|
||||
// Which group a lone twin belongs to is decided by its TEXT, not its position.
|
||||
// Claiming positionally silenced whichever group came first, so a twin
|
||||
// belonging to a LATER group erased the earlier group's roster and printed the
|
||||
// later one's sentence twice — the same silent drop, one permutation over.
|
||||
it('claims a lone twin for the group it names, not the first group in the message', () => {
|
||||
const other: readonly NativeChatSubagentEntry[] = [
|
||||
{ id: 'child-3', label: 'plan', state: 'completed' }
|
||||
]
|
||||
const second = subagentGroupFallbackText(other)
|
||||
const output = formatWorkerRead(
|
||||
transcriptRead(
|
||||
[
|
||||
{ type: 'text', text: second },
|
||||
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] },
|
||||
{ type: 'subagent-group', groupId: 'thread:turn-2', agents: [...other] }
|
||||
],
|
||||
'system'
|
||||
)
|
||||
)
|
||||
|
||||
expect(occurrences(output, second)).toBe(1)
|
||||
expect(output).toContain(`[subagents] ${subagentGroupFallbackText(ROSTER)}`)
|
||||
})
|
||||
|
||||
// The same claim, with the twin written after both blocks: nothing about the
|
||||
// ORDER of a twin and its group is guaranteed by the block schema.
|
||||
it('claims a trailing twin for the group it names', () => {
|
||||
const other: readonly NativeChatSubagentEntry[] = [
|
||||
{ id: 'child-3', label: 'plan', state: 'completed' }
|
||||
]
|
||||
const second = subagentGroupFallbackText(other)
|
||||
const output = formatWorkerRead(
|
||||
transcriptRead(
|
||||
[
|
||||
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] },
|
||||
{ type: 'subagent-group', groupId: 'thread:turn-2', agents: [...other] },
|
||||
{ type: 'text', text: second }
|
||||
],
|
||||
'system'
|
||||
)
|
||||
)
|
||||
|
||||
expect(occurrences(output, second)).toBe(1)
|
||||
expect(output).toContain(`[subagents] ${subagentGroupFallbackText(ROSTER)}`)
|
||||
})
|
||||
|
||||
// A group with no twin beside it is a shape the block schema admits and no
|
||||
// producer writes. Dropping it would lose the roster entirely, so the block
|
||||
// itself carries the sentence when nothing else does.
|
||||
it('stands in for a roster block that arrived without its twin', () => {
|
||||
const output = formatWorkerRead(
|
||||
transcriptRead([{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] }])
|
||||
)
|
||||
|
||||
expect(output).toContain(`[assistant] [subagents] ${subagentGroupFallbackText(ROSTER)}`)
|
||||
})
|
||||
|
||||
// A roster from a newer build holds a state this build does not know, which
|
||||
// `summarizeSubagentGroup` reads as `unverifiable`. Recomputing the sentence
|
||||
// to compare it against the frozen twin therefore produced a DIFFERENT string,
|
||||
// and the CLI printed the roster twice: the twin's own wording plus a
|
||||
// `[subagents]` line contradicting it.
|
||||
it('prints the roster once when the twin names a state this build cannot reproduce', () => {
|
||||
const frozenTwin = 'Ran 2 subagents (1 cancelled)'
|
||||
const output = formatWorkerRead(
|
||||
transcriptRead(
|
||||
[
|
||||
{ type: 'text', text: frozenTwin },
|
||||
{
|
||||
type: 'subagent-group',
|
||||
groupId: 'thread:turn-1',
|
||||
agents: [
|
||||
{ id: 'child-1', label: 'read', state: 'completed' },
|
||||
{ id: 'child-2', label: 'edit', state: 'cancelled' }
|
||||
] as unknown as NativeChatSubagentEntry[]
|
||||
}
|
||||
],
|
||||
'system'
|
||||
)
|
||||
)
|
||||
|
||||
expect(output).toContain(`[system] ${frozenTwin}`)
|
||||
expect(output).not.toContain('[subagents]')
|
||||
expect(output).not.toContain('unverifiable')
|
||||
})
|
||||
|
||||
// The journal admits block types this build does not know, and `client.call`
|
||||
// casts the RPC result rather than validating it — so a newer remote host's
|
||||
// block reaches this formatter as-is. Reading fields off it threw a TypeError
|
||||
// and took down the whole `worker read`.
|
||||
it('degrades an unknown block type from a newer host instead of throwing', () => {
|
||||
const output = formatWorkerRead(
|
||||
transcriptRead([
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'plan-step', title: 'ship it' } as unknown as NativeChatBlock,
|
||||
{ type: 'text', text: 'after' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(output).toContain('[assistant] before\n[unsupported block]\nafter')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -784,7 +784,7 @@ describe('codex item bodies', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves subagent items on the generic row until a real renderer exists', () => {
|
||||
it('drops the raw subagent item now the roster row renders it', () => {
|
||||
expect(
|
||||
codexJournalItem({
|
||||
type: 'subAgentActivity',
|
||||
@@ -793,10 +793,7 @@ describe('codex item bodies', () => {
|
||||
agentThreadId: 'thread-child',
|
||||
agentPath: '/root/list_directory'
|
||||
})
|
||||
).toMatchObject({
|
||||
handled: false,
|
||||
body: { kind: 'status', providerFrame: { kind: 'item:subAgentActivity' } }
|
||||
})
|
||||
).toMatchObject({ handled: true, body: null })
|
||||
})
|
||||
|
||||
it('drops the sleep item, which codex itself renders as nothing', () => {
|
||||
|
||||
@@ -7,3 +7,10 @@ export const MAX_CODEX_PENDING_PROMPTS = 128
|
||||
export const MAX_CODEX_IDENTITY_ENTRIES = 512
|
||||
export const MAX_CODEX_DETAIL_ENTRIES = 512
|
||||
export const MAX_CODEX_DETAIL_BYTES = 64 * 1024
|
||||
/** Spawn-group rows kept live per session, and children per row. Both bound an
|
||||
* event-accumulated map that no provider snapshot ever prunes. */
|
||||
export const MAX_CODEX_SUBAGENT_GROUPS = 32
|
||||
export const MAX_CODEX_SUBAGENTS_PER_GROUP = 64
|
||||
/** Threads whose latest token total is retained. Usage frames arrive for
|
||||
* threads that are not yet (or never become) roster children. */
|
||||
export const MAX_CODEX_TOKEN_USAGE_THREADS = 256
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The translator's provider-frame arms.
|
||||
*
|
||||
* Each returns null for a frame it does not own, which is the translator's
|
||||
* signal to keep looking. Split out so the translator reads as routing rather
|
||||
* than as the shape checks each arm performs.
|
||||
*/
|
||||
|
||||
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts'
|
||||
import { settleCodexOversizedNotification } from './codex-structured-journal-settlement'
|
||||
import {
|
||||
readCodexJournalRecord,
|
||||
readCodexJournalString
|
||||
} from './codex-structured-journal-translation-values'
|
||||
|
||||
type OversizedInput = Parameters<typeof settleCodexOversizedNotification>[0]
|
||||
|
||||
/** A notification the transport refused to carry whole: settle whatever it
|
||||
* opened rather than leaving the item mid-flight. */
|
||||
export function settleCodexOversizedNotificationFrame(input: {
|
||||
sessionId: string
|
||||
threadId: string
|
||||
kind: string
|
||||
payload: unknown
|
||||
sink: OversizedInput['sink']
|
||||
streams: OversizedInput['streams']
|
||||
activeItems: OversizedInput['activeItems']
|
||||
}): CodexJournalTranslationAdmission | null {
|
||||
if (input.kind !== 'frame:oversized-notification') {
|
||||
return null
|
||||
}
|
||||
const method = readCodexJournalString(readCodexJournalRecord(input.payload), 'method')
|
||||
return method
|
||||
? settleCodexOversizedNotification({
|
||||
sessionId: input.sessionId,
|
||||
threadId: input.threadId,
|
||||
method,
|
||||
sink: input.sink,
|
||||
streams: input.streams,
|
||||
activeItems: input.activeItems
|
||||
})
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import type { AgentSessionTurnActivity } from '../../shared/agent-session-wire'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
|
||||
|
||||
const SESSION_ID = 'session-1'
|
||||
const THREAD_ID = 'thread-abc'
|
||||
const TURN_ID = 'turn-1'
|
||||
|
||||
type Row = { key: string; body: AgentJournalItemBody }
|
||||
|
||||
function harness() {
|
||||
const rows: Row[] = []
|
||||
const activities: (AgentSessionTurnActivity | null)[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity: AgentJournalItemIdentity, body) =>
|
||||
rows.push({ key: agentJournalItemKey(identity), body }),
|
||||
appendTombstone: () => {},
|
||||
publish: () => {},
|
||||
setActivity: (activity) => activities.push(activity)
|
||||
}
|
||||
const translator = createCodexJournalTranslator({
|
||||
sink,
|
||||
primaryThreadId: () => THREAD_ID,
|
||||
schedule: (run: () => void) => {
|
||||
run()
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
return { translator, rows, activities }
|
||||
}
|
||||
|
||||
function notification(method: string, params: unknown): CodexStructuredSessionEvent {
|
||||
return { type: 'notification', sessionId: SESSION_ID, threadId: THREAD_ID, method, params }
|
||||
}
|
||||
|
||||
function subagentItem(kind: string, agentThreadId: string, agentPath: string): unknown {
|
||||
return {
|
||||
turnId: TURN_ID,
|
||||
item: {
|
||||
type: 'subAgentActivity',
|
||||
id: `item-${agentThreadId}-${kind}`,
|
||||
kind,
|
||||
agentThreadId,
|
||||
agentPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every activity item reaches the wire twice. */
|
||||
function deliverActivity(
|
||||
translator: ReturnType<typeof createCodexJournalTranslator>,
|
||||
params: unknown
|
||||
): void {
|
||||
translator.handle(notification('item/started', params))
|
||||
translator.handle(notification('item/completed', params))
|
||||
}
|
||||
|
||||
function rosterAgents(rows: Row[]): { id: string; state: string; tokens?: number }[] {
|
||||
const body = rows.findLast((row) => row.key.startsWith('orca:codex-subagents'))?.body
|
||||
if (!body || body.kind !== 'message') {
|
||||
return []
|
||||
}
|
||||
return body.blocks.find(isSubagentGroupBlock)?.agents ?? []
|
||||
}
|
||||
|
||||
describe('codex journal translation — subagents', () => {
|
||||
it('renders a spawn group as one roster row and no opcode-shaped duplicate', () => {
|
||||
const { translator, rows } = harness()
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
|
||||
deliverActivity(translator, subagentItem('started', 'child-1', '/root/list_directory'))
|
||||
deliverActivity(translator, subagentItem('interacted', 'child-1', '/root/list_directory'))
|
||||
|
||||
expect(rosterAgents(rows)).toMatchObject([
|
||||
{ id: 'child-1', label: 'list_directory', state: 'working' }
|
||||
])
|
||||
// Four wire deliveries (two items, each sent twice) collapse to ONE roster
|
||||
// row, and none of the gray `codex · item:subAgentActivity` rows survive.
|
||||
const providerFrameKinds = rows.flatMap((row) =>
|
||||
row.body.kind === 'status' && row.body.providerFrame ? [row.body.providerFrame.kind] : []
|
||||
)
|
||||
expect(providerFrameKinds).toEqual([])
|
||||
expect(rows.filter((row) => row.key.startsWith('orca:codex-subagents'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The roster claims the item, but claiming it must not take the turn tail with
|
||||
// it: the activity table is reached only through the publish arm, so a bare
|
||||
// return leaves the tail stuck on whatever the previous frame said.
|
||||
it('still publishes the turn tail for an item the roster claims', () => {
|
||||
const { translator, activities } = harness()
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
|
||||
activities.length = 0
|
||||
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read'))
|
||||
|
||||
expect(activities.at(-1)).toEqual({
|
||||
turnId: TURN_ID,
|
||||
text: 'Coordinating with another agent'
|
||||
})
|
||||
})
|
||||
|
||||
it('consumes thread/tokenUsage/updated instead of swallowing it as chrome', () => {
|
||||
const { translator, rows } = harness()
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
|
||||
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read'))
|
||||
translator.handle(
|
||||
notification('thread/tokenUsage/updated', {
|
||||
threadId: 'child-1',
|
||||
tokenUsage: { total: { totalTokens: 40661 } }
|
||||
})
|
||||
)
|
||||
|
||||
expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', tokens: 40661 }])
|
||||
})
|
||||
|
||||
// The QA scenario this row got wrong: three `spawn_agent` children were still
|
||||
// running when a mid-turn correction ended their turn and opened a new one.
|
||||
// They reported `completed` 57-87s later, so a turn boundary is a fact about
|
||||
// the turn and never evidence that contact with a child was lost.
|
||||
it('leaves children working when their turn ends and a newer turn opens', () => {
|
||||
const { translator, rows } = harness()
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
|
||||
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read_readme'))
|
||||
deliverActivity(translator, subagentItem('started', 'child-2', '/root/read_package'))
|
||||
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
|
||||
translator.handle(notification('turn/started', { turn: { id: 'turn-2' } }))
|
||||
|
||||
expect(rosterAgents(rows)).toMatchObject([
|
||||
{ id: 'child-1', state: 'working' },
|
||||
{ id: 'child-2', state: 'working' }
|
||||
])
|
||||
|
||||
// And the verdict a child reports after its turn ended still lands on the row.
|
||||
deliverActivity(translator, subagentItem('completed', 'child-1', '/root/read_readme'))
|
||||
|
||||
expect(rosterAgents(rows)).toMatchObject([
|
||||
{ id: 'child-1', state: 'completed' },
|
||||
{ id: 'child-2', state: 'working' }
|
||||
])
|
||||
})
|
||||
|
||||
it('sweeps every group when the provider ends', () => {
|
||||
const { translator, rows } = harness()
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
|
||||
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read'))
|
||||
translator.handle({
|
||||
type: 'ended',
|
||||
sessionId: SESSION_ID,
|
||||
reason: 'provider exited',
|
||||
cause: 'unexpected-exit',
|
||||
fence: 1,
|
||||
acquisitionGeneration: 'gen-1'
|
||||
} as CodexStructuredSessionEvent)
|
||||
|
||||
expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', state: 'unverifiable' }])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity'
|
||||
import {
|
||||
CODEX_TOKEN_USAGE_METHOD,
|
||||
readCodexNotificationThreadItem
|
||||
} from './codex-subagent-activity'
|
||||
import { CodexSubagentRoster } from './codex-subagent-roster'
|
||||
import { readCodexThreadItem } from './codex-structured-item-translation'
|
||||
import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames'
|
||||
import { CodexJournalItems } from './codex-structured-journal-items'
|
||||
import { CodexJournalPrompts } from './codex-structured-journal-prompts'
|
||||
@@ -10,16 +16,12 @@ import {
|
||||
} from './codex-structured-journal-contracts'
|
||||
import {
|
||||
settleCodexJournalSession,
|
||||
settleCodexJournalTurn,
|
||||
settleCodexOversizedNotification
|
||||
settleCodexJournalTurn
|
||||
} from './codex-structured-journal-settlement'
|
||||
import { settleCodexOversizedNotificationFrame } from './codex-structured-journal-translation-frames'
|
||||
import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore'
|
||||
import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state'
|
||||
import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns'
|
||||
import {
|
||||
readCodexJournalRecord,
|
||||
readCodexJournalString
|
||||
} from './codex-structured-journal-translation-values'
|
||||
import { readCodexTurnId } from './codex-structured-thread-facts'
|
||||
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
|
||||
|
||||
@@ -55,6 +57,11 @@ export function createCodexJournalTranslator(
|
||||
const prompts = new CodexJournalPrompts(deps, (threadId, itemId) =>
|
||||
items.detailFor(threadId, itemId)
|
||||
)
|
||||
const subagents = new CodexSubagentRoster({
|
||||
sink: deps.sink,
|
||||
primaryThreadId: () => deps.primaryThreadId?.() ?? null,
|
||||
activeTurn: (threadId) => activeTurns.current(threadId)
|
||||
})
|
||||
const flushStreams = (): CodexJournalTranslationAdmission =>
|
||||
items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' }
|
||||
let readActivity = createCodexProviderActivityReader()
|
||||
@@ -118,6 +125,11 @@ export function createCodexJournalTranslator(
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
}
|
||||
// No event will ever settle a child once the provider is gone.
|
||||
const sweep = subagents.settleSession()
|
||||
if (!sweep.accepted) {
|
||||
return sweep
|
||||
}
|
||||
readActivity = createCodexProviderActivityReader()
|
||||
deps.sink.setActivity?.(null)
|
||||
items.activeItems.clear()
|
||||
@@ -159,7 +171,30 @@ export function createCodexJournalTranslator(
|
||||
if (event.method === 'turn/completed') {
|
||||
return completeTurn(event)
|
||||
}
|
||||
if (event.method === CODEX_TOKEN_USAGE_METHOD) {
|
||||
// Classified `status-chrome`, so the generic-frame path swallows it
|
||||
// before the journal. The roster consumes it as a typed notification.
|
||||
const admission = subagents.handleTokenUsage(event.params)
|
||||
if (admission) {
|
||||
return admission
|
||||
}
|
||||
}
|
||||
if (event.method === 'item/started' || event.method === 'item/completed') {
|
||||
const subagentItem = readCodexNotificationThreadItem(event.params, readCodexThreadItem)
|
||||
// Null means the roster did not claim it; fall through to normal item
|
||||
// handling. Returning here unconditionally swallows every other item.
|
||||
const subagentAdmission = subagentItem
|
||||
? subagents.handleItem({
|
||||
threadId: event.threadId,
|
||||
turnId: readCodexTurnId(event.params) ?? activeTurns.current(event.threadId),
|
||||
item: subagentItem
|
||||
})
|
||||
: null
|
||||
if (subagentAdmission) {
|
||||
// Not a bare return: the roster claiming the item must not skip the
|
||||
// turn-tail arm, which is the only publisher of its activity copy.
|
||||
return publishActivity(event, subagentAdmission)
|
||||
}
|
||||
const translated = items.handle(event)
|
||||
return publishActivity(
|
||||
event,
|
||||
@@ -186,30 +221,25 @@ export function createCodexJournalTranslator(
|
||||
items.dispose()
|
||||
prompts.dispose()
|
||||
genericFrames.dispose()
|
||||
subagents.dispose()
|
||||
activeTurns.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** Settles the item a notification the transport refused to carry left
|
||||
* mid-flight; null when the frame is not one. */
|
||||
function settleOversizedNotification(event: {
|
||||
sessionId: string
|
||||
threadId: string
|
||||
kind: string
|
||||
payload: unknown
|
||||
}): CodexJournalTranslationAdmission | null {
|
||||
if (event.kind !== 'frame:oversized-notification') {
|
||||
return null
|
||||
}
|
||||
const method = readCodexJournalString(readCodexJournalRecord(event.payload), 'method')
|
||||
return method
|
||||
? settleCodexOversizedNotification({
|
||||
sessionId: event.sessionId,
|
||||
threadId: event.threadId,
|
||||
method,
|
||||
sink: deps.sink,
|
||||
streams: items.streams,
|
||||
activeItems: items.activeItems
|
||||
})
|
||||
: null
|
||||
return settleCodexOversizedNotificationFrame({
|
||||
...event,
|
||||
sink: deps.sink,
|
||||
streams: items.streams,
|
||||
activeItems: items.activeItems
|
||||
})
|
||||
}
|
||||
|
||||
function startTurn(event: {
|
||||
@@ -255,6 +285,12 @@ export function createCodexJournalTranslator(
|
||||
if (!turnId) {
|
||||
return CODEX_JOURNAL_ADMITTED
|
||||
}
|
||||
// The roster is deliberately NOT swept here. `spawn_agent` children outlive
|
||||
// the turn that spawned them and go on reporting into the same group, so a
|
||||
// turn boundary is no evidence contact was lost — and `turn/completed` is
|
||||
// the only turn-end notification Codex sends, so an abort cannot be told
|
||||
// apart from a clean finish either. Only `settleSession` may write
|
||||
// `unverifiable`.
|
||||
const admission = settleCodexJournalTurn({
|
||||
sink: deps.sink,
|
||||
sessionId: event.sessionId,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Reading Codex's subagent wire shapes.
|
||||
//
|
||||
// Established by a live probe against `codex app-server` 0.152.1, not inferred:
|
||||
// * `subAgentActivity` items carry `{kind, agentThreadId, agentPath}`, and each
|
||||
// one arrives TWICE — via `item/started` and again via `item/completed`.
|
||||
// * `agentPath` is a tree path (`/root`, `/root/list_directory`); the trailing
|
||||
// segment is a semantic task name and the only label available. There is no
|
||||
// `thread/started` for a child, so nickname/role/depth do not exist.
|
||||
// * `agentsStates` on `collabAgentToolCall` arrived empty (`{}`) throughout the
|
||||
// probe, so nothing here reads it — state comes from `kind` alone.
|
||||
// * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the
|
||||
// latest frame replaces the previous one — it is never accumulated.
|
||||
|
||||
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
|
||||
import type { CodexThreadItem } from './codex-structured-item-translation'
|
||||
|
||||
export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity'
|
||||
export const CODEX_TOKEN_USAGE_METHOD = 'thread/tokenUsage/updated'
|
||||
|
||||
export type CodexSubagentActivity = {
|
||||
kind: string
|
||||
agentThreadId: string
|
||||
agentPath: string | null
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentActivity | null {
|
||||
if (item.type !== CODEX_SUBAGENT_ITEM_TYPE) {
|
||||
return null
|
||||
}
|
||||
const agentThreadId = nonEmptyString(item.agentThreadId)
|
||||
if (!agentThreadId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
kind: nonEmptyString(item.kind) ?? '',
|
||||
agentThreadId,
|
||||
agentPath: nonEmptyString(item.agentPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a `kind` implies for the child it names.
|
||||
*
|
||||
* An unrecognized kind means "this child exists and reported something we
|
||||
* cannot classify" — `working`, which the session sweep will later settle to
|
||||
* `unverifiable` if nothing better ever arrives. Claiming a terminal state from
|
||||
* an unknown kind would assert an outcome the wire never gave us.
|
||||
*/
|
||||
export function codexSubagentStateForKind(kind: string): NativeChatSubagentState {
|
||||
if (kind === 'completed') {
|
||||
return 'completed'
|
||||
}
|
||||
if (kind === 'interrupted') {
|
||||
return 'stopped'
|
||||
}
|
||||
return 'working'
|
||||
}
|
||||
|
||||
/** Path segments, empty ones dropped: `/root/list_directory` → 2 segments. */
|
||||
export function codexSubagentPathSegments(agentPath: string | null): string[] {
|
||||
return agentPath === null ? [] : agentPath.split('/').filter((part) => part.length > 0)
|
||||
}
|
||||
|
||||
/** The one path segment that names the parent turn itself rather than a child.
|
||||
* Compared after the same normalization the label uses, not against the raw
|
||||
* string: `/root/` and `/root//` are the same node as `/root`, and a check that
|
||||
* disagreed with `codexSubagentPathSegments` would let one path be both the
|
||||
* turn and a child of it — a phantom row labelled `root` inflating the group.
|
||||
* Only this segment is the root; `/morpheus` is single-segment too but IS a
|
||||
* child. */
|
||||
const CODEX_ROOT_AGENT_SEGMENT = 'root'
|
||||
|
||||
/**
|
||||
* Whether an activity item describes the ROOT of the agent tree rather than a
|
||||
* spawned child. Counting the root would make the parent turn report itself as
|
||||
* its own subagent.
|
||||
*
|
||||
* A path-less item cannot be placed in the tree at all, so it is treated as a
|
||||
* child: dropping it would lose a real spawn, while an extra row is visible and
|
||||
* self-correcting.
|
||||
*/
|
||||
export function isCodexRootAgentActivity(activity: CodexSubagentActivity): boolean {
|
||||
const segments = codexSubagentPathSegments(activity.agentPath)
|
||||
return segments.length === 1 && segments[0] === CODEX_ROOT_AGENT_SEGMENT
|
||||
}
|
||||
|
||||
/** Row label: the agent path's trailing segment, trimmed. A segment with nothing
|
||||
* visible in it survives the empty-segment filter but would draw a nameless row,
|
||||
* so it reads as no label and the caller's placeholder takes over. Trimmed
|
||||
* because the caller keys its collision ordinals on this string: ` read ` and
|
||||
* `read` render identically and must therefore collide. */
|
||||
export function codexSubagentLabel(activity: CodexSubagentActivity): string | null {
|
||||
const trailing = codexSubagentPathSegments(activity.agentPath).at(-1)?.trim()
|
||||
return trailing !== undefined && trailing.length > 0 ? trailing : null
|
||||
}
|
||||
|
||||
export type CodexThreadTokenTotal = { threadId: string; totalTokens: number }
|
||||
|
||||
/** `{threadId, tokenUsage: {total: {totalTokens}}}`. Older builds put the total
|
||||
* on the envelope, so both shapes are accepted. */
|
||||
export function readCodexThreadTokenTotal(params: unknown): CodexThreadTokenTotal | null {
|
||||
const root = record(params)
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
const threadId = nonEmptyString(root.threadId) ?? nonEmptyString(record(root.thread)?.id)
|
||||
if (!threadId) {
|
||||
return null
|
||||
}
|
||||
const usage = record(root.tokenUsage)
|
||||
const total = record(usage?.total)?.totalTokens ?? usage?.totalTokens ?? root.totalTokens
|
||||
return typeof total === 'number' && Number.isFinite(total) && total >= 0
|
||||
? { threadId, totalTokens: total }
|
||||
: null
|
||||
}
|
||||
|
||||
/** Pull the `subAgentActivity` item out of a raw notification payload.
|
||||
*
|
||||
* Lives beside the readers rather than in the translator: the translator's job
|
||||
* is routing, and this is the shape check that decides whether a frame is one
|
||||
* of ours at all. Returns null for anything that is not a thread item, which is
|
||||
* the translator's signal to keep looking. */
|
||||
export function readCodexNotificationThreadItem(
|
||||
params: unknown,
|
||||
read: (value: unknown) => CodexThreadItem | null
|
||||
): CodexThreadItem | null {
|
||||
const record =
|
||||
typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {}
|
||||
return read(record.item)
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
|
||||
import { isSubagentGroupBlock, type NativeChatSubagentEntry } from '../../shared/native-chat-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import {
|
||||
CodexSubagentRoster,
|
||||
codexSubagentGroupIdentity,
|
||||
codexSubagentGroupId
|
||||
} from './codex-subagent-roster'
|
||||
import type { CodexThreadItem } from './codex-structured-item-translation'
|
||||
import {
|
||||
MAX_CODEX_SUBAGENT_GROUPS,
|
||||
MAX_CODEX_SUBAGENTS_PER_GROUP,
|
||||
MAX_CODEX_TOKEN_USAGE_THREADS
|
||||
} from './codex-structured-journal-limits'
|
||||
|
||||
const THREAD = 'thread-parent'
|
||||
const TURN = 'turn-1'
|
||||
|
||||
type Appended = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }
|
||||
|
||||
function createHarness(options: { threadId?: string | null } = {}): {
|
||||
roster: CodexSubagentRoster
|
||||
appended: Appended[]
|
||||
agents: () => NativeChatSubagentEntry[]
|
||||
latest: () => Appended | undefined
|
||||
} {
|
||||
const appended: Appended[] = []
|
||||
let clock = 1_000
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {},
|
||||
tryAppendItem: (identity, body) => {
|
||||
appended.push({ identity, body })
|
||||
return { accepted: true }
|
||||
},
|
||||
tryPublish: () => ({ accepted: true })
|
||||
}
|
||||
const roster = new CodexSubagentRoster({
|
||||
sink,
|
||||
primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId),
|
||||
activeTurn: () => TURN,
|
||||
now: () => (clock += 1)
|
||||
})
|
||||
const agents = (): NativeChatSubagentEntry[] => {
|
||||
const body = appended.at(-1)?.body
|
||||
if (!body || body.kind !== 'message') {
|
||||
return []
|
||||
}
|
||||
const block = body.blocks.find(isSubagentGroupBlock)
|
||||
return block ? block.agents : []
|
||||
}
|
||||
return { roster, appended, agents, latest: () => appended.at(-1) }
|
||||
}
|
||||
|
||||
function latestIdentity(appended: Appended[]): AgentJournalItemIdentity | undefined {
|
||||
return appended.at(-1)?.identity
|
||||
}
|
||||
|
||||
function activity(input: {
|
||||
id?: string
|
||||
kind: string
|
||||
agentThreadId: string
|
||||
agentPath: string | null
|
||||
}): CodexThreadItem {
|
||||
return {
|
||||
type: 'subAgentActivity',
|
||||
id: input.id ?? `item-${input.agentThreadId}-${input.kind}`,
|
||||
kind: input.kind,
|
||||
agentThreadId: input.agentThreadId,
|
||||
agentPath: input.agentPath
|
||||
}
|
||||
}
|
||||
|
||||
function deliver(
|
||||
roster: CodexSubagentRoster,
|
||||
item: CodexThreadItem,
|
||||
turnId: string | null = TURN
|
||||
): void {
|
||||
// Every activity item reaches the wire twice: item/started, then item/completed.
|
||||
roster.handleItem({ threadId: THREAD, turnId, item })
|
||||
roster.handleItem({ threadId: THREAD, turnId, item })
|
||||
}
|
||||
|
||||
/**
|
||||
* A sink that coalesces the way the real queue does: by `coalescingKey` ALONE,
|
||||
* with no op-kind check, and only draining when released. A fake that ignores
|
||||
* the key cannot see an append being spliced out by its own publish.
|
||||
*/
|
||||
function createCoalescingHarness(): {
|
||||
roster: CodexSubagentRoster
|
||||
appended: Appended[]
|
||||
drain: () => void
|
||||
} {
|
||||
const appended: Appended[] = []
|
||||
const queue: { key?: string; run: () => void }[] = []
|
||||
let clock = 1_000
|
||||
const submit = (key: string | undefined, run: () => void): void => {
|
||||
const at = key === undefined ? -1 : queue.findIndex((queued) => queued.key === key)
|
||||
if (at >= 0) {
|
||||
queue.splice(at, 1)
|
||||
}
|
||||
queue.push(key === undefined ? { run } : { key, run })
|
||||
}
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {},
|
||||
tryAppendItem: (identity, body, options) => {
|
||||
submit(options?.coalescingKey, () => appended.push({ identity, body }))
|
||||
return { accepted: true }
|
||||
},
|
||||
tryPublish: (options) => {
|
||||
submit(options?.coalescingKey ?? 'publish', () => {})
|
||||
return { accepted: true }
|
||||
}
|
||||
}
|
||||
const roster = new CodexSubagentRoster({
|
||||
sink,
|
||||
primaryThreadId: () => THREAD,
|
||||
activeTurn: () => TURN,
|
||||
now: () => (clock += 1)
|
||||
})
|
||||
return {
|
||||
roster,
|
||||
appended,
|
||||
drain: () => {
|
||||
while (queue.length > 0) {
|
||||
queue.shift()?.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodexSubagentRoster', () => {
|
||||
it('does not let its own publish evict the still-queued roster append', () => {
|
||||
const { roster, appended, drain } = createCoalescingHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
drain()
|
||||
|
||||
// Sharing the append's coalescing key with the publish spliced the append
|
||||
// out of the queue, and `lastSerialized` then suppressed every retry.
|
||||
expect(appended).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('counts a /morpheus agent as a child — only /root is the turn itself', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(roster, activity({ kind: 'started', agentThreadId: 'child-m', agentPath: '/morpheus' }))
|
||||
|
||||
expect(agents()).toMatchObject([{ id: 'child-m', label: 'morpheus', state: 'working' }])
|
||||
})
|
||||
|
||||
// `codexSubagentPathSegments` already defines what a path means for the label,
|
||||
// and the root check has to agree with it: a path that normalizes to the same
|
||||
// node must classify the same way, or one string is both the turn itself and a
|
||||
// child of it — a phantom row labelled `root` inflating the group by one.
|
||||
it('reads a root path with a trailing or doubled separator as the turn itself', () => {
|
||||
for (const agentPath of ['/root/', '/root//', '//root']) {
|
||||
const { roster, appended } = createHarness()
|
||||
|
||||
deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath }))
|
||||
|
||||
expect(appended).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a doubled separator inside a child path off the label', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root//read/' })
|
||||
)
|
||||
|
||||
expect(agents()).toMatchObject([{ id: 'child-1', label: 'read' }])
|
||||
})
|
||||
|
||||
// An all-whitespace trailing segment survives the empty-segment filter and
|
||||
// would draw a row with no visible name at all.
|
||||
it('falls back to the placeholder when the trailing segment has nothing to show', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(roster, activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/ ' }))
|
||||
|
||||
expect(agents()).toMatchObject([{ id: 'child-1', label: 'subagent' }])
|
||||
})
|
||||
|
||||
// The collision ordinal keys on the label, so two segments that render
|
||||
// identically must collide rather than both draw as `read`.
|
||||
it('collides labels that differ only in surrounding whitespace', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/ read ' })
|
||||
)
|
||||
|
||||
expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2'])
|
||||
})
|
||||
|
||||
it('ignores the root node so a turn is not its own subagent', () => {
|
||||
const { roster, appended } = createHarness()
|
||||
|
||||
deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath: '/root' }))
|
||||
|
||||
expect(appended).toEqual([])
|
||||
})
|
||||
|
||||
it('writes an admissible journal body carrying a plain-text fallback block', () => {
|
||||
const { roster, latest } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/list_directory' })
|
||||
)
|
||||
|
||||
const body = latest()?.body
|
||||
expect(body?.kind).toBe('message')
|
||||
expect(isAdmissibleAgentJournalItemBody(body)).toBe(true)
|
||||
expect(body?.kind === 'message' ? body.blocks.map((block) => block.type) : []).toEqual([
|
||||
'text',
|
||||
'subagent-group'
|
||||
])
|
||||
expect(
|
||||
body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : ''
|
||||
).toBe('Kicked off 1 subagent')
|
||||
})
|
||||
|
||||
it('keys the durable identity by the parent turn so a revision lands on one row', () => {
|
||||
const { roster, appended } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
|
||||
const expected = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, TURN))
|
||||
expect(new Set(appended.map((entry) => JSON.stringify(entry.identity)))).toEqual(
|
||||
new Set([JSON.stringify(expected)])
|
||||
)
|
||||
})
|
||||
|
||||
it('rule 1 — a duplicate delivery writes no second revision', () => {
|
||||
const { roster, appended } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
|
||||
expect(appended).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rule 2 — a first event of any kind creates the entry in the state it implies', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'completed', agentThreadId: 'child-late', agentPath: '/root/search' })
|
||||
)
|
||||
|
||||
expect(agents()).toMatchObject([{ id: 'child-late', label: 'search', state: 'completed' }])
|
||||
})
|
||||
|
||||
it('rule 3 — a terminal state latches against a late or duplicate start', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
|
||||
expect(agents()).toMatchObject([{ state: 'completed' }])
|
||||
})
|
||||
|
||||
it('rule 4 — the session sweep settles a lost child as unverifiable, not exited', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'completed', agentThreadId: 'child-2', agentPath: '/root/search' })
|
||||
)
|
||||
roster.settleSession()
|
||||
|
||||
expect(agents()).toMatchObject([
|
||||
{ id: 'child-1', state: 'unverifiable' },
|
||||
{ id: 'child-2', state: 'completed' }
|
||||
])
|
||||
})
|
||||
|
||||
it('lets a swept child still report what it actually did', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
roster.settleSession()
|
||||
expect(agents()[0]?.state).toBe('unverifiable')
|
||||
|
||||
// Contact can return — a reconnected provider replays the child's own
|
||||
// verdict. Latching the sweep would report a child that finished as one we
|
||||
// never saw finish.
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
expect(agents()[0]?.state).toBe('completed')
|
||||
})
|
||||
|
||||
it('refuses to put a swept child back to working', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
roster.settleSession()
|
||||
// A straggler progress tick after we gave up must not re-light the row.
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
expect(agents()[0]?.state).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('keeps a real verdict when a later frame disagrees', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'interrupted', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
expect(agents()[0]?.state).toBe('completed')
|
||||
})
|
||||
|
||||
it('rule 4 — the session sweep settles every group and never un-terminals one', () => {
|
||||
const { roster, agents, appended } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
roster.settleSession()
|
||||
const afterFirstSweep = appended.length
|
||||
roster.settleSession()
|
||||
|
||||
expect(agents()).toMatchObject([{ state: 'unverifiable' }])
|
||||
expect(appended).toHaveLength(afterFirstSweep)
|
||||
})
|
||||
|
||||
it('rule 5 — the whole roster is persisted in the carrier, not just a count', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 40661 } } })
|
||||
|
||||
expect(agents()).toMatchObject([
|
||||
{ id: 'child-1', label: 'read', state: 'working', tokens: 40661 }
|
||||
])
|
||||
})
|
||||
|
||||
it('rule 6 — the group id names the parent turn, or says there was none', () => {
|
||||
const { roster, appended } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/search' }),
|
||||
null
|
||||
)
|
||||
|
||||
expect(appended.map((entry) => entry.identity)).toEqual([
|
||||
{ provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:${TURN}` },
|
||||
{ provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:outside-turn` }
|
||||
])
|
||||
})
|
||||
|
||||
it('disambiguates two children that share a trailing path segment', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/read' })
|
||||
)
|
||||
|
||||
expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2'])
|
||||
})
|
||||
|
||||
it('takes the latest token snapshot per child and never accumulates updates', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 100 } } })
|
||||
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 250 } } })
|
||||
|
||||
expect(agents()).toMatchObject([{ tokens: 250 }])
|
||||
})
|
||||
|
||||
it('retains a usage frame that arrives before the child is known', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
|
||||
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } })
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
|
||||
expect(agents()).toMatchObject([{ tokens: 900 }])
|
||||
})
|
||||
|
||||
it('never attributes the parent thread its own usage', () => {
|
||||
const { roster, agents, appended } = createHarness()
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
const beforeParentUsage = appended.length
|
||||
roster.handleTokenUsage({ threadId: THREAD, tokenUsage: { total: { totalTokens: 26099 } } })
|
||||
|
||||
expect(appended).toHaveLength(beforeParentUsage)
|
||||
expect(agents()).toHaveLength(1)
|
||||
expect(agents()[0]).not.toHaveProperty('tokens')
|
||||
})
|
||||
|
||||
// The row is durable and both readers clip these fields to the same cap, so
|
||||
// writing more than that is bytes replayed on every reconnect and then thrown
|
||||
// away. The marker is an ellipsis, not the tool-output truncation sentence:
|
||||
// `id` is the roster key and the renderer's React key.
|
||||
it('bounds the provider strings the roster row carries into the journal', () => {
|
||||
const { roster, agents, latest } = createHarness()
|
||||
const oversized = 'a'.repeat(20 * 1024)
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: oversized, agentPath: `/root/${oversized}` })
|
||||
)
|
||||
|
||||
const entry = agents()[0]
|
||||
expect(entry?.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
|
||||
expect(entry?.label).toMatch(/…~0$/)
|
||||
expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
|
||||
expect(entry?.id).toMatch(/…~0$/)
|
||||
expect(JSON.stringify(latest()?.body)).not.toContain('output truncated')
|
||||
expect(isAdmissibleAgentJournalItemBody(latest()?.body)).toBe(true)
|
||||
})
|
||||
|
||||
// The clip cuts UTF-16 code units, so a boundary landing inside a surrogate
|
||||
// pair left a LONE high surrogate in a durable row — malformed, and replaced
|
||||
// with U+FFFD through any non-JSON UTF-8 hop.
|
||||
it('never clips a provider string mid surrogate pair', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
const astral = '😀'.repeat(400)
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: astral, agentPath: `/root/${astral}` })
|
||||
)
|
||||
|
||||
const entry = agents()[0]
|
||||
expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
|
||||
expect(Buffer.from(entry?.id ?? '', 'utf8').toString('utf8')).toBe(entry?.id)
|
||||
expect(Buffer.from(entry?.label ?? '', 'utf8').toString('utf8')).toBe(entry?.label)
|
||||
})
|
||||
|
||||
// The clip removes exactly the tail that told two children apart: `id` is the
|
||||
// renderer's React key, and `claimLabel` writes its repeat ordinal at the end.
|
||||
// Two clipped children collapsing to one key drew two rows under one identity.
|
||||
it('keeps clipped ids and labels distinct between children', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
const prefix = 'p'.repeat(MAX_SUBAGENT_FIELD_CHARS)
|
||||
const sharedPath = `/root/${'q'.repeat(640)}`
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: `${prefix}AAAA`, agentPath: sharedPath })
|
||||
)
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: `${prefix}BBBB`, agentPath: sharedPath })
|
||||
)
|
||||
|
||||
const entries = agents()
|
||||
expect(entries).toHaveLength(2)
|
||||
expect(new Set(entries.map((agent) => agent.id)).size).toBe(2)
|
||||
expect(new Set(entries.map((agent) => agent.label)).size).toBe(2)
|
||||
for (const agent of entries) {
|
||||
expect(agent.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
|
||||
expect(agent.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
|
||||
}
|
||||
})
|
||||
|
||||
it('caps the children one spawn group admits', () => {
|
||||
const { roster, agents, appended } = createHarness()
|
||||
for (let index = 0; index < MAX_CODEX_SUBAGENTS_PER_GROUP; index++) {
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' })
|
||||
)
|
||||
}
|
||||
const atCap = appended.length
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-over-cap', agentPath: '/root/read' })
|
||||
)
|
||||
|
||||
expect(agents()).toHaveLength(MAX_CODEX_SUBAGENTS_PER_GROUP)
|
||||
expect(agents().map((agent) => agent.id)).not.toContain('child-over-cap')
|
||||
// Refusing the child must not burn a revision either.
|
||||
expect(appended).toHaveLength(atCap)
|
||||
})
|
||||
|
||||
// The eviction is the KNOWN LIMITATION the module documents: `groups` is never
|
||||
// seeded from the journal, so the evicted group's next child rebuilds its
|
||||
// durable row from that one child. Pinned so the boundary cannot move silently.
|
||||
it('caps live spawn groups, and an evicted group rebuilds its row from one child', () => {
|
||||
const { roster, appended, agents } = createHarness()
|
||||
for (let index = 0; index <= MAX_CODEX_SUBAGENT_GROUPS; index++) {
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }),
|
||||
`turn-${index}`
|
||||
)
|
||||
}
|
||||
const evicted = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0'))
|
||||
const rowsFor = (identity: AgentJournalItemIdentity): Appended[] =>
|
||||
appended.filter((entry) => JSON.stringify(entry.identity) === JSON.stringify(identity))
|
||||
expect(rowsFor(evicted)).toHaveLength(1)
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-late', agentPath: '/root/search' }),
|
||||
'turn-0'
|
||||
)
|
||||
|
||||
expect(latestIdentity(appended)).toEqual(evicted)
|
||||
expect(agents().map((agent) => agent.id)).toEqual(['child-late'])
|
||||
})
|
||||
|
||||
it('keeps a token count a later thread-map eviction would otherwise retract', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 4242 } } })
|
||||
expect(agents()).toMatchObject([{ tokens: 4242 }])
|
||||
|
||||
for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) {
|
||||
roster.handleTokenUsage({
|
||||
threadId: `other-${index}`,
|
||||
tokenUsage: { total: { totalTokens: index } }
|
||||
})
|
||||
}
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
|
||||
expect(agents()).toMatchObject([{ state: 'completed', tokens: 4242 }])
|
||||
})
|
||||
|
||||
it('caps retained usage threads, so a frame evicted before its child is dropped', () => {
|
||||
const { roster, agents } = createHarness()
|
||||
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } })
|
||||
for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) {
|
||||
roster.handleTokenUsage({
|
||||
threadId: `other-${index}`,
|
||||
tokenUsage: { total: { totalTokens: index } }
|
||||
})
|
||||
}
|
||||
|
||||
deliver(
|
||||
roster,
|
||||
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
)
|
||||
|
||||
expect(agents()[0]).not.toHaveProperty('tokens')
|
||||
})
|
||||
|
||||
it('declines a payload that is not a subagent item or a usage frame', () => {
|
||||
const { roster } = createHarness()
|
||||
|
||||
expect(
|
||||
roster.handleItem({
|
||||
threadId: THREAD,
|
||||
turnId: TURN,
|
||||
item: { type: 'commandExecution', id: 'item-9' }
|
||||
})
|
||||
).toBeNull()
|
||||
expect(roster.handleTokenUsage({ threadId: 'child-1' })).toBeNull()
|
||||
})
|
||||
|
||||
// A refusal must never advance the duplicate-suppression state: an identical
|
||||
// replay would short-circuit and the revision would never be retried. The
|
||||
// append and the publish are the two ways to be refused, so both are covered.
|
||||
it.each([{ refuse: 'append' as const }, { refuse: 'publish' as const }])(
|
||||
'retries the same revision after the $refuse is refused',
|
||||
({ refuse }) => {
|
||||
let refusing = true
|
||||
const appended: Appended[] = []
|
||||
const published: number[] = []
|
||||
const refusal = { accepted: false, reason: 'backpressure' } as const
|
||||
const roster = new CodexSubagentRoster({
|
||||
sink: {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {},
|
||||
tryAppendItem: (identity, body) => {
|
||||
if (refusing && refuse === 'append') {
|
||||
return refusal
|
||||
}
|
||||
appended.push({ identity, body })
|
||||
return { accepted: true }
|
||||
},
|
||||
tryPublish: () => {
|
||||
if (refusing && refuse === 'publish') {
|
||||
return refusal
|
||||
}
|
||||
published.push(1)
|
||||
return { accepted: true }
|
||||
}
|
||||
},
|
||||
primaryThreadId: () => THREAD,
|
||||
activeTurn: () => TURN,
|
||||
now: () => 1_000
|
||||
})
|
||||
const item = activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
|
||||
expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual(refusal)
|
||||
|
||||
// The wire redelivers the very same item; nothing about the roster changed,
|
||||
// so only a cleared suppression state can get the revision out.
|
||||
refusing = false
|
||||
expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual({
|
||||
accepted: true
|
||||
})
|
||||
// The retry re-appends when the publish was the half that failed; the real
|
||||
// queue coalesces those two by the group key into one journal write. What
|
||||
// must not happen is the revision never being published at all.
|
||||
expect(published).toHaveLength(1)
|
||||
const body = appended.at(-1)?.body
|
||||
expect(
|
||||
body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : []
|
||||
).toMatchObject([{ agents: [{ id: 'child-1', state: 'working' }] }])
|
||||
}
|
||||
)
|
||||
|
||||
// The sweep is the last event a group ever gets. A refusal there, left
|
||||
// unretried, strands the settled roster's final revision — the exact "row
|
||||
// stays stale forever" this row exists to prevent.
|
||||
it('republishes the settled roster when the sweep publish was refused', () => {
|
||||
let refusing = false
|
||||
const appended: Appended[] = []
|
||||
const published: number[] = []
|
||||
const roster = new CodexSubagentRoster({
|
||||
sink: {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {},
|
||||
tryAppendItem: (identity, body) => {
|
||||
appended.push({ identity, body })
|
||||
return { accepted: true }
|
||||
},
|
||||
tryPublish: () => {
|
||||
if (refusing) {
|
||||
return { accepted: false, reason: 'backpressure' }
|
||||
}
|
||||
published.push(1)
|
||||
return { accepted: true }
|
||||
}
|
||||
},
|
||||
primaryThreadId: () => THREAD,
|
||||
activeTurn: () => TURN,
|
||||
now: () => 1_000
|
||||
})
|
||||
roster.handleItem({
|
||||
threadId: THREAD,
|
||||
turnId: TURN,
|
||||
item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
})
|
||||
const publishedBeforeSweep = published.length
|
||||
|
||||
refusing = true
|
||||
expect(roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' })
|
||||
|
||||
// The retry sweep flips no state — every child already latched — so only a
|
||||
// cleared suppression state can carry the unverifiable roster out.
|
||||
refusing = false
|
||||
expect(roster.settleSession()).toEqual({ accepted: true })
|
||||
expect(published.length).toBe(publishedBeforeSweep + 1)
|
||||
const body = appended.at(-1)?.body
|
||||
expect(body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : []).toMatchObject([
|
||||
{ agents: [{ id: 'child-1', state: 'unverifiable' }] }
|
||||
])
|
||||
})
|
||||
|
||||
it('propagates sink backpressure instead of reporting the row as written', () => {
|
||||
const roster = new CodexSubagentRoster({
|
||||
sink: {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {},
|
||||
tryAppendItem: () => ({ accepted: false, reason: 'backpressure' }),
|
||||
tryPublish: () => ({ accepted: true })
|
||||
},
|
||||
primaryThreadId: () => THREAD,
|
||||
activeTurn: () => TURN
|
||||
})
|
||||
|
||||
expect(
|
||||
roster.handleItem({
|
||||
threadId: THREAD,
|
||||
turnId: TURN,
|
||||
item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
|
||||
})
|
||||
).toEqual({ accepted: false, reason: 'backpressure' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
// The Codex subagent roster: one journal row per spawn group, revised in place.
|
||||
//
|
||||
// There is no snapshot to read. `agentsStates` arrived empty in the live probe
|
||||
// and children get no `thread/started`, so the roster is
|
||||
// accumulated purely from `subAgentActivity` items — each of which arrives TWICE
|
||||
// (`item/started` and `item/completed`). Every transition here is therefore
|
||||
// idempotent, and a terminal state latches: duplicate and out-of-order delivery
|
||||
// must not resurrect a settled child.
|
||||
//
|
||||
// KNOWN LIMITATION: `groups` is process-local and is never seeded from the
|
||||
// journal, while the row's identity is keyed on the group id alone. So once a
|
||||
// group leaves the map its row stays, and the next activity item rebuilds that
|
||||
// row from one child — rewriting N down to one. Two ways in: eviction past
|
||||
// MAX_CODEX_SUBAGENT_GROUPS, which drops the oldest-inserted group in-process
|
||||
// even while it is live, and skips the sweep so its children never latch
|
||||
// `unverifiable`; and a restart on `threadId:outside-turn`, the one group id
|
||||
// that outlives the process — `thread/resume` is verified to return the same
|
||||
// thread, and a real turn id is assumed freshly minted per turn. Seeding from
|
||||
// the journal is the fix.
|
||||
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import {
|
||||
canReplaceSubagentState,
|
||||
isTerminalSubagentState,
|
||||
MAX_SUBAGENT_FIELD_CHARS,
|
||||
subagentGroupFallbackText
|
||||
} from '../../shared/native-chat-subagent-summary'
|
||||
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
|
||||
import type {
|
||||
StructuredAgentSessionEventSink,
|
||||
StructuredAgentSessionSinkAdmission
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import {
|
||||
codexSubagentLabel,
|
||||
codexSubagentStateForKind,
|
||||
isCodexRootAgentActivity,
|
||||
readCodexSubagentActivity,
|
||||
readCodexThreadTokenTotal
|
||||
} from './codex-subagent-activity'
|
||||
import type { CodexThreadItem } from './codex-structured-item-translation'
|
||||
import {
|
||||
MAX_CODEX_SUBAGENT_GROUPS,
|
||||
MAX_CODEX_SUBAGENTS_PER_GROUP,
|
||||
MAX_CODEX_TOKEN_USAGE_THREADS
|
||||
} from './codex-structured-journal-limits'
|
||||
|
||||
const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
|
||||
|
||||
/** The turn a group belongs to when Codex reports activity outside any turn.
|
||||
* Mirrors the generic-frame bucket name so the two read alike in the journal. */
|
||||
const OUTSIDE_TURN = 'outside-turn'
|
||||
|
||||
const UNLABELLED_AGENT = 'subagent'
|
||||
|
||||
type RosterGroup = {
|
||||
groupId: string
|
||||
identity: AgentJournalItemIdentity
|
||||
/** Insertion order is the display order; the map holds the state. */
|
||||
entries: Map<string, NativeChatSubagentEntry>
|
||||
/** Times each label has been claimed, so a repeat gets an ordinal suffix. */
|
||||
labelCounts: Map<string, number>
|
||||
/** Last body written, so an idempotent replay writes no new revision. */
|
||||
lastSerialized: string | null
|
||||
}
|
||||
|
||||
/** Group identity: the parent turn that spawned the children. `agentPath` is a
|
||||
* tree rooted at the parent thread, so every child of one turn shares a row
|
||||
* no matter which thread's stream carried its activity item. */
|
||||
export function codexSubagentGroupId(threadId: string, turnId: string | null): string {
|
||||
return `${threadId}:${turnId ?? OUTSIDE_TURN}`
|
||||
}
|
||||
|
||||
/** Durable journal identity for the group's row — stable across revisions and
|
||||
* across a restart, so replay finds the same row instead of appending a new one. */
|
||||
export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity {
|
||||
return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` }
|
||||
}
|
||||
|
||||
export type CodexSubagentRosterDeps = {
|
||||
sink: StructuredAgentSessionEventSink
|
||||
/** The thread that owns the agent tree; falls back to the event's thread. */
|
||||
primaryThreadId: () => string | null
|
||||
activeTurn: (threadId: string) => string | null
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export class CodexSubagentRoster {
|
||||
private readonly groups = new Map<string, RosterGroup>()
|
||||
/** Latest reported total per thread, kept regardless of roster membership: a
|
||||
* usage frame can arrive before the child's first activity item, and filtering
|
||||
* at receipt would lose it permanently. Children are selected at write time;
|
||||
* the map itself is LRU-capped in `handleTokenUsage`. */
|
||||
private readonly tokensByThread = new Map<string, number>()
|
||||
private readonly now: () => number
|
||||
|
||||
constructor(private readonly deps: CodexSubagentRosterDeps) {
|
||||
this.now = deps.now ?? (() => Date.now())
|
||||
}
|
||||
|
||||
/** Consume a `subAgentActivity` item. Returns null when the item is not one. */
|
||||
handleItem(input: {
|
||||
threadId: string
|
||||
turnId: string | null
|
||||
item: CodexThreadItem
|
||||
}): StructuredAgentSessionSinkAdmission | null {
|
||||
const activity = readCodexSubagentActivity(input.item)
|
||||
if (!activity) {
|
||||
return null
|
||||
}
|
||||
// The root node is the parent turn itself, not a child it spawned.
|
||||
if (isCodexRootAgentActivity(activity)) {
|
||||
return ADMITTED
|
||||
}
|
||||
const group = this.groupFor(input.threadId, input.turnId)
|
||||
const existing = group.entries.get(activity.agentThreadId)
|
||||
const state = codexSubagentStateForKind(activity.kind)
|
||||
if (!existing) {
|
||||
// Rule: the first event for a child may be ANY kind. An `interacted` or
|
||||
// `completed` with no prior `started` creates the entry in the state its
|
||||
// kind implies rather than being dropped for lacking a roster row.
|
||||
if (group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) {
|
||||
return ADMITTED
|
||||
}
|
||||
const now = this.now()
|
||||
group.entries.set(activity.agentThreadId, {
|
||||
id: activity.agentThreadId,
|
||||
label: this.claimLabel(group, codexSubagentLabel(activity)),
|
||||
state,
|
||||
startedAt: now,
|
||||
...(isTerminalSubagentState(state) ? { settledAt: now } : {})
|
||||
})
|
||||
} else if (canReplaceSubagentState(existing.state, state)) {
|
||||
// A child's own verdict latches. Re-applying the same non-terminal state
|
||||
// is a no-op, which is what makes the duplicate `item/started` +
|
||||
// `item/completed` delivery idempotent. `unverifiable` does not latch: a
|
||||
// child swept when contact was lost can still report what it actually did
|
||||
// if contact returns.
|
||||
group.entries.set(activity.agentThreadId, {
|
||||
...existing,
|
||||
state,
|
||||
...(isTerminalSubagentState(state) ? { settledAt: this.now() } : {})
|
||||
})
|
||||
}
|
||||
return this.write(group)
|
||||
}
|
||||
|
||||
/** Consume `thread/tokenUsage/updated`. Returns null when the params are not one. */
|
||||
handleTokenUsage(params: unknown): StructuredAgentSessionSinkAdmission | null {
|
||||
const usage = readCodexThreadTokenTotal(params)
|
||||
if (!usage) {
|
||||
return null
|
||||
}
|
||||
// A running total: the newest frame REPLACES the previous one. Summing
|
||||
// updates would multiply a single child's usage by its frame count.
|
||||
// Re-insert so the eviction scan below sees recency: `set` on an existing
|
||||
// key keeps its original position, which would age out an active thread.
|
||||
this.tokensByThread.delete(usage.threadId)
|
||||
this.tokensByThread.set(usage.threadId, usage.totalTokens)
|
||||
while (this.tokensByThread.size > MAX_CODEX_TOKEN_USAGE_THREADS) {
|
||||
const oldest = this.tokensByThread.keys().next().value
|
||||
if (typeof oldest !== 'string') {
|
||||
break
|
||||
}
|
||||
this.tokensByThread.delete(oldest)
|
||||
}
|
||||
for (const group of this.groups.values()) {
|
||||
if (!group.entries.has(usage.threadId)) {
|
||||
continue
|
||||
}
|
||||
const admission = this.write(group)
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
}
|
||||
}
|
||||
return ADMITTED
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider is gone, so any child still reported as working will never be
|
||||
* settled by an event: it becomes `unverifiable` — contact was lost, which is
|
||||
* NOT evidence the child exited.
|
||||
*
|
||||
* This is the ONLY sweep. A turn ending is not one: `spawn_agent` children
|
||||
* routinely outlive their turn and keep reporting into the same group.
|
||||
*/
|
||||
settleSession(): StructuredAgentSessionSinkAdmission {
|
||||
for (const group of this.groups.values()) {
|
||||
const admission = this.sweep(group)
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
}
|
||||
}
|
||||
return ADMITTED
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.groups.clear()
|
||||
this.tokensByThread.clear()
|
||||
}
|
||||
|
||||
private sweep(group: RosterGroup | undefined): StructuredAgentSessionSinkAdmission {
|
||||
if (!group) {
|
||||
return ADMITTED
|
||||
}
|
||||
let changed = false
|
||||
for (const [id, entry] of group.entries) {
|
||||
if (isTerminalSubagentState(entry.state)) {
|
||||
continue
|
||||
}
|
||||
group.entries.set(id, { ...entry, state: 'unverifiable', settledAt: this.now() })
|
||||
changed = true
|
||||
}
|
||||
// A null `lastSerialized` means the previous write was refused part-way, so
|
||||
// the settled roster's last revision is queued but never published. Nothing
|
||||
// is guaranteed to write this group again, so retry here even when the sweep
|
||||
// itself changed nothing.
|
||||
return changed || group.lastSerialized === null ? this.write(group) : ADMITTED
|
||||
}
|
||||
|
||||
private groupFor(threadId: string, turnId: string | null): RosterGroup {
|
||||
const ownerThreadId = this.deps.primaryThreadId() ?? threadId
|
||||
const ownerTurnId =
|
||||
ownerThreadId === threadId ? turnId : (this.deps.activeTurn(ownerThreadId) ?? turnId)
|
||||
const groupId = codexSubagentGroupId(ownerThreadId, ownerTurnId)
|
||||
const existing = this.groups.get(groupId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const group: RosterGroup = {
|
||||
groupId,
|
||||
identity: codexSubagentGroupIdentity(groupId),
|
||||
entries: new Map(),
|
||||
labelCounts: new Map(),
|
||||
lastSerialized: null
|
||||
}
|
||||
this.groups.set(groupId, group)
|
||||
while (this.groups.size > MAX_CODEX_SUBAGENT_GROUPS) {
|
||||
const oldest = this.groups.keys().next().value
|
||||
if (typeof oldest !== 'string' || oldest === groupId) {
|
||||
break
|
||||
}
|
||||
this.groups.delete(oldest)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
/** Two children can share a trailing path segment; the ordinal keeps their
|
||||
* rows apart without inventing a name the provider never sent. */
|
||||
private claimLabel(group: RosterGroup, label: string | null): string {
|
||||
const base = label ?? UNLABELLED_AGENT
|
||||
const seen = group.labelCounts.get(base) ?? 0
|
||||
group.labelCounts.set(base, seen + 1)
|
||||
return seen === 0 ? base : `${base} ${seen + 1}`
|
||||
}
|
||||
|
||||
private write(group: RosterGroup): StructuredAgentSessionSinkAdmission {
|
||||
const agents = [...group.entries].map(([id, entry]) => {
|
||||
const tokens = this.tokensByThread.get(id)
|
||||
if (typeof tokens !== 'number' || tokens === entry.tokens) {
|
||||
return entry
|
||||
}
|
||||
// Persisted, not merely read: the thread map is LRU-capped, and reading it
|
||||
// afresh each write would retract a count this row has already shown.
|
||||
const merged = { ...entry, tokens }
|
||||
group.entries.set(id, merged)
|
||||
return merged
|
||||
})
|
||||
const body = codexSubagentGroupBody(group.groupId, agents)
|
||||
const serialized = JSON.stringify(body)
|
||||
if (serialized === group.lastSerialized) {
|
||||
// Nothing changed — a duplicate delivery must not burn a revision.
|
||||
return ADMITTED
|
||||
}
|
||||
group.lastSerialized = serialized
|
||||
// The append coalesces per group so a burst collapses to the latest roster.
|
||||
// The publish must NOT reuse that key: the queue coalesces by key alone,
|
||||
// with no op-kind check, so a publish carrying it would splice out the
|
||||
// still-queued append and the row would never reach the journal.
|
||||
const options = { coalescingKey: `codex-subagents:${group.groupId}` }
|
||||
const admission = this.deps.sink.tryAppendItem
|
||||
? this.deps.sink.tryAppendItem(group.identity, body, options)
|
||||
: (this.deps.sink.appendItem(group.identity, body, options), ADMITTED)
|
||||
if (!admission.accepted) {
|
||||
group.lastSerialized = null
|
||||
return admission
|
||||
}
|
||||
const published = this.deps.sink.tryPublish
|
||||
? this.deps.sink.tryPublish()
|
||||
: (this.deps.sink.publish(), ADMITTED)
|
||||
if (!published.accepted) {
|
||||
// Symmetric with the append refusal above: the suppression state may only
|
||||
// advance once the revision is both queued AND published. Left set, an
|
||||
// identical replay short-circuits and the last revision of a settled
|
||||
// roster stays queued but never reaches the renderer.
|
||||
group.lastSerialized = null
|
||||
}
|
||||
return published
|
||||
}
|
||||
}
|
||||
|
||||
/** The roster row: the structured block plus the plain sentence an older client
|
||||
* renders in its place. A message whose only block is the new variant would
|
||||
* reach such a client with nothing it can draw. */
|
||||
export function codexSubagentGroupBody(
|
||||
groupId: string,
|
||||
agents: readonly NativeChatSubagentEntry[]
|
||||
): AgentJournalItemBody {
|
||||
const bounded = agents.map((agent, index) => ({
|
||||
...agent,
|
||||
id: boundSubagentField(agent.id, index),
|
||||
label: boundSubagentField(agent.label, index)
|
||||
}))
|
||||
return {
|
||||
kind: 'message',
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{ type: 'text', text: subagentGroupFallbackText(bounded) },
|
||||
{ type: 'subagent-group', groupId, agents: bounded }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** `id` and `label` are provider strings, so they take the bound both readers of
|
||||
* this row already clip them to. A plain length check, not the tool-output
|
||||
* bound: that one digests the whole value before it checks the length, and this
|
||||
* runs twice per child on every streamed token-usage frame.
|
||||
*
|
||||
* A clip is not identity-preserving, so a clipped value carries the child's
|
||||
* index: two ids sharing a long prefix collapse to one React key, and
|
||||
* `claimLabel` writes its ordinal at the very tail the clip removes. The index
|
||||
* is reserved out of the bound, not appended to it, because both readers
|
||||
* re-clip to the same cap and would cut a suffix that overflowed it. */
|
||||
function boundSubagentField(value: string, index: number): string {
|
||||
if (value.length <= MAX_SUBAGENT_FIELD_CHARS) {
|
||||
return value
|
||||
}
|
||||
const suffix = `…~${index}`
|
||||
const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length
|
||||
// Slicing UTF-16 units can split a surrogate pair; a lone surrogate is
|
||||
// malformed in a durable row and lossy through any non-JSON UTF-8 hop.
|
||||
const last = value.charCodeAt(keep - 1)
|
||||
const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep
|
||||
return `${value.slice(0, end)}${suffix}`
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentType } from '../../../shared/agent-status-types'
|
||||
import {
|
||||
findJournalFileFormatRemnant,
|
||||
@@ -6,6 +10,7 @@ import {
|
||||
} from './journal-file-format-remnant'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure'
|
||||
import { staleSubagentRosterRevisions } from './journal-subagent-liveness'
|
||||
|
||||
/** What any of this file's disclosures hands the store — a repair's, or the
|
||||
* pre-SQLite notice's. Same shape, and neither is only a repair. */
|
||||
@@ -36,9 +41,9 @@ export async function openJournalStoreState(input: {
|
||||
adopt: (loaded: JournalLoad) => void
|
||||
/** Republishes an anchor row for an epoch a repair emptied. */
|
||||
publishRepairEpoch: () => void
|
||||
appendDisclosure: (
|
||||
identity: JournalRepairDisclosure['identity'],
|
||||
body: JournalRepairDisclosure['body'],
|
||||
appendItem: (
|
||||
identity: AgentJournalItemIdentity,
|
||||
body: AgentJournalItemBody,
|
||||
fence: number
|
||||
) => Promise<unknown>
|
||||
agent: AgentType
|
||||
@@ -68,8 +73,9 @@ export async function openJournalStoreState(input: {
|
||||
}
|
||||
if (input.malformedRows() > 0 && !input.readOnly()) {
|
||||
const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() })
|
||||
await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence())
|
||||
await input.appendItem(disclosure.identity, disclosure.body, input.highestFence())
|
||||
}
|
||||
await settleStaleSubagentRosters(input, loaded)
|
||||
// Founding the epoch and appending the row are two transactions, and a
|
||||
// committed epoch sends every later open down this branch instead. Anything
|
||||
// that interrupts between them — a quit during startup restore, a failed
|
||||
@@ -92,7 +98,7 @@ export async function openJournalStoreState(input: {
|
||||
async function discloseFileFormatRemnant(input: {
|
||||
journalDir: string
|
||||
agent: AgentType
|
||||
appendDisclosure: (
|
||||
appendItem: (
|
||||
identity: JournalDisclosure['identity'],
|
||||
body: JournalDisclosure['body'],
|
||||
fence: number
|
||||
@@ -108,5 +114,32 @@ async function discloseFileFormatRemnant(input: {
|
||||
return
|
||||
}
|
||||
const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent })
|
||||
await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence())
|
||||
await input.appendItem(disclosure.identity, disclosure.body, input.highestFence())
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires a `working` subagent roster the previous host never got to settle.
|
||||
*
|
||||
* Skipped on a corrupt load: that journal is still owed a rebuild from provider
|
||||
* history, and content written past the repair's free sequence retires the
|
||||
* demand for it.
|
||||
*/
|
||||
async function settleStaleSubagentRosters(
|
||||
input: {
|
||||
appendItem: (
|
||||
identity: AgentJournalItemIdentity,
|
||||
body: AgentJournalItemBody,
|
||||
fence: number
|
||||
) => Promise<unknown>
|
||||
highestFence: () => number
|
||||
readOnly: () => boolean
|
||||
},
|
||||
loaded: JournalLoad
|
||||
): Promise<void> {
|
||||
if (input.readOnly() || loaded.corrupt) {
|
||||
return
|
||||
}
|
||||
for (const revision of staleSubagentRosterRevisions(loaded.state.items.values())) {
|
||||
await input.appendItem(revision.identity, revision.body, input.highestFence())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ export function restoreJournalStore(
|
||||
publishRepairEpoch: () =>
|
||||
collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence),
|
||||
adopt: host.adopt,
|
||||
appendDisclosure: (identity, body, fence) =>
|
||||
host.journal().appendItem(identity, body, { fence }),
|
||||
appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence }),
|
||||
agent: host.identity.agent,
|
||||
highestFence: () => host.state().highestFence,
|
||||
malformedRows: host.malformedRows,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { isSubagentGroupBlock } from '../../../shared/native-chat-types'
|
||||
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
|
||||
import {
|
||||
codexSubagentGroupBody,
|
||||
codexSubagentGroupIdentity
|
||||
} from '../../codex/codex-subagent-roster'
|
||||
import type { openAgentSessionJournal } from './journal-store-factory'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
import { staleSubagentRosterRevisions } from './journal-subagent-liveness'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
}
|
||||
|
||||
const GROUP_ID = 'thread-1:turn-1'
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
|
||||
function tick(): number {
|
||||
clock += 1
|
||||
return clock
|
||||
}
|
||||
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
async function open(overrides: Partial<Parameters<typeof openAgentSessionJournal>[0]> = {}) {
|
||||
return journals.open({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
now: tick,
|
||||
mintEpoch: () => `epoch-${clock}`,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
/** The row as the producer writes it: the structured block plus its twin. */
|
||||
function rosterRow(agents: NativeChatSubagentEntry[]) {
|
||||
return {
|
||||
identity: codexSubagentGroupIdentity(GROUP_ID),
|
||||
body: codexSubagentGroupBody(GROUP_ID, agents)
|
||||
}
|
||||
}
|
||||
|
||||
function renderItem(agents: NativeChatSubagentEntry[]): AgentJournalRenderItem {
|
||||
const row = rosterRow(agents)
|
||||
return {
|
||||
itemId: agentJournalItemKey(row.identity),
|
||||
revision: 1,
|
||||
body: row.body,
|
||||
sequence: 2,
|
||||
observedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function rosterOf(body: AgentJournalRenderItem['body']): NativeChatSubagentEntry[] {
|
||||
return body.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : []
|
||||
}
|
||||
|
||||
function twinOf(body: AgentJournalRenderItem['body']): string | undefined {
|
||||
return body.kind === 'message'
|
||||
? body.blocks.find((block) => block.type === 'text')?.text
|
||||
: undefined
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-subagents-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('staleSubagentRosterRevisions', () => {
|
||||
it('settles a child the previous host left working, and moves the twin with it', () => {
|
||||
const revisions = staleSubagentRosterRevisions([
|
||||
renderItem([
|
||||
{ id: 'a', label: 'read_readme', state: 'working', startedAt: 10 },
|
||||
{ id: 'b', label: 'read_package', state: 'completed', startedAt: 10, settledAt: 20 }
|
||||
])
|
||||
])
|
||||
|
||||
expect(revisions).toHaveLength(1)
|
||||
expect(rosterOf(revisions[0]!.body)).toMatchObject([
|
||||
{ id: 'a', state: 'unverifiable' },
|
||||
{ id: 'b', state: 'completed' }
|
||||
])
|
||||
// Mobile reads only this sentence, so it may not go on saying `Kicked off`.
|
||||
expect(twinOf(revisions[0]!.body)).toBe('Ran 2 subagents (1 unverifiable)')
|
||||
})
|
||||
|
||||
// The child stopped being observable at an unknown moment. A stamp taken now
|
||||
// would report the time the app was down as how long the child ran.
|
||||
it('records no terminal timestamp for a child whose run length is unknown', () => {
|
||||
const revisions = staleSubagentRosterRevisions([
|
||||
renderItem([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }])
|
||||
])
|
||||
|
||||
expect(rosterOf(revisions[0]!.body)[0]).not.toHaveProperty('settledAt')
|
||||
})
|
||||
|
||||
it('owes nothing for a roster whose children all settled', () => {
|
||||
expect(
|
||||
staleSubagentRosterRevisions([
|
||||
renderItem([{ id: 'a', label: 'read', state: 'completed', settledAt: 20 }])
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves rows that carry no roster alone', () => {
|
||||
expect(
|
||||
staleSubagentRosterRevisions([
|
||||
{
|
||||
itemId: 'orca:plain',
|
||||
revision: 1,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] },
|
||||
sequence: 2,
|
||||
observedAt: 1
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
// Appending under a fresh identity would add a second row rather than revise
|
||||
// the one on disk, so an unaddressable key is left exactly as it is.
|
||||
it('skips a row whose key cannot be parsed back to its identity', () => {
|
||||
expect(
|
||||
staleSubagentRosterRevisions([
|
||||
{ ...renderItem([{ id: 'a', label: 'r', state: 'working' }]), itemId: 'not-a-key' }
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('journal reopen after the writing host is gone', () => {
|
||||
it('settles a persisted working roster to unverifiable, while the live row still reads working', async () => {
|
||||
const live = await open()
|
||||
const row = rosterRow([
|
||||
{ id: 'a', label: 'read_readme', state: 'working', startedAt: 10 },
|
||||
{ id: 'b', label: 'read_package', state: 'working', startedAt: 10 }
|
||||
])
|
||||
await live.appendItem(row.identity, row.body, { fence: 0 })
|
||||
|
||||
// Still the writing host: it can see the children, so the row says so.
|
||||
const beforeRestart = live.snapshot().items.at(-1)!
|
||||
expect(rosterOf(beforeRestart.body)).toMatchObject([{ state: 'working' }, { state: 'working' }])
|
||||
expect(twinOf(beforeRestart.body)).toBe('Kicked off 2 subagents')
|
||||
|
||||
// The host dies without ever settling them — no `ended`, so no session sweep.
|
||||
await live.close()
|
||||
|
||||
const reopened = await open()
|
||||
const afterRestart = reopened.snapshot().items.at(-1)!
|
||||
expect(afterRestart.itemId).toBe(beforeRestart.itemId)
|
||||
expect(rosterOf(afterRestart.body)).toMatchObject([
|
||||
{ id: 'a', state: 'unverifiable' },
|
||||
{ id: 'b', state: 'unverifiable' }
|
||||
])
|
||||
expect(twinOf(afterRestart.body)).toBe('Ran 2 subagents (2 unverifiable)')
|
||||
})
|
||||
|
||||
it('revises the row in place rather than appending a second one', async () => {
|
||||
const live = await open()
|
||||
const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }])
|
||||
await live.appendItem(row.identity, row.body, { fence: 0 })
|
||||
const before = live.snapshot().items.length
|
||||
await live.close()
|
||||
|
||||
const reopened = await open()
|
||||
expect(reopened.snapshot().items).toHaveLength(before)
|
||||
expect(reopened.snapshot().items.at(-1)?.revision).toBe(2)
|
||||
})
|
||||
|
||||
it('writes nothing on a second reopen once every child is settled', async () => {
|
||||
const live = await open()
|
||||
const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }])
|
||||
await live.appendItem(row.identity, row.body, { fence: 0 })
|
||||
await live.close()
|
||||
|
||||
const once = await open()
|
||||
const revision = once.snapshot().items.at(-1)?.revision
|
||||
await once.close()
|
||||
|
||||
const twice = await open()
|
||||
expect(twice.snapshot().items.at(-1)?.revision).toBe(revision)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
// A roster row left claiming live children by a host that is gone.
|
||||
//
|
||||
// The writing host revises its `subagent-group` rows in place while it can see
|
||||
// the children, and sweeps whatever is still `working` when the provider goes
|
||||
// away. A host that DIED — crash, quit, force-restart — does neither: its last
|
||||
// revision goes on saying `working`, and nothing replays those children, so no
|
||||
// later event can ever settle them. Opening the journal is the one moment a new
|
||||
// host can state the truth about the old one: contact was lost. That is
|
||||
// `unverifiable`, never a synthesized exit — see
|
||||
// `docs/reference/ssh-execution-boundary.md`.
|
||||
//
|
||||
// Reconciles JOURNAL ROWS, not roster state: nothing here seeds the producer's
|
||||
// in-process group map, so the roster's known limitation is untouched.
|
||||
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
parseAgentJournalItemKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalRenderItem
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
isSubagentGroupFallbackText,
|
||||
normalizeSubagentState,
|
||||
subagentGroupFallbackText
|
||||
} from '../../../shared/native-chat-subagent-summary'
|
||||
import {
|
||||
isSubagentGroupBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatSubagentGroupBlock
|
||||
} from '../../../shared/native-chat-types'
|
||||
|
||||
export type JournalSubagentLivenessRevision = {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: AgentJournalItemBody
|
||||
}
|
||||
|
||||
/** The revisions a reopened journal owes: one per row still claiming a live
|
||||
* child. Empty — the common case — when nothing was left mid-flight. */
|
||||
export function staleSubagentRosterRevisions(
|
||||
items: Iterable<AgentJournalRenderItem>
|
||||
): JournalSubagentLivenessRevision[] {
|
||||
const revisions: JournalSubagentLivenessRevision[] = []
|
||||
for (const item of items) {
|
||||
const body = item.body
|
||||
if (body.kind !== 'message' || !body.blocks.some(hasWorkingChild)) {
|
||||
continue
|
||||
}
|
||||
// A key that will not parse cannot be re-addressed, and appending under a
|
||||
// fresh identity would duplicate the row rather than revise it.
|
||||
const identity = parseAgentJournalItemKey(item.itemId)
|
||||
if (!identity || agentJournalItemKey(identity) !== item.itemId) {
|
||||
continue
|
||||
}
|
||||
revisions.push({ identity, body: { ...body, blocks: settleBlocks(body.blocks) } })
|
||||
}
|
||||
return revisions
|
||||
}
|
||||
|
||||
function hasWorkingChild(block: NativeChatBlock): boolean {
|
||||
return (
|
||||
isSubagentGroupBlock(block) &&
|
||||
block.agents.some((agent) => normalizeSubagentState(agent.state) === 'working')
|
||||
)
|
||||
}
|
||||
|
||||
/** No `settledAt`: the child stopped being observable at an unknown moment, and
|
||||
* stamping the reopen would report the time the app was down as how long it
|
||||
* ran. Readers already draw an unverifiable child with no stamp as having no
|
||||
* known run length. */
|
||||
function settleBlocks(blocks: readonly NativeChatBlock[]): NativeChatBlock[] {
|
||||
const settled = blocks.map((block) =>
|
||||
hasWorkingChild(block) ? settleGroup(block as NativeChatSubagentGroupBlock) : block
|
||||
)
|
||||
const rosters = settled.filter(isSubagentGroupBlock)
|
||||
const only = rosters.length === 1 ? rosters[0] : undefined
|
||||
if (!only) {
|
||||
return settled
|
||||
}
|
||||
// The plain-text twin is all a client without the block type ever shows, so it
|
||||
// has to move with the block or the two would disagree about the same row.
|
||||
const twin = subagentGroupFallbackText(only.agents)
|
||||
return settled.map((block) =>
|
||||
block.type === 'text' && isSubagentGroupFallbackText(block.text)
|
||||
? { ...block, text: twin }
|
||||
: block
|
||||
)
|
||||
}
|
||||
|
||||
function settleGroup(block: NativeChatSubagentGroupBlock): NativeChatSubagentGroupBlock {
|
||||
return {
|
||||
...block,
|
||||
agents: block.agents.map((agent) =>
|
||||
normalizeSubagentState(agent.state) === 'working'
|
||||
? { ...agent, state: 'unverifiable' as const }
|
||||
: agent
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,16 @@ describe('provider frame activity', () => {
|
||||
expect(codexProviderFrameActivity('item/reasoning/summaryPartAdded', {})).toBeNull()
|
||||
})
|
||||
|
||||
it('names a fan-out from either Codex item type that reports one', () => {
|
||||
for (const type of ['collabAgentToolCall', 'subAgentActivity']) {
|
||||
expect(
|
||||
codexProviderFrameActivity('item/started', {
|
||||
item: { type, kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }
|
||||
})
|
||||
).toBe('Coordinating with another agent')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses Claude descriptions and safe semantic status without exposing tool labels', () => {
|
||||
expect(
|
||||
claudeProviderFrameActivity('message:system:task_started', {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isDeltaShapedProviderFrameKind,
|
||||
PROVIDER_FRAME_CLASSIFICATIONS
|
||||
} from './provider-frame-disposition'
|
||||
import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame'
|
||||
|
||||
describe('provider frame classification catalog', () => {
|
||||
it('classifies every pinned Codex app-server notification method', () => {
|
||||
@@ -124,7 +125,7 @@ describe('provider frame classification catalog', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps subagent items visible — the only evidence a spawned agent is working', () => {
|
||||
it('suppresses subAgentActivity once the roster renders it, but never collabAgentToolCall', () => {
|
||||
expect(
|
||||
classifyProviderFrame('codex', 'item:subAgentActivity', {
|
||||
id: 'a-1',
|
||||
@@ -132,7 +133,9 @@ describe('provider frame classification catalog', () => {
|
||||
agentThreadId: 'thread-child',
|
||||
agentPath: '/root/list_directory'
|
||||
})
|
||||
).toBe('timeline-substantive')
|
||||
// The spawn-group roster row renders this now, so a raw gray row beside it
|
||||
// would duplicate it. Suppressing it was gated on that renderer existing.
|
||||
).toBe('status-chrome')
|
||||
expect(
|
||||
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
|
||||
id: 'c-1',
|
||||
@@ -161,3 +164,45 @@ describe('provider frame classification catalog', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex subagent item disposition', () => {
|
||||
it('keeps subagent lifecycle out of the transcript now that it renders as a roster row', () => {
|
||||
expect(
|
||||
classifyProviderFrame('codex', 'item:subAgentActivity', {
|
||||
type: 'subAgentActivity',
|
||||
kind: 'started',
|
||||
agentThreadId: 'child-1',
|
||||
agentPath: '/root/read'
|
||||
})
|
||||
).toBe('status-chrome')
|
||||
})
|
||||
|
||||
it('leaves collab tool calls substantive — they may be the only subagent signal', () => {
|
||||
// A session that reports no `subAgentActivity` gets no roster row, so
|
||||
// suppressing this too would render its fan-out blank.
|
||||
expect(
|
||||
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
|
||||
type: 'collabAgentToolCall',
|
||||
agentsStates: {}
|
||||
})
|
||||
).not.toBe('status-chrome')
|
||||
})
|
||||
|
||||
it('journals no fallback row for subagent activity', () => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', 'item:subAgentActivity', {
|
||||
kind: 'completed',
|
||||
agentThreadId: 'child-1'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('still surfaces a subagent frame that reports a failure', () => {
|
||||
expect(
|
||||
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
|
||||
type: 'collabAgentToolCall',
|
||||
status: 'failed'
|
||||
})
|
||||
).toBe('error-surface')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CodexAppServerNotificationMethod } from '../../codex/codex-app-server-notification-schema'
|
||||
import { CODEX_SUBAGENT_ITEM_TYPE } from '../../codex/codex-subagent-activity'
|
||||
import type { ClaudeStreamJsonFrameKind } from './claude-stream-json-frame-schema'
|
||||
|
||||
export type ProviderFrameClassification =
|
||||
@@ -198,10 +199,21 @@ const CODEX_ITEM_CLASSIFICATIONS: Record<string, ProviderFrameClassification> =
|
||||
// The `thread/compacted` notification is already chrome; its item form is the
|
||||
// same event and must not read as a mysterious opcode row.
|
||||
contextCompaction: 'status-chrome',
|
||||
// Subagent lifecycle renders as the spawn-group roster row, so its raw items
|
||||
// must not print a gray `codex · item:<type>` row beside it. The live
|
||||
// notification path intercepts them before this catalog is reached;
|
||||
// `restoreThread` replays them straight through `items.handle`, which is where
|
||||
// the classification earns its keep.
|
||||
//
|
||||
// `collabAgentToolCall` is deliberately NOT suppressed with it. Nothing
|
||||
// guarantees a session reports subagent work as `subAgentActivity` at all; one
|
||||
// that only ever emits the collab tool call gets no roster row, and suppressing
|
||||
// that too would leave its fan-out showing nothing.
|
||||
[CODEX_SUBAGENT_ITEM_TYPE]: 'status-chrome',
|
||||
// `{id, durationMs}` and nothing else — Codex's own transcript renders it as
|
||||
// nothing at all. Every other item type this build does not model carries text
|
||||
// a user would want (review output, an image path, hook prompt text, subagent
|
||||
// progress), so those keep their visible fallback row.
|
||||
// a user would want (review output, an image path, hook prompt text), so those
|
||||
// keep their visible fallback row.
|
||||
sleep: 'status-chrome'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MAX_CODEX_SUBAGENTS_PER_GROUP } from '../../codex/codex-structured-journal-limits'
|
||||
import {
|
||||
boundWorkerTranscriptMessages,
|
||||
redactWorkerTerminalLines
|
||||
@@ -57,6 +58,80 @@ describe('worker transcript wire bounds', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// The bound matches the producer's per-group cap, so nothing this build writes
|
||||
// is clipped here. It stays because the journal schema declares no maximum and
|
||||
// a remote host may run a build with a larger one — the transport's own
|
||||
// invariant that no single block is huge.
|
||||
it('caps and redacts a spawn group the way every other collection is capped', () => {
|
||||
const result = boundWorkerTranscriptMessages([
|
||||
{
|
||||
id: 'message-roster',
|
||||
role: 'system',
|
||||
timestamp: null,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'subagent-group',
|
||||
groupId: 'thread-1:turn-1',
|
||||
agents: Array.from({ length: 80 }, (_unused, index) => ({
|
||||
id: `child-${index}`,
|
||||
label: index === 0 ? `dcap_${'A'.repeat(24)}` : 'read',
|
||||
state: 'working' as const
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const block = result.messages[0]?.blocks[0]
|
||||
expect(block?.type).toBe('subagent-group')
|
||||
expect(block?.type === 'subagent-group' ? block.agents : []).toHaveLength(
|
||||
MAX_CODEX_SUBAGENTS_PER_GROUP
|
||||
)
|
||||
expect(JSON.stringify(result.messages)).not.toContain('dcap_')
|
||||
expect(result.limited).toBe(true)
|
||||
expect(result.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Some subagents were omitted from oversized spawn groups.',
|
||||
'Dispatch capability tokens were redacted from transcript output.'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds a spawn-group state a newer build wrote as an oversized open string', () => {
|
||||
const result = boundWorkerTranscriptMessages([
|
||||
{
|
||||
id: 'message-roster-state',
|
||||
role: 'system',
|
||||
timestamp: null,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'subagent-group',
|
||||
groupId: 'g'.repeat(900),
|
||||
agents: [
|
||||
{
|
||||
id: 'i'.repeat(900),
|
||||
label: 'l'.repeat(900),
|
||||
state: 's'.repeat(900) as 'working'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const block = result.messages[0]?.blocks[0]
|
||||
const agent = block?.type === 'subagent-group' ? block.agents[0] : undefined
|
||||
expect(block?.type === 'subagent-group' ? block.groupId.length : 0).toBe(512)
|
||||
expect(agent?.id.length).toBe(512)
|
||||
expect(agent?.label.length).toBe(512)
|
||||
// A clipped state names no state any build knows, which is what
|
||||
// `unverifiable` records — a 512-character fragment is not a state at all.
|
||||
expect(agent?.state).toBe('unverifiable')
|
||||
expect(result.limited).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps complete bounded messages unlimited', () => {
|
||||
const result = boundWorkerTranscriptMessages([
|
||||
{
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types'
|
||||
import { normalizeSubagentState } from '../../../shared/native-chat-subagent-summary'
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatMessage,
|
||||
NativeChatSubagentState
|
||||
} from '../../../shared/native-chat-types'
|
||||
|
||||
export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40
|
||||
export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50
|
||||
@@ -7,6 +12,14 @@ const MAX_WORKER_TRANSCRIPT_BLOCKS = 6
|
||||
const MAX_WORKER_TRANSCRIPT_BLOCK_CHARS = 1_200
|
||||
const MAX_WORKER_TRANSCRIPT_INPUT_ITEMS = 20
|
||||
const MAX_WORKER_TRANSCRIPT_INPUT_NODES = 100
|
||||
// Matches the producer's per-group cap, so no group this build writes is clipped
|
||||
// here. The bound stays because the journal schema declares no maximum and a
|
||||
// remote host may run a build with a larger one.
|
||||
const MAX_WORKER_TRANSCRIPT_SUBAGENTS = 64
|
||||
// Message ids, turn ids, tool-call names and image urls, not only roster fields.
|
||||
// Equal to `MAX_SUBAGENT_FIELD_CHARS` today, kept a separate literal so a
|
||||
// roster-motivated change to that cap cannot silently move this one.
|
||||
const MAX_WORKER_TRANSCRIPT_METADATA_CHARS = 512
|
||||
const MAX_WORKER_TRANSCRIPT_RESPONSE_BYTES = 512 * 1024
|
||||
const TRUNCATION_MARKER = '\n… (truncated)'
|
||||
const DISPATCH_CAPABILITY_PATTERN = /\bdcap_[A-Za-z0-9_-]{20,}\b/g
|
||||
@@ -128,6 +141,24 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native
|
||||
input: boundToolInput(block.input, budget, 0, state)
|
||||
}
|
||||
}
|
||||
if (block.type === 'subagent-group') {
|
||||
const agents = block.agents.slice(0, MAX_WORKER_TRANSCRIPT_SUBAGENTS)
|
||||
if (agents.length < block.agents.length) {
|
||||
markClipped(state, 'Some subagents were omitted from oversized spawn groups.')
|
||||
}
|
||||
// Labels, ids and states come from provider-supplied strings, so they get the
|
||||
// same redaction and clipping every other piece of transcript metadata gets.
|
||||
return {
|
||||
...block,
|
||||
groupId: clipMetadata(block.groupId, state),
|
||||
agents: agents.map((agent) => ({
|
||||
...agent,
|
||||
id: clipMetadata(agent.id, state),
|
||||
label: clipMetadata(agent.label, state),
|
||||
state: clipSubagentState(agent.state, state)
|
||||
}))
|
||||
}
|
||||
}
|
||||
if (block.path || (block.url && isLocalFileLocator(block.url))) {
|
||||
markClipped(state, 'Local image paths were omitted from transcript output.')
|
||||
return {
|
||||
@@ -165,11 +196,22 @@ function isLocalFileLocator(value: string): boolean {
|
||||
|
||||
function clipMetadata(value: string, state: TranscriptBoundState): string {
|
||||
const redacted = redactSensitiveText(value, state.warnings)
|
||||
if (redacted.length <= 512) {
|
||||
if (redacted.length <= MAX_WORKER_TRANSCRIPT_METADATA_CHARS) {
|
||||
return redacted
|
||||
}
|
||||
markClipped(state, 'Oversized transcript metadata was clipped.')
|
||||
return redacted.slice(0, 512)
|
||||
return redacted.slice(0, MAX_WORKER_TRANSCRIPT_METADATA_CHARS)
|
||||
}
|
||||
|
||||
/** `state` is an open string on the wire, so it takes the same bound. A value
|
||||
* that had to be redacted or clipped names no state any build knows, which is
|
||||
* exactly what `unverifiable` records. */
|
||||
function clipSubagentState(
|
||||
value: NativeChatSubagentState,
|
||||
state: TranscriptBoundState
|
||||
): NativeChatSubagentState {
|
||||
const clipped = clipMetadata(value, state)
|
||||
return clipped === value ? value : normalizeSubagentState(clipped)
|
||||
}
|
||||
|
||||
function clipText(value: string, state: TranscriptBoundState): string {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
MAX_SUBAGENT_FIELD_CHARS,
|
||||
normalizeSubagentState
|
||||
} from '../../../../shared/native-chat-subagent-summary'
|
||||
import type { NativeChatBlock, NativeChatSubagentState } from '../../../../shared/native-chat-types'
|
||||
import type { RpcContext } from '../core'
|
||||
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
|
||||
|
||||
// Why: the mobile-only payload diet. Inline image bytes are kept off every RPC
|
||||
// transport; everything below that only applies to `mobile` clients, whose
|
||||
// renderer previews block bodies rather than showing them whole.
|
||||
|
||||
// Why: a single tool result (a big file read, a long diff) can be hundreds of KB.
|
||||
// The mobile view only previews tool block bodies, so truncate them on the wire
|
||||
// to keep the payload small; the marker tells the user content was clipped.
|
||||
const MOBILE_BLOCK_CHAR_CAP = 4000
|
||||
// Why: text blocks are the message body itself, rendered in full by the chat
|
||||
// view — a preview-sized cap cut long assistant replies mid-sentence with no way
|
||||
// to read on (STA-3230). Keep only a generous safety ceiling: a transcript
|
||||
// record can legally reach 2MB, and shipping that much markdown in one block
|
||||
// would freeze the phone.
|
||||
const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000
|
||||
const MOBILE_TOOL_INPUT_ITEMS_CAP = 20
|
||||
const MOBILE_TOOL_INPUT_NODE_CAP = 100
|
||||
// Why: a spawn group's roster is metadata, not a body — provider-supplied agent
|
||||
// paths and an open-string lifecycle whose schema declares no maximum, so a
|
||||
// journal from a newer build can carry more children and longer strings than
|
||||
// this build ever writes.
|
||||
const MOBILE_SUBAGENT_CAP = 64
|
||||
const TRUNCATION_MARKER = '\n… (truncated)'
|
||||
|
||||
function clip(text: string, cap: number): string {
|
||||
return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text
|
||||
}
|
||||
|
||||
export function sanitizeNativeChatRpcBlock(
|
||||
block: NativeChatBlock,
|
||||
clientKind: RpcContext['clientKind']
|
||||
): NativeChatBlock {
|
||||
if (block.type === 'image-ref') {
|
||||
return sanitizeNativeChatRpcImageBlock(block)
|
||||
}
|
||||
if (clientKind !== 'mobile') {
|
||||
return block
|
||||
}
|
||||
if (block.type === 'text') {
|
||||
return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP
|
||||
? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) }
|
||||
: block
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
return block.output.length > MOBILE_BLOCK_CHAR_CAP
|
||||
? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) }
|
||||
: block
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP }
|
||||
return { ...block, input: sanitizeToolInput(block.input, budget, 0) }
|
||||
}
|
||||
if (block.type === 'subagent-group') {
|
||||
return {
|
||||
...block,
|
||||
groupId: clip(block.groupId, MAX_SUBAGENT_FIELD_CHARS),
|
||||
agents: block.agents.slice(0, MOBILE_SUBAGENT_CAP).map((agent) => ({
|
||||
...agent,
|
||||
id: clip(agent.id, MAX_SUBAGENT_FIELD_CHARS),
|
||||
label: clip(agent.label, MAX_SUBAGENT_FIELD_CHARS),
|
||||
state: clipSubagentState(agent.state)
|
||||
}))
|
||||
}
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
/** A state too long to be one this build knows names no state at all, which is
|
||||
* what `unverifiable` records — clipping it would ship a truncated word. */
|
||||
function clipSubagentState(value: NativeChatSubagentState): NativeChatSubagentState {
|
||||
return value.length > MAX_SUBAGENT_FIELD_CHARS ? normalizeSubagentState(value) : value
|
||||
}
|
||||
|
||||
function sanitizeToolInput(
|
||||
value: unknown,
|
||||
budget: { remaining: number; nodes: number },
|
||||
depth: number
|
||||
): unknown {
|
||||
budget.nodes--
|
||||
if (budget.nodes < 0 || budget.remaining <= 0) {
|
||||
return '… (truncated)'
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const length = Math.min(value.length, budget.remaining)
|
||||
budget.remaining -= length
|
||||
return length < value.length ? `${value.slice(0, length)}… (truncated)` : value
|
||||
}
|
||||
if (!value || typeof value !== 'object' || depth >= 5) {
|
||||
return value && typeof value === 'object' ? '… (truncated)' : value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const result = value
|
||||
.slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP)
|
||||
.map((item) => sanitizeToolInput(item, budget, depth + 1))
|
||||
if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) {
|
||||
result.push('… (truncated)')
|
||||
}
|
||||
return result
|
||||
}
|
||||
const result: Record<string, unknown> = {}
|
||||
let count = 0
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue
|
||||
}
|
||||
if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) {
|
||||
result['…'] = 'truncated'
|
||||
break
|
||||
}
|
||||
let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128))
|
||||
// Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse
|
||||
// to the same bounded key; suffix collisions so neither field is silently lost.
|
||||
if (Object.hasOwn(result, boundedKey)) {
|
||||
boundedKey = `${boundedKey}~${count}`
|
||||
}
|
||||
budget.remaining -= boundedKey.length
|
||||
result[boundedKey] = sanitizeToolInput(
|
||||
(value as Record<string, unknown>)[key],
|
||||
budget,
|
||||
depth + 1
|
||||
)
|
||||
count++
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -266,6 +266,39 @@ describe('nativeChat.readSession clientKind truncation gating', () => {
|
||||
expect(JSON.stringify(input)).toContain('truncated')
|
||||
})
|
||||
|
||||
// The roster block reached mobile through a bare fall-through, uncapped, on the
|
||||
// one path that exists to keep the payload off the phone.
|
||||
it('bounds a spawn-group roster before sending it to mobile', async () => {
|
||||
cachedResult.value = {
|
||||
messages: [
|
||||
{
|
||||
...makeMessage('ignored'),
|
||||
blocks: [
|
||||
{
|
||||
type: 'subagent-group',
|
||||
groupId: 'thread-1:turn-1',
|
||||
agents: Array.from({ length: 80 }, (_unused, index) => ({
|
||||
id: `child-${index}`,
|
||||
label: index === 0 ? OVERSIZED : 'read',
|
||||
state: index === 0 ? (OVERSIZED as 'working') : ('working' as const)
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const result = await readSessionHandler()({ agent: 'codex', sessionId: 's' }, ctxWith('mobile'))
|
||||
const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0]
|
||||
if (block.type !== 'subagent-group') {
|
||||
throw new Error('expected a subagent-group block')
|
||||
}
|
||||
|
||||
expect(block.agents).toHaveLength(64)
|
||||
expect(block.agents[0].label.length).toBeLessThan(OVERSIZED.length)
|
||||
expect(block.agents[0].state).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('preserves AskUserQuestion option objects at the supported nesting depth', async () => {
|
||||
cachedResult.value = {
|
||||
messages: [
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatMessage,
|
||||
AgentType
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatMessage, AgentType } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
readNativeChatTranscriptTail,
|
||||
subscribeNativeChatTranscript,
|
||||
@@ -11,7 +7,7 @@ import {
|
||||
type SubscribeNativeChatTranscriptArgs
|
||||
} from '../../../native-chat/transcript-watch'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
|
||||
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
|
||||
import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize'
|
||||
|
||||
// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The
|
||||
// desktop reaches the readers via Electron IPC; mobile/web clients reach the
|
||||
@@ -68,109 +64,15 @@ const NativeChatUnsubscribe = z.object({
|
||||
// older history as the user scrolls back.
|
||||
const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40
|
||||
const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
|
||||
// Why: a single tool result (a big file read, a long diff) can be hundreds of KB.
|
||||
// The mobile view only previews tool block bodies, so truncate them on the wire
|
||||
// to keep the payload small; the marker tells the user content was clipped.
|
||||
const MOBILE_BLOCK_CHAR_CAP = 4000
|
||||
// Why: text blocks are the message body itself, rendered in full by the chat
|
||||
// view — a preview-sized cap cut long assistant replies mid-sentence with no way
|
||||
// to read on (STA-3230). Keep only a generous safety ceiling: a transcript
|
||||
// record can legally reach 2MB, and shipping that much markdown in one block
|
||||
// would freeze the phone.
|
||||
const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000
|
||||
const MOBILE_TOOL_INPUT_ITEMS_CAP = 20
|
||||
const MOBILE_TOOL_INPUT_NODE_CAP = 100
|
||||
const TRUNCATION_MARKER = '\n… (truncated)'
|
||||
|
||||
function clip(text: string, cap: number): string {
|
||||
return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text
|
||||
}
|
||||
|
||||
function sanitizeBlock(
|
||||
block: NativeChatBlock,
|
||||
clientKind: RpcContext['clientKind']
|
||||
): NativeChatBlock {
|
||||
if (block.type === 'image-ref') {
|
||||
return sanitizeNativeChatRpcImageBlock(block)
|
||||
}
|
||||
if (clientKind !== 'mobile') {
|
||||
return block
|
||||
}
|
||||
if (block.type === 'text') {
|
||||
return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP
|
||||
? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) }
|
||||
: block
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
return block.output.length > MOBILE_BLOCK_CHAR_CAP
|
||||
? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) }
|
||||
: block
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP }
|
||||
return { ...block, input: sanitizeToolInput(block.input, budget, 0) }
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
function sanitizeToolInput(
|
||||
value: unknown,
|
||||
budget: { remaining: number; nodes: number },
|
||||
depth: number
|
||||
): unknown {
|
||||
budget.nodes--
|
||||
if (budget.nodes < 0 || budget.remaining <= 0) {
|
||||
return '… (truncated)'
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const length = Math.min(value.length, budget.remaining)
|
||||
budget.remaining -= length
|
||||
return length < value.length ? `${value.slice(0, length)}… (truncated)` : value
|
||||
}
|
||||
if (!value || typeof value !== 'object' || depth >= 5) {
|
||||
return value && typeof value === 'object' ? '… (truncated)' : value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const result = value
|
||||
.slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP)
|
||||
.map((item) => sanitizeToolInput(item, budget, depth + 1))
|
||||
if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) {
|
||||
result.push('… (truncated)')
|
||||
}
|
||||
return result
|
||||
}
|
||||
const result: Record<string, unknown> = {}
|
||||
let count = 0
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue
|
||||
}
|
||||
if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) {
|
||||
result['…'] = 'truncated'
|
||||
break
|
||||
}
|
||||
let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128))
|
||||
// Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse
|
||||
// to the same bounded key; suffix collisions so neither field is silently lost.
|
||||
if (Object.hasOwn(result, boundedKey)) {
|
||||
boundedKey = `${boundedKey}~${count}`
|
||||
}
|
||||
budget.remaining -= boundedKey.length
|
||||
result[boundedKey] = sanitizeToolInput(
|
||||
(value as Record<string, unknown>)[key],
|
||||
budget,
|
||||
depth + 1
|
||||
)
|
||||
count++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function sanitizeMessage(
|
||||
message: NativeChatMessage,
|
||||
clientKind: RpcContext['clientKind']
|
||||
): NativeChatMessage {
|
||||
return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) }
|
||||
return {
|
||||
...message,
|
||||
blocks: message.blocks.map((block) => sanitizeNativeChatRpcBlock(block, clientKind))
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAppendForClient(
|
||||
|
||||
@@ -4,6 +4,11 @@ import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { subagentGroupFallbackText } from '../../../../shared/native-chat-subagent-summary'
|
||||
import type {
|
||||
NativeChatMessage,
|
||||
NativeChatSubagentEntry
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
|
||||
@@ -560,3 +565,293 @@ describe('NativeChatMessageList assistant messages', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// List-level, because every defect this feature has shipped so far lived in the
|
||||
// assembly between rows — the roster is its own `role: 'system'` journal row, and
|
||||
// what reaches the DOM depends on `foldToolMessages`, the turn-key mapping and the
|
||||
// disclosure state the list owns. Rendering `NativeChatToolRun` in isolation
|
||||
// supplies those by hand and agrees with whatever the caller was asked to assume.
|
||||
describe('NativeChatMessageList spawn-group roster', () => {
|
||||
const ROSTER: NativeChatSubagentEntry[] = [
|
||||
{ id: 'a', label: 'read', state: 'completed' },
|
||||
{ id: 'b', label: 'search', state: 'failed' }
|
||||
]
|
||||
|
||||
/** The exact two-block row `codexSubagentGroupBody` writes: the structured
|
||||
* block plus the plain-text twin a client without the block type reads. */
|
||||
function rosterMessage(agents: NativeChatSubagentEntry[], at: number): NativeChatMessage {
|
||||
return {
|
||||
id: 'roster-1',
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{ type: 'text', text: subagentGroupFallbackText(agents) },
|
||||
{ type: 'subagent-group', groupId: 'thread-1:turn-1', agents }
|
||||
],
|
||||
timestamp: at,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit ascending timestamps: the list re-sorts by (timestamp, id), so rows
|
||||
// sharing a millisecond tie-break alphabetically and the user turn can land
|
||||
// last — which would strand the roster outside its own turn.
|
||||
function rosterSession(
|
||||
agents: NativeChatSubagentEntry[],
|
||||
startedAt: number
|
||||
): NativeChatLiveSession {
|
||||
return {
|
||||
...session,
|
||||
status: 'ready',
|
||||
messages: [
|
||||
{
|
||||
id: 'user-fanout',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'Fan this out' }],
|
||||
timestamp: startedAt,
|
||||
source: 'transcript'
|
||||
},
|
||||
{
|
||||
id: 'assistant-fanout',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'tool-call', name: 'shell', input: { command: 'pwd' }, state: 'completed' },
|
||||
{ type: 'tool-result', output: '/repo' }
|
||||
],
|
||||
timestamp: startedAt + 1,
|
||||
source: 'transcript'
|
||||
},
|
||||
rosterMessage(agents, startedAt + 2)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// A settled turn with its activity collapsed is the resting state of the whole
|
||||
// transcript, so this is the roster's normal appearance, not an edge case. The
|
||||
// completed-turn disclosure guard used to swallow it here — the compact row the
|
||||
// feature exists to leave behind vanished the moment its turn ended.
|
||||
it('leaves the roster row behind on a settled turn whose activity is collapsed', () => {
|
||||
const startedAt = Date.now() - 3000
|
||||
render(
|
||||
<NativeChatMessageList
|
||||
session={rosterSession(ROSTER, startedAt)}
|
||||
isWorking={false}
|
||||
workingStartedAt={startedAt}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /Ran 2 subagents/ })).toHaveTextContent('1 failed')
|
||||
// The twin is the roster written out for clients that cannot draw the block.
|
||||
// This one draws it, so printing the sentence too would say it all twice.
|
||||
expect(screen.queryByText('Ran 2 subagents (1 failed)')).toBeNull()
|
||||
})
|
||||
|
||||
// The block is provider-agnostic — the Claude lane feeds it too — so a lane
|
||||
// that folds a roster into a message carrying real prose is a live shape. The
|
||||
// filter used to drop EVERY text block once a roster was present, so that
|
||||
// prose vanished on desktop while mobile, which reads the raw blocks, kept it.
|
||||
it('keeps prose beside a roster block and drops only the twin', () => {
|
||||
const startedAt = Date.now() - 3000
|
||||
const twin = subagentGroupFallbackText(ROSTER)
|
||||
render(
|
||||
<NativeChatMessageList
|
||||
session={{
|
||||
...rosterSession(ROSTER, startedAt),
|
||||
messages: [
|
||||
{
|
||||
id: 'roster-with-prose',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'Handing the audit to two children.' },
|
||||
{ type: 'text', text: twin },
|
||||
{ type: 'subagent-group', groupId: 'thread-1:turn-1', agents: ROSTER }
|
||||
],
|
||||
timestamp: startedAt + 3,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
}}
|
||||
isWorking={false}
|
||||
workingStartedAt={startedAt}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Handing the audit to two children.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Ran 2 subagents/ })).toBeInTheDocument()
|
||||
expect(screen.queryByText(twin)).toBeNull()
|
||||
})
|
||||
|
||||
// The reordering that kept the roster visible must not have let TOOL activity
|
||||
// out from behind the same disclosure: a failed child command reading as live
|
||||
// on a finished turn is what put that guard there.
|
||||
it('keeps tool activity behind the disclosure the roster now bypasses', () => {
|
||||
const startedAt = Date.now() - 3000
|
||||
render(
|
||||
<NativeChatMessageList
|
||||
session={rosterSession(ROSTER, startedAt)}
|
||||
isWorking={false}
|
||||
workingStartedAt={startedAt}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /1× shell/ })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle turn details' }))
|
||||
expect(screen.getByRole('button', { name: /1× shell/ })).toBeInTheDocument()
|
||||
// Expanding must reveal the tools beside the roster, never a second copy of it.
|
||||
expect(screen.getAllByRole('button', { name: /Ran 2 subagents/ })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads as a live spawn while the turn is still working', () => {
|
||||
render(
|
||||
<NativeChatMessageList
|
||||
session={{
|
||||
...rosterSession(
|
||||
[
|
||||
{ id: 'a', label: 'read', state: 'working' },
|
||||
{ id: 'b', label: 'search', state: 'working' }
|
||||
],
|
||||
Date.now() - 3000
|
||||
),
|
||||
status: 'working'
|
||||
}}
|
||||
isWorking
|
||||
workingStartedAt={Date.now()}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Kicked off 2 subagents/ })).toHaveTextContent(
|
||||
'2 working'
|
||||
)
|
||||
})
|
||||
|
||||
// The QA defect, at the seam that produced it. A mid-turn correction opens a
|
||||
// NEW turn, so `isCurrentTurn` goes false for the fan-out's row and the list
|
||||
// passes `activeTurnIsWorking={false}` down to the roster. The row used to
|
||||
// relabel every live child `unverifiable` and flip its headline to "Ran" —
|
||||
// claiming both that contact was lost and that the fan-out had finished, while
|
||||
// the three real children were still running and completed 57-87s later.
|
||||
it('keeps live children working after a newer turn supersedes their own', () => {
|
||||
const startedAt = Date.now() - 3000
|
||||
const live = rosterSession(
|
||||
[
|
||||
{ id: 'a', label: 'read_readme', state: 'working', startedAt },
|
||||
{ id: 'b', label: 'read_package', state: 'working', startedAt }
|
||||
],
|
||||
startedAt
|
||||
)
|
||||
render(
|
||||
<NativeChatMessageList
|
||||
session={{
|
||||
...live,
|
||||
status: 'working',
|
||||
messages: [
|
||||
...live.messages,
|
||||
{
|
||||
id: 'user-correction',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'Actually, read the styleguide too' }],
|
||||
timestamp: startedAt + 3,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
}}
|
||||
isWorking
|
||||
workingStartedAt={startedAt + 3}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
|
||||
const roster = screen.getByRole('button', { name: /Kicked off 2 subagents/ })
|
||||
expect(roster).toHaveTextContent('2 working')
|
||||
expect(roster).not.toHaveTextContent('unverifiable')
|
||||
expect(screen.queryByRole('button', { name: /Ran 2 subagents/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// The block schema admits `agents: []`, so a childless spawn group is a shape the
|
||||
// wire allows even though no producer writes one. It draws nothing, so the row
|
||||
// must not be mounted on its account: "counts as renderable" and "actually draws"
|
||||
// have to answer the same. A row that passes the first and fails the second is an
|
||||
// invisible div that still consumes one `gap-5` slot of the transcript.
|
||||
describe('NativeChatMessageList childless spawn group', () => {
|
||||
const NO_AGENTS: NativeChatSubagentEntry[] = []
|
||||
|
||||
function rosterSession(blocks: NativeChatMessage['blocks'], at: number): NativeChatLiveSession {
|
||||
return {
|
||||
...session,
|
||||
status: 'ready',
|
||||
messages: [
|
||||
{
|
||||
id: 'user-fanout',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'Fan this out' }],
|
||||
timestamp: at,
|
||||
source: 'transcript'
|
||||
},
|
||||
{ id: 'roster-1', role: 'system', blocks, timestamp: at + 1, source: 'transcript' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** Every slot the transcript column lays out — one per row that mounted. */
|
||||
function emptySlots(container: HTMLElement): Element[] {
|
||||
const column = container.querySelector('.max-w-4xl')
|
||||
expect(column).not.toBeNull()
|
||||
return Array.from(column!.children).filter((slot) => slot.textContent === '')
|
||||
}
|
||||
|
||||
it('mounts no row for a bare spawn group with no children', () => {
|
||||
const startedAt = Date.now() - 3000
|
||||
const { container } = render(
|
||||
<NativeChatMessageList
|
||||
session={rosterSession(
|
||||
[{ type: 'subagent-group', groupId: 'thread-1:turn-1', agents: NO_AGENTS }],
|
||||
startedAt
|
||||
)}
|
||||
isWorking={false}
|
||||
workingStartedAt={startedAt}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Fan this out')).toBeInTheDocument()
|
||||
expect(emptySlots(container)).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the plain-text twin when the block it stands in for cannot draw', () => {
|
||||
const startedAt = Date.now() - 3000
|
||||
const { container } = render(
|
||||
<NativeChatMessageList
|
||||
session={rosterSession(
|
||||
[
|
||||
{ type: 'text', text: subagentGroupFallbackText(NO_AGENTS) },
|
||||
{ type: 'subagent-group', groupId: 'thread-1:turn-1', agents: NO_AGENTS }
|
||||
],
|
||||
startedAt
|
||||
)}
|
||||
isWorking={false}
|
||||
workingStartedAt={startedAt}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
|
||||
// The twin is dropped only because the block draws the roster instead. This
|
||||
// one cannot, so suppressing it too would leave the row with nothing at all.
|
||||
expect(screen.getByText(subagentGroupFallbackText(NO_AGENTS))).toBeInTheDocument()
|
||||
expect(emptySlots(container)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,11 @@ import CommentMarkdown, {
|
||||
} from '@/components/sidebar/CommentMarkdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
isSubagentGroupFallbackText,
|
||||
subagentGroupBlocks
|
||||
} from '../../../../shared/native-chat-subagent-summary'
|
||||
import { isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { splitNativeChatBlocks } from './native-chat-tool-fold'
|
||||
import { NativeChatToolRun } from './NativeChatToolRun'
|
||||
import { nativeChatProseToMarkdown } from './native-chat-prose'
|
||||
@@ -47,12 +51,28 @@ export const MessageRow = memo(function MessageRow({
|
||||
const rowRef = useRef<HTMLDivElement | null>(null)
|
||||
// One pass per block set: a streaming turn re-renders this row on every frame, and these
|
||||
// derivations used to re-run each time even though `message.blocks` had not changed.
|
||||
const { hasImages, markdown, prose, tools } = useMemo(() => {
|
||||
const { hasImages, markdown, prose, subagentGroups, tools } = useMemo(() => {
|
||||
const split = splitNativeChatBlocks(message.blocks)
|
||||
const groups = subagentGroupBlocks(split.prose)
|
||||
// A spawn-group row carries a plain-text twin so a client without the block
|
||||
// type still reads the roster. This one draws the block, so the twin is
|
||||
// dropped rather than printed beside it — only the twin, never the prose
|
||||
// beside it: the block is provider-agnostic, so a lane that folds a roster
|
||||
// into a message with real text must not lose that text here.
|
||||
const prose =
|
||||
groups.length === 0
|
||||
? split.prose
|
||||
: split.prose.filter(
|
||||
(block) =>
|
||||
!isSubagentGroupBlock(block) &&
|
||||
!(block.type === 'text' && isSubagentGroupFallbackText(block.text))
|
||||
)
|
||||
return {
|
||||
...split,
|
||||
markdown: nativeChatProseToMarkdown(split.prose),
|
||||
hasImages: split.prose.some((block) => block.type === 'image-ref')
|
||||
tools: split.tools,
|
||||
prose,
|
||||
subagentGroups: groups,
|
||||
markdown: nativeChatProseToMarkdown(prose),
|
||||
hasImages: prose.some((block) => block.type === 'image-ref')
|
||||
}
|
||||
}, [message.blocks])
|
||||
const isUser = message.role === 'user'
|
||||
@@ -69,7 +89,7 @@ export const MessageRow = memo(function MessageRow({
|
||||
// Skip rows with nothing renderable so the transcript shows no empty/ghost
|
||||
// bubble.
|
||||
// After all hooks, so hook order stays unconditional.
|
||||
if (markdown.length === 0 && !hasImages && tools.length === 0) {
|
||||
if (markdown.length === 0 && !hasImages && tools.length === 0 && subagentGroups.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -151,9 +171,10 @@ export const MessageRow = memo(function MessageRow({
|
||||
linkifyFilePaths={onLinkClick !== undefined}
|
||||
/>
|
||||
) : null}
|
||||
{tools.length > 0 ? (
|
||||
{tools.length > 0 || subagentGroups.length > 0 ? (
|
||||
<NativeChatToolRun
|
||||
blocks={tools}
|
||||
subagentGroups={subagentGroups}
|
||||
expandSignal={expandSignal}
|
||||
expandOverride={activityExpandOverride}
|
||||
activeTurnIsWorking={activeTurnIsWorking}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
NativeChatSubagentEntry,
|
||||
NativeChatSubagentGroupBlock,
|
||||
NativeChatSubagentState
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { NativeChatSubagentRun } from './NativeChatSubagentRun'
|
||||
import { NativeChatToolRun } from './NativeChatToolRun'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function group(agents: NativeChatSubagentEntry[]): NativeChatSubagentGroupBlock {
|
||||
return { type: 'subagent-group', groupId: 'thread:turn-1', agents }
|
||||
}
|
||||
|
||||
describe('NativeChatSubagentRun', () => {
|
||||
it('reads as a live spawn while children work', () => {
|
||||
render(
|
||||
<NativeChatSubagentRun
|
||||
block={group([
|
||||
{ id: 'a', label: 'read', state: 'working' },
|
||||
{ id: 'b', label: 'search', state: 'completed', tokens: 40661 }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Kicked off 2 subagents')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toHaveTextContent('1 working')
|
||||
expect(screen.getByRole('button')).toHaveTextContent('40.7k tokens')
|
||||
})
|
||||
|
||||
it('switches to Ran once every child completed', () => {
|
||||
render(
|
||||
<NativeChatSubagentRun
|
||||
block={group([
|
||||
{ id: 'a', label: 'read', state: 'completed' },
|
||||
{ id: 'b', label: 'search', state: 'completed' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Ran 2 subagents')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toHaveTextContent('completed')
|
||||
})
|
||||
|
||||
it('shows the worst settled verdict, not the count of finished children', () => {
|
||||
render(
|
||||
<NativeChatSubagentRun
|
||||
block={group([
|
||||
{ id: 'a', label: 'read', state: 'failed' },
|
||||
{ id: 'b', label: 'search', state: 'failed' },
|
||||
{ id: 'c', label: 'list', state: 'completed' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('2 failed')
|
||||
})
|
||||
|
||||
it('surfaces a failed child while its siblings still work', () => {
|
||||
const { container } = render(
|
||||
<NativeChatSubagentRun
|
||||
block={group([
|
||||
{ id: 'a', label: 'read', state: 'working' },
|
||||
{ id: 'b', label: 'search', state: 'working' },
|
||||
{ id: 'c', label: 'list', state: 'working' },
|
||||
{ id: 'd', label: 'edit', state: 'failed' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
const row = screen.getByRole('button')
|
||||
expect(row).toHaveTextContent('3 working')
|
||||
expect(row).toHaveTextContent('+1 failed')
|
||||
// The dot carries the failure; the pulse still says the group is in flight.
|
||||
expect(container.querySelector('.bg-destructive.animate-pulse')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the dot neutral when nothing has gone wrong', () => {
|
||||
const { container } = render(
|
||||
<NativeChatSubagentRun
|
||||
block={group([
|
||||
{ id: 'a', label: 'read', state: 'working' },
|
||||
{ id: 'b', label: 'search', state: 'completed' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent('failed')
|
||||
expect(container.querySelector('.bg-destructive')).toBeNull()
|
||||
})
|
||||
|
||||
// The QA defect: a mid-turn correction opened a new turn while three real
|
||||
// children were still running, and the row relabelled every one of them
|
||||
// `unverifiable` and flipped its headline to `Ran`. The children completed
|
||||
// 57-87s later. A turn boundary says nothing about a child.
|
||||
it('keeps a working child working once its turn is no longer the current one', () => {
|
||||
render(<NativeChatSubagentRun block={group([{ id: 'a', label: 'read', state: 'working' }])} />)
|
||||
|
||||
const row = screen.getByRole('button')
|
||||
expect(row).toHaveTextContent('working')
|
||||
expect(row).not.toHaveTextContent('unverifiable')
|
||||
expect(screen.getByText('Kicked off 1 subagent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports the verdict a child lands after its turn ended', () => {
|
||||
render(
|
||||
<NativeChatSubagentRun block={group([{ id: 'a', label: 'read', state: 'completed' }])} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toHaveTextContent('completed')
|
||||
})
|
||||
|
||||
// Only the writing host may claim loss of contact, and it writes that verdict
|
||||
// into the row itself. The renderer draws it, and never infers it.
|
||||
it('draws the unverifiable verdict the host recorded', () => {
|
||||
render(
|
||||
<NativeChatSubagentRun block={group([{ id: 'a', label: 'read', state: 'unverifiable' }])} />
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('unverifiable')
|
||||
})
|
||||
|
||||
it('leads with the bot glyph, decorative beside the word that names the group', () => {
|
||||
const { container } = render(
|
||||
<NativeChatSubagentRun block={group([{ id: 'a', label: 'read', state: 'working' }])} />
|
||||
)
|
||||
|
||||
const glyph = container.querySelector('.lucide-bot')
|
||||
expect(glyph).not.toBeNull()
|
||||
expect(glyph).toHaveAttribute('aria-hidden', 'true')
|
||||
// Never icon-only: the word is what carries the accessible name.
|
||||
expect(screen.getByRole('button')).toHaveAccessibleName(/Kicked off 1 subagent/)
|
||||
})
|
||||
|
||||
it('keeps the same glyph in every state, so a settling row never changes identity', () => {
|
||||
const states: NativeChatSubagentState[] = [
|
||||
'working',
|
||||
'idle',
|
||||
'completed',
|
||||
'failed',
|
||||
'stopped',
|
||||
'unverifiable'
|
||||
]
|
||||
|
||||
for (const state of states) {
|
||||
const { container } = render(
|
||||
<NativeChatSubagentRun block={group([{ id: 'a', label: 'read', state }])} />
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.lucide-bot')).toHaveLength(1)
|
||||
expect(container.querySelector('.lucide-check')).toBeNull()
|
||||
expect(container.querySelector('.lucide-users')).toBeNull()
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// The only aria-hidden span carrying text is the elapsed-clock wrapper: the
|
||||
// glyph's Bot is an <svg> and the status dots render empty.
|
||||
function hiddenTextSpans(container: HTMLElement): Element[] {
|
||||
return [...container.querySelectorAll('span[aria-hidden="true"]')].filter(
|
||||
(element) => (element.textContent ?? '').trim().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
it('keeps the ticking clock out of the live region until it stops moving', () => {
|
||||
const { container } = render(
|
||||
<NativeChatSubagentRun
|
||||
block={group([{ id: 'a', label: 'read', state: 'working', startedAt: 1_000 }])}
|
||||
/>
|
||||
)
|
||||
|
||||
const row = screen.getByRole('button')
|
||||
expect(row).toHaveAttribute('aria-live', 'polite')
|
||||
// A clock that reticks every second would announce a new duration every
|
||||
// second and bury the state changes the live region exists to report.
|
||||
expect(hiddenTextSpans(container)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads the elapsed time out once it has stopped moving', () => {
|
||||
const { container } = render(
|
||||
<NativeChatSubagentRun
|
||||
block={group([
|
||||
{ id: 'a', label: 'read', state: 'completed', startedAt: 1_000, settledAt: 5_000 }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
// Settled: the duration is fixed, so hiding it would cost a reader real
|
||||
// information for no announcement churn.
|
||||
expect(hiddenTextSpans(container)).toHaveLength(0)
|
||||
expect(screen.getByRole('button')).toHaveTextContent('4s')
|
||||
})
|
||||
|
||||
it('shows no duration for a child whose run length was never recorded', () => {
|
||||
render(
|
||||
<NativeChatSubagentRun
|
||||
block={group([{ id: 'a', label: 'read', state: 'unverifiable', startedAt: 1_000 }])}
|
||||
/>
|
||||
)
|
||||
|
||||
const row = screen.getByRole('button')
|
||||
expect(row).toHaveTextContent('unverifiable')
|
||||
// `unverifiable` with no terminal timestamp has no known run length, so the
|
||||
// clock would measure to `now` and report the time since we lost sight of
|
||||
// the child as how long it ran — on a row that is not even counting.
|
||||
expect(row.textContent).not.toContain('·')
|
||||
})
|
||||
|
||||
// A partial sweep leaves one child settled and one whose fate is unknown. The
|
||||
// group's clock would then report the settled sibling's duration as the
|
||||
// group's run length while the other child is still unaccounted for.
|
||||
it('shows no duration while one child settled and another is unaccounted for', () => {
|
||||
render(
|
||||
<NativeChatSubagentRun
|
||||
block={group([
|
||||
{ id: 'a', label: 'read', state: 'completed', startedAt: 1_000, settledAt: 5_000 },
|
||||
{ id: 'b', label: 'search', state: 'unverifiable', startedAt: 1_000 }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
const row = screen.getByRole('button')
|
||||
expect(row).toHaveTextContent('unverifiable')
|
||||
expect(row.textContent).not.toContain('·')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NativeChatToolRun with a spawn group', () => {
|
||||
it('renders a roster with no tool calls without inventing a tool count', () => {
|
||||
render(
|
||||
<NativeChatToolRun
|
||||
blocks={[]}
|
||||
subagentGroups={[group([{ id: 'a', label: 'read', state: 'working' }])]}
|
||||
expandSignal={false}
|
||||
activeTurnIsWorking
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Kicked off 1 subagent')).toBeInTheDocument()
|
||||
expect(screen.queryByText('1 tool call')).toBeNull()
|
||||
})
|
||||
|
||||
// Every settled turn sits here by default: the list passes
|
||||
// `expandOverride={expandedTurnIds.has(turnKey)}` — false until the reader
|
||||
// opens that turn — and `activeTurnIsWorking={false}`. The completed-turn
|
||||
// guard above bailed before the roster branch, so the one row this feature
|
||||
// exists to draw vanished the moment its turn finished, and the message row
|
||||
// that kept itself alive for it rendered an empty ghost bubble.
|
||||
it('keeps the roster visible on a completed turn whose activity is collapsed', () => {
|
||||
render(
|
||||
<NativeChatToolRun
|
||||
blocks={[]}
|
||||
subagentGroups={[group([{ id: 'a', label: 'read', state: 'completed' }])]}
|
||||
expandSignal={false}
|
||||
expandOverride={false}
|
||||
activeTurnIsWorking={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The roster-only branch returns a `mt-3` wrapper whenever it has rows, so a
|
||||
// group that draws nothing must not count as one — that wrapper would be the
|
||||
// empty bubble with a margin that the message row refuses to emit.
|
||||
it('draws nothing at all for a spawn group that carries no children', () => {
|
||||
const { container } = render(
|
||||
<NativeChatToolRun
|
||||
blocks={[]}
|
||||
subagentGroups={[group([])]}
|
||||
expandSignal={false}
|
||||
expandOverride={false}
|
||||
activeTurnIsWorking={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
// The roster-only escape above is keyed on `blocks.length === 0`, so a group
|
||||
// sharing its message with tool calls falls through to the settled-turn guard
|
||||
// — which returned bare null and took the roster with it.
|
||||
it('keeps a roster that shares its message with tool calls on a collapsed turn', () => {
|
||||
render(
|
||||
<NativeChatToolRun
|
||||
blocks={[{ type: 'tool-call', name: 'shell', input: { command: 'ls' } }]}
|
||||
subagentGroups={[group([{ id: 'a', label: 'read', state: 'completed' }])]}
|
||||
expandSignal={false}
|
||||
expandOverride={false}
|
||||
activeTurnIsWorking={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
|
||||
expect(screen.queryByText('shell ls')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the roster alongside the tool activity of its turn', () => {
|
||||
render(
|
||||
<NativeChatToolRun
|
||||
blocks={[{ type: 'tool-call', name: 'shell', input: { command: 'ls' } }]}
|
||||
subagentGroups={[group([{ id: 'a', label: 'read', state: 'completed' }])]}
|
||||
expandSignal={false}
|
||||
activeTurnIsWorking={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
|
||||
expect(screen.getByText('shell ls')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Bot, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useNow } from '@/hooks/use-now'
|
||||
import {
|
||||
normalizeSubagentState,
|
||||
summarizeSubagentGroup
|
||||
} from '../../../../shared/native-chat-subagent-summary'
|
||||
import type {
|
||||
NativeChatSubagentGroupBlock,
|
||||
NativeChatSubagentState
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { formatNativeChatDuration } from './NativeChatWorkingStatus'
|
||||
|
||||
/** Compact token counts: the row shows scale, not an exact ledger. */
|
||||
function formatSubagentTokens(tokens: number): string {
|
||||
if (tokens < 1_000) {
|
||||
return String(Math.round(tokens))
|
||||
}
|
||||
const scaled = tokens < 1_000_000 ? tokens / 1_000 : tokens / 1_000_000
|
||||
const suffix = tokens < 1_000_000 ? 'k' : 'M'
|
||||
return `${scaled.toFixed(1).replace(/\.0$/, '')}${suffix}`
|
||||
}
|
||||
|
||||
/** The group's one-line verdict. A single-child group reads as a bare word; any
|
||||
* larger group always carries the count, because "working" alone would not say
|
||||
* how many of the children it covers. `completed` never takes one: every child
|
||||
* finishing is the whole group finishing. */
|
||||
function subagentStateLabel(
|
||||
state: NativeChatSubagentState,
|
||||
count: number,
|
||||
groupTotal: number
|
||||
): string {
|
||||
if (state === 'completed') {
|
||||
return translate('components.native-chat.subagents.state.completed', 'completed')
|
||||
}
|
||||
if (groupTotal <= 1) {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return translate('components.native-chat.subagents.state.working', 'working')
|
||||
case 'idle':
|
||||
return translate('components.native-chat.subagents.state.idle', 'idle')
|
||||
case 'failed':
|
||||
return translate('components.native-chat.subagents.state.failed', 'failed')
|
||||
case 'stopped':
|
||||
return translate('components.native-chat.subagents.state.stopped', 'stopped')
|
||||
case 'unverifiable':
|
||||
return translate('components.native-chat.subagents.state.unverifiable', 'unverifiable')
|
||||
}
|
||||
}
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return translate(
|
||||
'components.native-chat.subagents.state.workingCount',
|
||||
'{{value0}} working',
|
||||
{
|
||||
value0: count
|
||||
}
|
||||
)
|
||||
case 'idle':
|
||||
return translate('components.native-chat.subagents.state.idleCount', '{{value0}} idle', {
|
||||
value0: count
|
||||
})
|
||||
case 'failed':
|
||||
return translate('components.native-chat.subagents.state.failedCount', '{{value0}} failed', {
|
||||
value0: count
|
||||
})
|
||||
case 'stopped':
|
||||
return translate(
|
||||
'components.native-chat.subagents.state.stoppedCount',
|
||||
'{{value0}} stopped',
|
||||
{
|
||||
value0: count
|
||||
}
|
||||
)
|
||||
case 'unverifiable':
|
||||
return translate(
|
||||
'components.native-chat.subagents.state.unverifiableCount',
|
||||
'{{value0}} unverifiable',
|
||||
{ value0: count }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const STATE_DOT_CLASS: Record<NativeChatSubagentState, string> = {
|
||||
working: 'bg-foreground/70',
|
||||
idle: 'bg-muted-foreground/40',
|
||||
completed: 'bg-muted-foreground/60',
|
||||
failed: 'bg-destructive',
|
||||
stopped: 'bg-muted-foreground',
|
||||
unverifiable: 'bg-muted-foreground'
|
||||
}
|
||||
|
||||
/**
|
||||
* The group's identity glyph, fixed across every state — a settling row must not
|
||||
* appear to change identity. State is carried by {@link StatusDot} and the tone
|
||||
* of the words beside it.
|
||||
*
|
||||
* SWAP POINT: once the shared category-icon component lands (PR #18760), this
|
||||
* whole component becomes that component asked for the `bot` category, which is
|
||||
* the same glyph the individual `subAgentActivity` rows use.
|
||||
*/
|
||||
function SubagentGlyph(): React.JSX.Element {
|
||||
return (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Bot aria-hidden="true" className="size-3.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** `pulsing` is separate from `state` so a group that is still working can show
|
||||
* a failed sibling's colour without losing its in-flight cue. */
|
||||
function StatusDot({
|
||||
state,
|
||||
pulsing = false
|
||||
}: {
|
||||
state: NativeChatSubagentState
|
||||
pulsing?: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
STATE_DOT_CLASS[state],
|
||||
pulsing && 'animate-pulse motion-reduce:animate-none'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Leaf so the shared 1s clock re-renders only the digits, never the roster. */
|
||||
function SubagentElapsed({
|
||||
startedAt,
|
||||
settledAt,
|
||||
counting
|
||||
}: {
|
||||
startedAt: number
|
||||
settledAt: number | null
|
||||
counting: boolean
|
||||
}): React.JSX.Element {
|
||||
const now = useNow(1_000, counting)
|
||||
const end = counting ? now : (settledAt ?? now)
|
||||
return <>{formatNativeChatDuration(Math.max(0, (end - startedAt) / 1000))}</>
|
||||
}
|
||||
|
||||
/** One spawn group: how many children are working, their settled verdict, and
|
||||
* the tokens they consumed. Deliberately flat — children are summarized here,
|
||||
* never nested into the transcript as turns of their own.
|
||||
*
|
||||
* Every state is drawn exactly as the journal recorded it. Turn state is NOT
|
||||
* consulted: `spawn_agent` children outlive the turn that spawned them and keep
|
||||
* reporting into this group long after a newer turn opened, so a turn boundary
|
||||
* is a fact about the turn and never evidence that contact with a child was
|
||||
* lost. Only a host can say that, and one does: `CodexSubagentRoster.settleSession`
|
||||
* when the provider goes away, and `staleSubagentRosterRevisions` on the next
|
||||
* journal open when the host itself died mid-flight. */
|
||||
export function NativeChatSubagentRun({
|
||||
block
|
||||
}: {
|
||||
block: NativeChatSubagentGroupBlock
|
||||
}): React.JSX.Element | null {
|
||||
const [open, setOpen] = useState(false)
|
||||
const agents = block.agents
|
||||
const summary = useMemo(() => summarizeSubagentGroup(agents), [agents])
|
||||
if (summary.total === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const working = summary.working > 0
|
||||
const headline = working
|
||||
? summary.total === 1
|
||||
? translate('components.native-chat.subagents.startedOne', 'Kicked off 1 subagent')
|
||||
: translate('components.native-chat.subagents.startedN', 'Kicked off {{value0}} subagents', {
|
||||
value0: summary.total
|
||||
})
|
||||
: summary.total === 1
|
||||
? translate('components.native-chat.subagents.ranOne', 'Ran 1 subagent')
|
||||
: translate('components.native-chat.subagents.ranN', 'Ran {{value0}} subagents', {
|
||||
value0: summary.total
|
||||
})
|
||||
const verdictState: NativeChatSubagentState = working
|
||||
? 'working'
|
||||
: (summary.settledState ?? 'idle')
|
||||
const verdict = working
|
||||
? subagentStateLabel('working', summary.working, summary.total)
|
||||
: subagentStateLabel(verdictState, summary.settledCount, summary.total)
|
||||
// A child that already failed must not wait for its siblings to be readable.
|
||||
const alertState = working ? summary.adverseState : null
|
||||
const alert =
|
||||
alertState === null ? null : subagentStateLabel(alertState, summary.adverseCount, summary.total)
|
||||
// A child settled by the reopen reads `unverifiable` with no terminal stamp:
|
||||
// it stopped being observable at an unknown moment. Measuring to `now` would
|
||||
// report the time since the host died as how long the child ran, on a row that
|
||||
// is not even counting. A sibling's stamp is no better: in a mixed group it
|
||||
// would present that sibling's duration as the group's while a child's fate is
|
||||
// still unknown.
|
||||
const runLengthUnknown = agents.some(
|
||||
(agent) =>
|
||||
normalizeSubagentState(agent.state) === 'unverifiable' && typeof agent.settledAt !== 'number'
|
||||
)
|
||||
const clockStartedAt =
|
||||
!runLengthUnknown && (working || summary.settledAt !== null) ? summary.startedAt : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="group flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
aria-expanded={open}
|
||||
aria-live="polite"
|
||||
>
|
||||
<SubagentGlyph />
|
||||
<StatusDot state={alertState ?? verdictState} pulsing={working} />
|
||||
<span className={cn('min-w-0 flex-1 truncate', working && 'text-foreground/85')}>
|
||||
{headline}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||
{verdict}
|
||||
{alert === null ? null : ` +${alert}`}
|
||||
{clockStartedAt !== null ? (
|
||||
// The row is a live region, and this clock reticks every second: left
|
||||
// exposed it announces a new duration every second and buries the
|
||||
// state changes worth hearing. Readable again once it stops moving.
|
||||
<span aria-hidden={working || undefined}>
|
||||
{' · '}
|
||||
<SubagentElapsed
|
||||
startedAt={clockStartedAt}
|
||||
settledAt={summary.settledAt}
|
||||
counting={working}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
{summary.tokens !== null
|
||||
? ` · ${translate('components.native-chat.subagents.tokens', '{{value0}} tokens', {
|
||||
value0: formatSubagentTokens(summary.tokens)
|
||||
})}`
|
||||
: null}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-all',
|
||||
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{agents.map((agent) => {
|
||||
const state = normalizeSubagentState(agent.state)
|
||||
return (
|
||||
<li key={agent.id} className="flex items-center gap-1.5 py-0.5">
|
||||
<StatusDot state={state} pulsing={state === 'working'} />
|
||||
<code
|
||||
className={cn(
|
||||
'min-w-0 truncate font-mono text-[11px]',
|
||||
state === 'idle' ? 'text-muted-foreground/70' : 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{agent.label}
|
||||
</code>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||
{subagentStateLabel(state, 1, 1)}
|
||||
{typeof agent.tokens === 'number'
|
||||
? ` · ${formatSubagentTokens(agent.tokens)}`
|
||||
: null}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock
|
||||
type NativeChatBlock,
|
||||
type NativeChatSubagentGroupBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { isRenderableSubagentGroup } from '../../../../shared/native-chat-subagent-summary'
|
||||
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
|
||||
import { NativeChatDiffCard } from './NativeChatDiffCard'
|
||||
import { pairToolBlocks } from './native-chat-tool-fold'
|
||||
@@ -27,9 +29,13 @@ import {
|
||||
} from '../../../../shared/native-chat-tool-activity'
|
||||
import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon'
|
||||
import { NativeChatDiffView } from './NativeChatDiffView'
|
||||
import { NativeChatSubagentRun } from './NativeChatSubagentRun'
|
||||
import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon'
|
||||
import { nativeChatToolActivityLabel } from './native-chat-tool-activity-label'
|
||||
|
||||
/** Stable empty default: a fresh array literal per render breaks memoization. */
|
||||
const NO_SUBAGENT_GROUPS: NativeChatSubagentGroupBlock[] = []
|
||||
|
||||
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
|
||||
* show the call's diff/input or the result's body. Tool calls read as flat
|
||||
* lines in the conversation rather than boxed blocks (mobile parity). Lines only
|
||||
@@ -182,12 +188,15 @@ function buildEditCards(blocks: NativeChatBlock[]): EditCardModel {
|
||||
* toolbar toggle drive every run at once while still allowing per-run override. */
|
||||
export function NativeChatToolRun({
|
||||
blocks,
|
||||
subagentGroups = NO_SUBAGENT_GROUPS,
|
||||
expandSignal,
|
||||
activeTurnIsWorking,
|
||||
expandOverride,
|
||||
structuredActivityUi = true
|
||||
}: {
|
||||
blocks: NativeChatBlock[]
|
||||
/** Spawn-group rosters that belong with this run's activity, one row each. */
|
||||
subagentGroups?: NativeChatSubagentGroupBlock[]
|
||||
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
|
||||
expandSignal: boolean
|
||||
/** Per-turn disclosure state controlled by the completed turn status row. */
|
||||
@@ -200,6 +209,14 @@ export function NativeChatToolRun({
|
||||
// Re-sync when the global toolbar toggle flips.
|
||||
useEffect(() => setOpen(expandOverride ?? expandSignal), [expandOverride, expandSignal])
|
||||
|
||||
// Childless groups are dropped so `subagentRows.length` stays an honest test of
|
||||
// "something will draw": the roster-only branch below returns a margin-bearing
|
||||
// wrapper on the strength of it, and a group with no children renders null.
|
||||
// Same predicate `subagentGroupBlocks` applies, so this row and the caller
|
||||
// deciding the row is worth mounting cannot disagree about what draws.
|
||||
const subagentRows = subagentGroups
|
||||
.filter(isRenderableSubagentGroup)
|
||||
.map((group) => <NativeChatSubagentRun key={group.groupId} block={group} />)
|
||||
const callCount = countToolCalls(blocks) || blocks.length
|
||||
const summary = summarizeToolRun(blocks)
|
||||
const latestActiveCall = structuredActivityUi
|
||||
@@ -229,6 +246,19 @@ export function NativeChatToolRun({
|
||||
value0: callCount
|
||||
})
|
||||
|
||||
// A roster with no tool calls beside it is the whole run: rendering the tool
|
||||
// header too would announce "1 tool call" for activity that has none.
|
||||
//
|
||||
// Ordered BEFORE the completed-turn guard below on purpose. That guard hides
|
||||
// TOOL activity behind the turn-status disclosure, and a roster row has none
|
||||
// to hide: it is the compact summary this row exists to leave behind. Bailing
|
||||
// there instead dropped it from every settled turn — the default state of the
|
||||
// whole transcript — and left the caller, which counts a spawn group as
|
||||
// renderable, drawing the empty bubble it explicitly guards against.
|
||||
if (blocks.length === 0) {
|
||||
return subagentRows.length > 0 ? <div className="mt-3">{subagentRows}</div> : null
|
||||
}
|
||||
|
||||
// Completed turn activity belongs behind the turn-status disclosure. Keeping
|
||||
// the grouped row visible here made a failed child command look like the
|
||||
// whole response was still running (or had failed) even while collapsed.
|
||||
@@ -238,13 +268,17 @@ export function NativeChatToolRun({
|
||||
isSettled &&
|
||||
activeTurnIsWorking === false
|
||||
) {
|
||||
return null
|
||||
// The roster is not tool activity, so it survives this guard exactly as it
|
||||
// survives the tool-less escape above — otherwise a group sharing a message
|
||||
// with tool calls is dropped from every settled turn.
|
||||
return subagentRows.length > 0 ? <div className="mt-3">{subagentRows}</div> : null
|
||||
}
|
||||
|
||||
return (
|
||||
// Extra top margin sets the tool run apart from the assistant prose above it
|
||||
// so the turn's activity doesn't crowd the message text.
|
||||
<div className="mt-3">
|
||||
{subagentRows}
|
||||
{latestActiveCall ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -203,3 +203,52 @@ describe('splitNativeChatBlocks', () => {
|
||||
expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawn-group roster rows', () => {
|
||||
const roster = msg({
|
||||
id: 'roster',
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'Kicked off 1 subagent — 1 working' },
|
||||
{
|
||||
type: 'subagent-group',
|
||||
groupId: 'thread:turn-1',
|
||||
agents: [{ id: 'child-1', label: 'read', state: 'working' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
it('does not end the assistant run the following tool messages fold into', () => {
|
||||
const folded = foldToolMessages([
|
||||
msg({
|
||||
id: 'a',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'working' },
|
||||
{ type: 'tool-call', name: 'Bash', input: {} }
|
||||
]
|
||||
}),
|
||||
roster,
|
||||
msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'done' }] })
|
||||
])
|
||||
|
||||
expect(folded.map((message) => message.id)).toEqual(['a', 'roster'])
|
||||
expect(folded[0]?.blocks.map((block) => block.type)).toEqual([
|
||||
'text',
|
||||
'tool-call',
|
||||
'tool-result'
|
||||
])
|
||||
})
|
||||
|
||||
it('survives the noise strip so the roster still reaches the transcript', () => {
|
||||
expect(stripNoiseMessages([roster]).map((message) => message.id)).toEqual(['roster'])
|
||||
})
|
||||
|
||||
it('keeps the roster out of the tool array so mobile draws no empty tool run', () => {
|
||||
const { prose, tools } = splitNativeChatBlocks(roster.blocks)
|
||||
|
||||
expect(tools).toEqual([])
|
||||
// The plain-text twin stays in prose: a client without the block type reads it.
|
||||
expect(prose.map((block) => block.type)).toEqual(['text', 'subagent-group'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17164,6 +17164,26 @@
|
||||
"structuredSessionFellBackToTerminal": "Structured chat isn't available",
|
||||
"structuredSessionFellBackToTerminalDescription": "Orca tried to open a {{value0}} terminal instead.",
|
||||
"structuredSessionLaunchFailedDescription": "Orca could not open a structured {{value0}} chat. See the logs for details.",
|
||||
"subagents": {
|
||||
"state": {
|
||||
"completed": "completed",
|
||||
"working": "working",
|
||||
"idle": "idle",
|
||||
"failed": "failed",
|
||||
"stopped": "stopped",
|
||||
"unverifiable": "unverifiable",
|
||||
"workingCount": "{{value0}} working",
|
||||
"idleCount": "{{value0}} idle",
|
||||
"failedCount": "{{value0}} failed",
|
||||
"stoppedCount": "{{value0}} stopped",
|
||||
"unverifiableCount": "{{value0}} unverifiable"
|
||||
},
|
||||
"startedOne": "Kicked off 1 subagent",
|
||||
"startedN": "Kicked off {{value0}} subagents",
|
||||
"ranOne": "Ran 1 subagent",
|
||||
"ranN": "Ran {{value0}} subagents",
|
||||
"tokens": "{{value0}} tokens"
|
||||
},
|
||||
"conversationCommand": {
|
||||
"pendingWork": "Wait for pending work and messages to finish before using this command.",
|
||||
"unconfirmed": "Conversation operation was not confirmed."
|
||||
|
||||
@@ -34,7 +34,24 @@ const ProviderFrame = z.object({
|
||||
payload: BoundedPayload
|
||||
})
|
||||
|
||||
const KNOWN_BLOCK_TYPES = new Set(['text', 'tool-call', 'tool-result', 'image-ref'])
|
||||
const KNOWN_BLOCK_TYPES = new Set([
|
||||
'text',
|
||||
'tool-call',
|
||||
'tool-result',
|
||||
'image-ref',
|
||||
'subagent-group'
|
||||
])
|
||||
|
||||
/** Child-agent lifecycle stays an open string for the same reason tool states
|
||||
* do: a state a newer build writes must not turn the row malformed. */
|
||||
const SubagentEntry = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
state: z.string().min(1),
|
||||
tokens: z.number().optional(),
|
||||
startedAt: z.number().optional(),
|
||||
settledAt: z.number().optional()
|
||||
})
|
||||
|
||||
/** Renderers select blocks by `type` equality and skip what they cannot draw,
|
||||
* so an unknown block type stays admissible; a known type with a broken
|
||||
@@ -59,6 +76,11 @@ const Block = z.union([
|
||||
path: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
alt: z.string().optional()
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('subagent-group'),
|
||||
groupId: z.string(),
|
||||
agents: z.array(SubagentEntry)
|
||||
})
|
||||
]),
|
||||
z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type))
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isSubagentGroupFallbackText,
|
||||
isTerminalSubagentState,
|
||||
normalizeSubagentState,
|
||||
subagentGroupFallbackText,
|
||||
summarizeSubagentGroup
|
||||
} from './native-chat-subagent-summary'
|
||||
import type { NativeChatSubagentEntry } from './native-chat-types'
|
||||
|
||||
function agent(entry: Partial<NativeChatSubagentEntry>): NativeChatSubagentEntry {
|
||||
return { id: 'a', label: 'task', state: 'working', ...entry }
|
||||
}
|
||||
|
||||
describe('summarizeSubagentGroup', () => {
|
||||
it('collapses in-flight children into one working count', () => {
|
||||
const summary = summarizeSubagentGroup([
|
||||
agent({ id: 'a', state: 'working' }),
|
||||
agent({ id: 'b', state: 'working' }),
|
||||
agent({ id: 'c', state: 'completed' })
|
||||
])
|
||||
|
||||
expect(summary).toMatchObject({ total: 3, working: 2, settledState: null, settledCount: 0 })
|
||||
})
|
||||
|
||||
it('ranks the settled verdict worst-first and reports ✓ completed last', () => {
|
||||
const cascade: [NativeChatSubagentEntry['state'][], string][] = [
|
||||
[['failed', 'stopped', 'idle', 'completed'], 'failed'],
|
||||
[['stopped', 'idle', 'completed'], 'stopped'],
|
||||
[['unverifiable', 'idle', 'completed'], 'unverifiable'],
|
||||
[['idle', 'completed'], 'idle'],
|
||||
[['completed', 'completed'], 'completed']
|
||||
]
|
||||
|
||||
for (const [states, expected] of cascade) {
|
||||
const summary = summarizeSubagentGroup(
|
||||
states.map((state, index) => agent({ id: `a${index}`, state }))
|
||||
)
|
||||
expect(summary.settledState).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('counts how many children hold the winning verdict', () => {
|
||||
const summary = summarizeSubagentGroup([
|
||||
agent({ id: 'a', state: 'failed' }),
|
||||
agent({ id: 'b', state: 'failed' }),
|
||||
agent({ id: 'c', state: 'completed' })
|
||||
])
|
||||
|
||||
expect(summary).toMatchObject({ settledState: 'failed', settledCount: 2 })
|
||||
})
|
||||
|
||||
it('sums the per-child token snapshots and leaves them null when none reported', () => {
|
||||
expect(
|
||||
summarizeSubagentGroup([
|
||||
agent({ id: 'a', tokens: 40661 }),
|
||||
agent({ id: 'b', tokens: 1000 }),
|
||||
agent({ id: 'c' })
|
||||
]).tokens
|
||||
).toBe(41661)
|
||||
expect(summarizeSubagentGroup([agent({ id: 'a' })]).tokens).toBeNull()
|
||||
})
|
||||
|
||||
it('reports the earliest start and withholds a settled time while work continues', () => {
|
||||
const working = summarizeSubagentGroup([
|
||||
agent({ id: 'a', state: 'completed', startedAt: 50, settledAt: 80 }),
|
||||
agent({ id: 'b', state: 'working', startedAt: 20 })
|
||||
])
|
||||
const settled = summarizeSubagentGroup([
|
||||
agent({ id: 'a', state: 'completed', startedAt: 50, settledAt: 80 }),
|
||||
agent({ id: 'b', state: 'stopped', startedAt: 20, settledAt: 95 })
|
||||
])
|
||||
|
||||
expect(working).toMatchObject({ startedAt: 20, settledAt: null })
|
||||
expect(settled).toMatchObject({ startedAt: 20, settledAt: 95 })
|
||||
})
|
||||
|
||||
it('reads a state this build does not know as unverifiable, never as working', () => {
|
||||
expect(normalizeSubagentState('paused-for-review')).toBe('unverifiable')
|
||||
expect(isTerminalSubagentState('paused-for-review')).toBe(true)
|
||||
expect(summarizeSubagentGroup([agent({ state: 'unheard-of' as 'working' })])).toMatchObject({
|
||||
working: 0,
|
||||
settledState: 'unverifiable'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an adverse outcome before the group settles', () => {
|
||||
const summary = summarizeSubagentGroup([
|
||||
agent({ id: 'a', state: 'working' }),
|
||||
agent({ id: 'b', state: 'working' }),
|
||||
agent({ id: 'c', state: 'failed' })
|
||||
])
|
||||
|
||||
// The group verdict is still withheld, but the failure is not.
|
||||
expect(summary).toMatchObject({
|
||||
working: 2,
|
||||
settledState: null,
|
||||
adverseState: 'failed',
|
||||
adverseCount: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('ranks the adverse outcome worst-first and ignores benign settled states', () => {
|
||||
expect(
|
||||
summarizeSubagentGroup([
|
||||
agent({ id: 'a', state: 'working' }),
|
||||
agent({ id: 'b', state: 'stopped' }),
|
||||
agent({ id: 'c', state: 'failed' })
|
||||
]).adverseState
|
||||
).toBe('failed')
|
||||
expect(
|
||||
summarizeSubagentGroup([
|
||||
agent({ id: 'a', state: 'working' }),
|
||||
agent({ id: 'b', state: 'idle' }),
|
||||
agent({ id: 'c', state: 'completed' })
|
||||
]).adverseState
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps working the only non-terminal state', () => {
|
||||
expect(isTerminalSubagentState('working')).toBe(false)
|
||||
for (const state of ['idle', 'completed', 'failed', 'stopped', 'unverifiable']) {
|
||||
expect(isTerminalSubagentState(state)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagentGroupFallbackText', () => {
|
||||
it('names the failure a client without the block type would otherwise never see', () => {
|
||||
expect(
|
||||
subagentGroupFallbackText([
|
||||
agent({ id: 'a', state: 'working' }),
|
||||
agent({ id: 'b', state: 'working' }),
|
||||
agent({ id: 'c', state: 'failed' })
|
||||
])
|
||||
).toBe('Kicked off 3 subagents (1 failed)')
|
||||
expect(
|
||||
subagentGroupFallbackText([
|
||||
agent({ id: 'a', state: 'completed' }),
|
||||
agent({ id: 'b', state: 'stopped' })
|
||||
])
|
||||
).toBe('Ran 2 subagents (1 stopped)')
|
||||
})
|
||||
|
||||
// The sentence is frozen into a durable journal row and replayed on every
|
||||
// reconnect, to clients that draw no roster block and reconcile nothing. It
|
||||
// may therefore only state what a dead process still makes true: the group was
|
||||
// spawned, and whatever outcome had already latched. `Kicked off` vs `Ran`
|
||||
// reports whether an outcome was recorded yet, which is a write-time fact —
|
||||
// saying `Ran` while children were in flight would assert they exited.
|
||||
it('makes no liveness claim a replayed row could not still justify', () => {
|
||||
const inFlight = subagentGroupFallbackText([
|
||||
agent({ id: 'a', state: 'working' }),
|
||||
agent({ id: 'b', state: 'working' }),
|
||||
agent({ id: 'c', state: 'completed' })
|
||||
])
|
||||
|
||||
expect(inFlight).toBe('Kicked off 3 subagents')
|
||||
expect(inFlight).not.toMatch(/\bworking\b/)
|
||||
expect(
|
||||
subagentGroupFallbackText([
|
||||
agent({ id: 'a', state: 'working' }),
|
||||
agent({ id: 'b', state: 'unverifiable' })
|
||||
])
|
||||
).toBe('Kicked off 2 subagents (1 unverifiable)')
|
||||
})
|
||||
|
||||
it('stays quiet when nothing has gone wrong', () => {
|
||||
expect(subagentGroupFallbackText([agent({ id: 'a', state: 'working' })])).toBe(
|
||||
'Kicked off 1 subagent'
|
||||
)
|
||||
expect(subagentGroupFallbackText([agent({ id: 'a', state: 'completed' })])).toBe(
|
||||
'Ran 1 subagent'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Both readers decide "the twin is already printing" with this, so a false
|
||||
// positive silently eats a message's real prose and a false negative prints the
|
||||
// roster twice. The shape must outlive a byte compare: a roster from a newer
|
||||
// build names a state this build never produces.
|
||||
describe('isSubagentGroupFallbackText', () => {
|
||||
it('recognizes every sentence the producer writes, including an unknown state', () => {
|
||||
expect(isSubagentGroupFallbackText(subagentGroupFallbackText([agent({})]))).toBe(true)
|
||||
expect(
|
||||
isSubagentGroupFallbackText(
|
||||
subagentGroupFallbackText([agent({ id: 'a' }), agent({ id: 'b', state: 'failed' })])
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSubagentGroupFallbackText(
|
||||
subagentGroupFallbackText([agent({ id: 'a', state: 'completed' })])
|
||||
)
|
||||
).toBe(true)
|
||||
// Not reproducible here: this build normalizes `cancelled` to `unverifiable`.
|
||||
expect(isSubagentGroupFallbackText('Ran 2 subagents (1 cancelled)')).toBe(true)
|
||||
expect(isSubagentGroupFallbackText('Kicked off 4 subagents — 2 working (1 timed-out)')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
// Journals written before the twin dropped its live count still hold the old
|
||||
// sentence, and those rows replay forever. A pattern that stopped matching
|
||||
// them would print every one of those rosters twice — once as the block, once
|
||||
// as prose the reader meant to drop.
|
||||
it('still recognizes the legacy twin already frozen into existing journals', () => {
|
||||
for (const legacy of [
|
||||
'Kicked off 1 subagent — 1 working',
|
||||
'Kicked off 4 subagents — 2 working',
|
||||
'Kicked off 4 subagents — 2 working (1 failed)',
|
||||
'Kicked off 4 subagents — 2 working (1 timed-out)'
|
||||
]) {
|
||||
expect(isSubagentGroupFallbackText(legacy)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves prose that merely mentions subagents alone', () => {
|
||||
for (const prose of [
|
||||
'Handing the audit to two children.',
|
||||
'I kicked off 2 subagents to look at this',
|
||||
'Ran 2 subagents and then cleaned up',
|
||||
'Ran 2 subagents (1 failed) — see below',
|
||||
'Ran two subagents'
|
||||
]) {
|
||||
expect(isSubagentGroupFallbackText(prose)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
// One spawn group's roster → the numbers a single flat row needs.
|
||||
//
|
||||
// Shared because the producer and the desktop transcript must agree on what
|
||||
// "N working" means: the producer uses the same terminal predicate the renderer
|
||||
// does, so a state that reads terminal here latches terminal there. Mobile has
|
||||
// no roster renderer — it shows only the write-time-frozen fallback sentence,
|
||||
// which is why that sentence is built from this same summary, and why the
|
||||
// sentence itself may claim nothing that a later reader cannot still verify.
|
||||
|
||||
import {
|
||||
isSubagentGroupBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatSubagentEntry,
|
||||
type NativeChatSubagentGroupBlock,
|
||||
type NativeChatSubagentState
|
||||
} from './native-chat-types'
|
||||
|
||||
/** Every state a child cannot leave. `working` is the only in-flight state:
|
||||
* providers report several (started/interacted, pending/running/paused) and the
|
||||
* producer collapses them before the roster is written. */
|
||||
const TERMINAL_SUBAGENT_STATES: ReadonlySet<string> = new Set([
|
||||
'idle',
|
||||
'completed',
|
||||
'failed',
|
||||
'stopped',
|
||||
'unverifiable'
|
||||
])
|
||||
|
||||
/** Settled-state precedence for the group's one-line verdict: the worst
|
||||
* outcome wins, and `completed` only shows when nothing else is left. */
|
||||
const SETTLED_PRECEDENCE = ['failed', 'stopped', 'unverifiable', 'idle', 'completed'] as const
|
||||
|
||||
/** Outcomes that must be visible immediately, not held back until the last
|
||||
* sibling stops working: a fan-out with a dead child is not a neutral row. */
|
||||
const ADVERSE_PRECEDENCE = ['failed', 'stopped', 'unverifiable'] as const
|
||||
|
||||
/** A state this build does not know reads as `unverifiable`, never as working:
|
||||
* a roster written by a newer build must not leave the row spinning forever. */
|
||||
export function normalizeSubagentState(state: string): NativeChatSubagentState {
|
||||
if (state === 'working') {
|
||||
return 'working'
|
||||
}
|
||||
return TERMINAL_SUBAGENT_STATES.has(state) ? (state as NativeChatSubagentState) : 'unverifiable'
|
||||
}
|
||||
|
||||
/** Bound on the per-child provider strings a roster row carries — `id` and
|
||||
* `label`. One constant because the producer writes a durable row and both
|
||||
* readers clip it again: a larger producer bound is bytes every consumer throws
|
||||
* away, replayed on every reconnect.
|
||||
*
|
||||
* `groupId` is deliberately NOT bounded by the producer: the row's durable
|
||||
* identity is `codex-subagents:${groupId}` and cannot be clipped without
|
||||
* changing which row a replay finds, so bounding only the block field would
|
||||
* save nothing and make the two disagree. Both readers still clip it. */
|
||||
export const MAX_SUBAGENT_FIELD_CHARS = 512
|
||||
|
||||
export function isTerminalSubagentState(state: string): boolean {
|
||||
return normalizeSubagentState(state) !== 'working'
|
||||
}
|
||||
|
||||
/** The child's own verdict about itself. `unverifiable` is deliberately absent:
|
||||
* it records that we stopped being able to see the child, not what it did, so
|
||||
* a later authoritative report must still be able to correct it. */
|
||||
const LATCHED_SUBAGENT_STATES: ReadonlySet<string> = new Set([
|
||||
'idle',
|
||||
'completed',
|
||||
'failed',
|
||||
'stopped'
|
||||
])
|
||||
|
||||
/** Whether `next` may replace `current`.
|
||||
*
|
||||
* A child that reported its own outcome keeps it. A child we merely lost sight
|
||||
* of may still settle: the session sweep marks live children `unverifiable`,
|
||||
* and contact can return before the row is read — latching the sweep would
|
||||
* report a child that finished as one we never saw finish.
|
||||
* The reverse is refused: nothing returns to `working` once we have given up on
|
||||
* it, so a straggler progress tick cannot re-light a settled row. */
|
||||
export function canReplaceSubagentState(current: string, next: string): boolean {
|
||||
const from = normalizeSubagentState(current)
|
||||
if (from === 'working') {
|
||||
return true
|
||||
}
|
||||
if (LATCHED_SUBAGENT_STATES.has(from)) {
|
||||
return false
|
||||
}
|
||||
// `from` is `unverifiable`: only a real verdict may land.
|
||||
return LATCHED_SUBAGENT_STATES.has(normalizeSubagentState(next))
|
||||
}
|
||||
|
||||
export type NativeChatSubagentSummary = {
|
||||
total: number
|
||||
working: number
|
||||
/** The group's verdict once nothing is in flight; null while any child works. */
|
||||
settledState: NativeChatSubagentState | null
|
||||
/** How many children hold `settledState`. */
|
||||
settledCount: number
|
||||
/** Worst adverse outcome already recorded, reported even while siblings still
|
||||
* work. Null when nothing has gone wrong. */
|
||||
adverseState: NativeChatSubagentState | null
|
||||
/** How many children hold `adverseState`. */
|
||||
adverseCount: number
|
||||
/** Sum of the latest per-child totals. Null when no child reported one.
|
||||
* Children's counters are disjoint from the parent's, so this never
|
||||
* double-counts — and the parent's own usage is deliberately excluded. */
|
||||
tokens: number | null
|
||||
/** Earliest child start, for the live elapsed clock. */
|
||||
startedAt: number | null
|
||||
/** Latest terminal timestamp, once the group has settled. */
|
||||
settledAt: number | null
|
||||
}
|
||||
|
||||
export function summarizeSubagentGroup(
|
||||
agents: readonly NativeChatSubagentEntry[]
|
||||
): NativeChatSubagentSummary {
|
||||
const counts = new Map<NativeChatSubagentState, number>()
|
||||
let working = 0
|
||||
let tokens: number | null = null
|
||||
let startedAt: number | null = null
|
||||
let settledAt: number | null = null
|
||||
for (const agent of agents) {
|
||||
const state = normalizeSubagentState(agent.state)
|
||||
if (state === 'working') {
|
||||
working += 1
|
||||
} else {
|
||||
counts.set(state, (counts.get(state) ?? 0) + 1)
|
||||
}
|
||||
if (typeof agent.tokens === 'number' && Number.isFinite(agent.tokens)) {
|
||||
tokens = (tokens ?? 0) + agent.tokens
|
||||
}
|
||||
if (typeof agent.startedAt === 'number') {
|
||||
startedAt = startedAt === null ? agent.startedAt : Math.min(startedAt, agent.startedAt)
|
||||
}
|
||||
if (typeof agent.settledAt === 'number') {
|
||||
settledAt = settledAt === null ? agent.settledAt : Math.max(settledAt, agent.settledAt)
|
||||
}
|
||||
}
|
||||
const settledState =
|
||||
working > 0 ? null : (SETTLED_PRECEDENCE.find((state) => counts.has(state)) ?? null)
|
||||
const adverseState = ADVERSE_PRECEDENCE.find((state) => counts.has(state)) ?? null
|
||||
return {
|
||||
total: agents.length,
|
||||
working,
|
||||
settledState,
|
||||
settledCount: settledState === null ? 0 : (counts.get(settledState) ?? 0),
|
||||
adverseState,
|
||||
adverseCount: adverseState === null ? 0 : (counts.get(adverseState) ?? 0),
|
||||
tokens,
|
||||
startedAt,
|
||||
settledAt: working > 0 ? null : settledAt
|
||||
}
|
||||
}
|
||||
|
||||
/** A childless group draws nothing: `NativeChatSubagentRun` renders null for one,
|
||||
* so no caller may count it as renderable. The block schema admits `agents: []`
|
||||
* though no producer writes it, and a row that passes a renderable check while
|
||||
* drawing nothing still costs the transcript a gap slot. */
|
||||
export function isRenderableSubagentGroup(block: NativeChatSubagentGroupBlock): boolean {
|
||||
return block.agents.length > 0
|
||||
}
|
||||
|
||||
/** The spawn groups in `blocks` that will actually draw a row. */
|
||||
export function subagentGroupBlocks(
|
||||
blocks: readonly NativeChatBlock[]
|
||||
): NativeChatSubagentGroupBlock[] {
|
||||
return blocks.filter(
|
||||
(block): block is NativeChatSubagentGroupBlock =>
|
||||
isSubagentGroupBlock(block) && isRenderableSubagentGroup(block)
|
||||
)
|
||||
}
|
||||
|
||||
/** Plain-text stand-in for the roster, frozen into the journal at write time for
|
||||
* clients without the block type.
|
||||
*
|
||||
* It states only what stays true once the writing process is gone: the group was
|
||||
* spawned, and whatever outcome had already latched. It deliberately carries NO
|
||||
* live count. The row is durable and replayed on every reconnect, and the
|
||||
* clients that read this sentence instead of the block reconcile nothing and
|
||||
* cannot re-check the children — so a frozen `N working` would go on asserting
|
||||
* a liveness only the dead process could have observed. That is the collapse
|
||||
* `docs/reference/ssh-execution-boundary.md` forbids: loss of contact is not
|
||||
* evidence of a live state. Liveness stays with the structured block, which the
|
||||
* writing host revises in place for as long as it can see the children.
|
||||
*
|
||||
* `Kicked off` vs `Ran` is kept, and is not a liveness claim: it reports
|
||||
* whether an outcome had been recorded when the row was written. Saying `Ran`
|
||||
* while children were in flight would assert they exited, which is the same
|
||||
* error in the other direction.
|
||||
*
|
||||
* The adverse count stays: a reader that only ever sees this sentence must not
|
||||
* be told a failing fan-out is fine. */
|
||||
export function subagentGroupFallbackText(agents: readonly NativeChatSubagentEntry[]): string {
|
||||
const { total, working, adverseState, adverseCount } = summarizeSubagentGroup(agents)
|
||||
const noun = total === 1 ? 'subagent' : 'subagents'
|
||||
const adverse = adverseState === null ? '' : ` (${adverseCount} ${adverseState})`
|
||||
return `${working > 0 ? 'Kicked off' : 'Ran'} ${total} ${noun}${adverse}`
|
||||
}
|
||||
|
||||
/** Whether `text` is a roster block's frozen twin rather than ordinary prose.
|
||||
* Shape-matched, not recomputed: a roster written by a newer build can hold a
|
||||
* state this build normalizes to `unverifiable`, so its twin never equals the
|
||||
* sentence recomputed here — and a byte compare would then print the roster
|
||||
* twice.
|
||||
*
|
||||
* The `— N working` clause is LEGACY. The twin carried a live count only while
|
||||
* this feature was unreleased, so the rows holding one are dev journals of this
|
||||
* branch rather than anything shipped — but those replay forever too, and each
|
||||
* would print twice without this branch. It costs no false-positive surface the
|
||||
* bare shape does not already carry, so it stays until such journals no longer
|
||||
* matter. Keep in sync with `subagentGroupFallbackText`. */
|
||||
const SUBAGENT_GROUP_FALLBACK_PATTERN =
|
||||
/^(?:Kicked off \d+ subagents?(?: — \d+ working)?|Ran \d+ subagents?)(?: \(\d+ [a-z][a-z-]*\))?$/
|
||||
|
||||
export function isSubagentGroupFallbackText(text: string): boolean {
|
||||
return SUBAGENT_GROUP_FALLBACK_PATTERN.test(text)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
isSubagentGroupBlock,
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock,
|
||||
@@ -35,6 +36,13 @@ function isHarnessSidecarToolMessage(message: NativeChatMessage): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** The spawn-group roster row lands mid-turn, between the assistant's tool
|
||||
* calls. It is activity chrome, not a new turn, so it must not end the run the
|
||||
* following tool messages fold into. */
|
||||
function isSubagentRosterMessage(message: NativeChatMessage): boolean {
|
||||
return message.blocks.some(isSubagentGroupBlock)
|
||||
}
|
||||
|
||||
function isInterruptionBoundary(message: NativeChatMessage): boolean {
|
||||
return message.blocks.some(
|
||||
(block) =>
|
||||
@@ -104,7 +112,10 @@ export function foldToolMessages(messages: readonly NativeChatMessage[]): Native
|
||||
if (message.role === 'assistant') {
|
||||
mutableAssistantIndex = output.length - 1
|
||||
clonedAssistantIndex = -1
|
||||
} else if (!isNoiseMessage(message) || isInterruptionBoundary(message)) {
|
||||
} else if (
|
||||
!isSubagentRosterMessage(message) &&
|
||||
(!isNoiseMessage(message) || isInterruptionBoundary(message))
|
||||
) {
|
||||
mutableAssistantIndex = -1
|
||||
clonedAssistantIndex = -1
|
||||
}
|
||||
|
||||
@@ -91,11 +91,51 @@ export type NativeChatImageRefBlock = {
|
||||
alt?: string
|
||||
}
|
||||
|
||||
/** Lifecycle of one spawned child agent, as the display collapses it.
|
||||
* `unverifiable` is the repo's loss-of-contact verdict (see
|
||||
* docs/reference/ssh-execution-boundary.md): the child stopped reporting and
|
||||
* nothing proves it exited. Every in-flight provider state collapses to
|
||||
* `working`; `idle` is a child that exists but is not currently working. */
|
||||
export const NATIVE_CHAT_SUBAGENT_STATES = [
|
||||
'working',
|
||||
'idle',
|
||||
'completed',
|
||||
'failed',
|
||||
'stopped',
|
||||
'unverifiable'
|
||||
] as const
|
||||
export type NativeChatSubagentState = (typeof NATIVE_CHAT_SUBAGENT_STATES)[number]
|
||||
|
||||
/** One child agent in a spawn group. */
|
||||
export type NativeChatSubagentEntry = {
|
||||
/** Provider's child id (Codex: the child thread id). The roster key. */
|
||||
id: string
|
||||
/** Row label — the provider's task name, disambiguated by ordinal on collision. */
|
||||
label: string
|
||||
state: NativeChatSubagentState
|
||||
/** Latest total tokens the provider reported FOR THIS CHILD, never a running sum. */
|
||||
tokens?: number
|
||||
/** Epoch ms of the first event that created the entry. */
|
||||
startedAt?: number
|
||||
/** Epoch ms the entry latched terminal. */
|
||||
settledAt?: number
|
||||
}
|
||||
|
||||
/** One spawn group's roster, revised in place as its children report activity.
|
||||
* Provider-agnostic on purpose: the Codex and Claude lanes both feed this. */
|
||||
export type NativeChatSubagentGroupBlock = {
|
||||
type: 'subagent-group'
|
||||
/** Stable group key — the parent turn that spawned these children. */
|
||||
groupId: string
|
||||
agents: NativeChatSubagentEntry[]
|
||||
}
|
||||
|
||||
export type NativeChatBlock =
|
||||
| NativeChatTextBlock
|
||||
| NativeChatToolCallBlock
|
||||
| NativeChatToolResultBlock
|
||||
| NativeChatImageRefBlock
|
||||
| NativeChatSubagentGroupBlock
|
||||
|
||||
export type NativeChatMessage = {
|
||||
/** Stable across re-reads/appends so the assembler and the renderer list can
|
||||
@@ -179,3 +219,9 @@ export function isInterruptedStatusMessage(message: NativeChatMessage): boolean
|
||||
export function isImageRefBlock(block: NativeChatBlock): block is NativeChatImageRefBlock {
|
||||
return block.type === 'image-ref'
|
||||
}
|
||||
|
||||
export function isSubagentGroupBlock(
|
||||
block: NativeChatBlock
|
||||
): block is NativeChatSubagentGroupBlock {
|
||||
return block.type === 'subagent-group'
|
||||
}
|
||||
|
||||
@@ -6,10 +6,21 @@
|
||||
* copied: two renderings would let the two surfaces disagree about what a tool call looked like.
|
||||
*/
|
||||
|
||||
import {
|
||||
isSubagentGroupFallbackText,
|
||||
subagentGroupFallbackText
|
||||
} from './native-chat-subagent-summary'
|
||||
import type { NativeChatMessage } from './native-chat-types'
|
||||
|
||||
export function formatWorkerTranscriptMessage(message: NativeChatMessage): string {
|
||||
const blocks = message.blocks.map((block) => {
|
||||
// Every roster block is written beside a plain-text twin carrying the same
|
||||
// sentence, for clients that cannot draw the block. Text surfaces are those
|
||||
// clients, so they print the twin and drop the block. The renderer reaches the
|
||||
// same single print from the other side but not by the same rule: it drops
|
||||
// every fallback-shaped text block as soon as any group is present and draws
|
||||
// each group, so it never has to decide which twin belongs to which group.
|
||||
const standIns = claimSubagentGroupTwins(message.blocks)
|
||||
const blocks = message.blocks.map((block, index) => {
|
||||
if (block.type === 'text') {
|
||||
return block.text
|
||||
}
|
||||
@@ -19,9 +30,58 @@ export function formatWorkerTranscriptMessage(message: NativeChatMessage): strin
|
||||
if (block.type === 'tool-result') {
|
||||
return `[tool result${block.isError ? ' error' : ''}] ${block.output}`
|
||||
}
|
||||
return block.url ? `[image] ${block.url}` : `[image omitted]`
|
||||
if (block.type === 'image-ref') {
|
||||
return block.url ? `[image] ${block.url}` : `[image omitted]`
|
||||
}
|
||||
if (block.type === 'subagent-group') {
|
||||
return standIns.get(index) ?? null
|
||||
}
|
||||
// The journal deliberately admits block types this build does not know, and
|
||||
// a newer remote host can send one over the wire. Degrade to a marker rather
|
||||
// than reading fields off a shape that has none.
|
||||
return '[unsupported block]'
|
||||
})
|
||||
return `[${message.role}] ${blocks.join('\n')}`.trimEnd()
|
||||
return `[${message.role}] ${blocks.filter((line) => line !== null).join('\n')}`.trimEnd()
|
||||
}
|
||||
|
||||
/** For each roster block, the sentence it must print itself — absent when a twin
|
||||
* beside it already prints one.
|
||||
*
|
||||
* Exact-text claims are settled for EVERY group before any leftover twin is
|
||||
* claimed by position: claiming in block order let an earlier group consume a
|
||||
* later group's twin, silencing the earlier roster while the later one printed
|
||||
* twice. The positional fallback stays because a roster written by a newer build
|
||||
* holds a state this build reads as `unverifiable`, so its frozen twin can never
|
||||
* equal the sentence recomputed here and a text match alone would print it
|
||||
* twice. A group left with no twin prints its own: the wire admits a roster that
|
||||
* arrived without one, and dropping that would lose the sentence altogether. */
|
||||
function claimSubagentGroupTwins(blocks: NativeChatMessage['blocks']): Map<number, string> {
|
||||
const twins: string[] = []
|
||||
const groups: { index: number; sentence: string }[] = []
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type === 'text' && isSubagentGroupFallbackText(block.text)) {
|
||||
twins.push(block.text)
|
||||
} else if (block.type === 'subagent-group') {
|
||||
groups.push({ index, sentence: subagentGroupFallbackText(block.agents) })
|
||||
}
|
||||
})
|
||||
const standIns = new Map<number, string>()
|
||||
const unclaimed = groups.filter((group) => {
|
||||
const exact = twins.indexOf(group.sentence)
|
||||
if (exact === -1) {
|
||||
return true
|
||||
}
|
||||
twins.splice(exact, 1)
|
||||
return false
|
||||
})
|
||||
for (const group of unclaimed) {
|
||||
if (twins.length > 0) {
|
||||
twins.pop()
|
||||
continue
|
||||
}
|
||||
standIns.set(group.index, `[subagents] ${group.sentence}`)
|
||||
}
|
||||
return standIns
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
|
||||
Reference in New Issue
Block a user