Files
orca/src/shared/structured-agent-session-projection.test.ts
T
Brennan Benson c4ea14cd9f Show a tool call you stopped as interrupted, not failed (#25181)
* Move the turn message ordinals and the turn-row revision to the neutral timeline folder

Pure moves so a shared timeline assembler can use them: Codex's message
ordinal counter becomes ProviderTurnMessageOrdinals and Claude's turn-row
revision becomes the provider-neutral agent-journal turn-row revision. Only
names and import paths change.

* Admit one provider event's writes as one transition, and let rows be found again after a restart

- A sink transition is admitted whole or not at all; its steps run back to
  back at their turn in the journal's write queue, and each resolver reads
  the fold with every earlier write landed. A resolver may also say where the
  row belongs (turn scope, provider reference), and the writer always hears
  how the transition landed. A resolved lifecycle batch chooses its
  settlement mutations from the fold at execution.
- New optional row field providerItemRef: the provider's own reference for
  the item a row is, written only where the row's identity cannot spell it
  (Codex keys messages by their place in the turn and renumbers its item ids
  on resume). Set by the creating write, kept by revisions, indexed by the
  journal fold, never read by clients. A downgrade test shows an older host
  and client render such rows unchanged.
- Provider timeline identity schemes (shared legacy arm, Codex) and the join
  index that resolves a provider item to its row from memory or the fold:
  ordinals and request incarnations are read back from the rows, so a
  restart or an evicted entry finds the original row instead of placing a
  new one.

* Recover message ordinals from the journal's highest place, and forget joins read from a replaced epoch

A fresh join index continued a turn's messages at the first free place, so a journal holding only a
later ordinal (an imported or removed earlier row) had its sequence back-filled. The place is now
one past the highest ordinal any row or echoed send holds there, read through a pure scheme reader.
The join caches also drop what they read when the journal's epoch is replaced.

* Spell the subagent thread's message slot without spreading an identity union

* Add a provider timeline grammar and a shared assembler that decides at its turn in the journal

Adapters translate their provider's dialect into a small grammar (turns,
items, streamed text, requests, context facts, session end/reset); one shared
assembler turns it into the journal rows every structured lane writes.

Each event is planned as one sink transition. Which row a write lands on,
whether a replay writes anything, and every change to what the assembler
knows (its ledger) are decided by the transition's resolvers at the event's
turn in the journal's write queue, against the fold as it stands then. A
forecast (the ledger plus admitted events still queued) only answers apply()
at once. So a refused event allocates nothing, a write the journal rejects
leaves no trace in memory, and a restart or evicted cache finds the same rows
again. Text and full snapshots of one provider item share one row and one
lifecycle; reset always flushes text and settles the old session from the
journal; the open-work budget is derived from what is actually open.

Codex migration contracts compare against the existing Codex translator,
including a restart mid-stream and a repeat that outlives the join cache.

* Fix the types and the exhaustive event switch CI reported for the assembler

* Let the journal decide stream lifetimes, request reuse, named sends and background work

A third review found two blockers with the earlier rounds' cause, a remembered interpretation
trusted after the journal moved on:

- A reused request id was judged by its earlier prompt's settled turn before asking which turn the
  new one lands in, so a real approval in a later turn was dropped. The target turn now decides:
  the old turn again is a replay; a different live turn opens the next prompt beside it.
- A text stream checked its row's turn only on its first write, and a turn's end released streams
  by the turn planning expected. Every write now checks the row, a turn's end stops the streams
  whose rows are in it, and turn status reads the journal first, so another writer's Stop wins.

Also: a message boundary drawn by an event the journal held as a replay no longer splits an
anonymous message; a send naming a turn not yet open waits for that turn; the budget charges a
stream's thread and turn strings and the turn caches are byte-bounded; the open turn ends when the
journal shows it settled; a turn's opener is read from the journal's row.

Background work is now Orca's existing background-task row instead of a tool call flagged
`outlivesTurn` (a flag remembered only in memory, so a restart failed the task). A turn's end
never settles that row, so it survives restarts; session end leaves one in flight unverifiable.
Three tests that opened a background tool call with `outlivesTurn` now open a background-task row
and keep their original expectations about which turn the row stays in.

* Type the unbound assembler helper's drain as the void it reports

* Bound the rows kept for a continued anonymous message and the stopped streams

A row kept for the anonymous stream that may continue it, and the marker that a stopped stream's
queued writes write nothing, lived in the live-stream map and were never removed when no stream
followed. They now live in their own bounded maps, so the live map holds open streams only.

* Read a tool call a stop or the session's end cut short as interrupted, not failed

A call still running when its turn was interrupted, when the provider child
was seen to exit, or that the provider cancelled now carries `endedAs:
'interrupted'` beside `state: 'failed'`. Every build that predates the field
keeps reading the call as the failure it always showed; this build reads both
through one shared reader and counts the call apart from failures in the run
header, without the error tint on its partial output.

The shared assembler, the restart settlement and the Codex lane settle a
running call through one rule: only a proven interruption cuts it short, so a
turn the provider completed around an unclosed call, or work the host lost
track of, still reads failed.

* Assert a completed Codex turn leaves its unclosed call plainly failed

* Assert an unverifiable restart settlement leaves its running call plainly failed

* Let a later death proof correct the calls an unverifiable settle closed

A settle with no proof of the child's death closed its running calls as plain failed, so a
proof written afterwards corrected the turn to interrupted but could not find the calls. The
call now keeps endedAs: 'unverifiable' beside its failed state, and the stale settle revises
exactly those calls whose owner the proof names. Older builds still read them as failed.

* Render a cut-short call's output on mobile, with and without proof

* Find the mobile result box by walking to its View, and type the unverified-ending block

* Walk to the mobile result box without a type assertion

* Keep the journal store under its line limit after the main merge

* Drop the provider item reference, join index and identity schemes from the transition PR

Nothing in production reaches the state they defended (an assembler that lost its memory while
its child keeps streaming the same turn), and the stored Codex id was positional. The legacy
identity scheme moves to the assembler PR with its first caller; the Codex scheme and any
persisted reference wait for Codex to move onto the assembler. The Codex ordinal counter goes
back to codex/, since no neutral code imports it.

* Write a resolved settlement in one transaction through enqueueRows

A settlement too large for one row now commits all its rows or none, through the journal's
existing all-or-nothing write, instead of a row-by-row writer. Every row is built before any
commits, so a settlement naming one item twice is refused before anything is written.

* Drop the transition's landing report; keep the turn-row write fire-and-forget

Nothing reads which steps of a transition wrote. A failed step fails the sink, leaving the
steps before it written; the header says so, and tests cover it plus a settlement whose second
row fails inside the transaction. writeAgentJournalTurnRow returns nothing again, as on main.

* Run a transition's steps as a prefix; drop the paced flag and resolved options

A failed step no longer lets the steps after it write: each step checks, at its own turn in the
journal's queue, whether the write handed over just ahead of it completed, using the queue's count
of completed write bodies (a promise would report the failure only after the next step ran). The
sink fails only once every step has had its turn. The item step's `paced` size bypass and the
resolver's replacement `options` are removed; nothing planned uses them.

* Rebuild the timeline assembler on one admission-order state

The assembler kept a second copy of its state (a forecast beside a ledger), hydrated
the open turn from the journal, and recognised replays, all to recover from losing its
memory while the provider child kept streaming. That never happens: one assembler lives
exactly as long as one child, and a new child is a new assembler in a new generation
whose events land after the dead-generation sweep.

- One state, changed only when the sink admits an event (minted keys included), so a
  refused event takes nothing.
- Every journal-dependent choice is made when the write runs, by keyed reads: a turn row
  is written only where none is, a stream checks its row's turn on every write, a running
  snapshot never lands in a settled turn or relights a settled tool, a request takes the
  first incarnation the journal holds no row for.
- Rows are found by spelling their ids (provider-timeline-rows.ts); no join cache.
- The identity scheme (legacy arm only) lives here with its first caller; requests are
  spelled in their acquisition generation, since JSON-RPC ids restart per process.
- Saved history goes in as `input.history` plus ordinary events with the provider's ids,
  into an empty journal; `session.reset` and every replay rule are gone.
- One terminal-body function (`terminalAgentJournalBody`) is shared with the
  dead-generation settlement.
- The test rig's restart now sweeps and starts a new generation, as production does.

* Cover new running work in a turn the sweep ended

* Run a transition's steps in one queued write that loops over them

The steps of one event now share one turn in the journal's write queue: a
loop writes each in its own transaction through the row writer's
synchronous writeRows (split out of enqueueRows) and stops at the first
throw. Prefix semantics and "nothing lands between the steps" now hold by
construction, so the completed-write counter on the queue, the step gate
and the allSettled barrier are gone; the queue is back to main's bytes.

* End a turn another writer settled the way the provider's end does

A person's Stop settled the open turn's row without a word to the assembler.
The assembler then forgot the turn: its running tools and pending prompts were
never settled, the provider's own end and withdrawal were dropped, and the
turn's text streams stayed counted against the open budget for the life of the
process. Text the provider kept streaming afterwards could land as a message
outside the stopped turn.

- The open turn the journal shows settled ends first, as one transition, through
  the same settlement the provider's turn.end plans; its streams stop and their
  keys drop later text until that turn's end or the next turn opens.
- turn.end and request.withdrawn are admitted for a turn or request the journal
  holds; their settlement writes nothing for rows already settled.
- The budget's re-check frees streams whose turn settled.
- A settled tool keeps its terminal body against any differing write.
- Session-end settlement of lost background work uses the journal's own
  lostLiveWorkJournalBody instead of a copy.
- The rig's window elapses before every read, and restart swaps and disposes
  the old assembler.

* Leave a stopped turn's running tools to the agent's own end

When another writer settles the open turn (a person's Stop), the assembler
now only stops that turn's text and cancels its pending prompts. Running tool
calls stay the agent's: a progress update or completion it reports after the
Stop lands as reported, and whatever is still running settles at the agent's
turn end for that turn, the next turn's open, or the session's end.

An agent's end for an earlier turn while a newer one is open no longer clears
the open turn's activity line or ends its anonymous reply. An unnamed end right
after a Stop ends the stopped turn instead of being dropped. The test rig's
restart no longer writes the dead assembler's window text, matching dispose.

* Pin that a stopped turn's running tools hold budget until the agent's end

* Type the stopped turn's tool progress update as a tool body

* List every event the assembler hands to the decision step

The type-aware lint requires an exhaustive switch with no default case.
Also retitle a Stop test to say what it asserts.

* End a running call as its turn's journal row ends

A call still running when its turn ends takes the state of that turn's
row: a row another writer settled first (a person's Stop) stands, so its
calls read interrupted whatever the provider's later end reports. The
no-ending path that settled calls from the Stop row is gone, since a Stop
now leaves running calls to the provider. Adds the two Spanish strings.

* Settle a stopped turn's running call as its turn row ended after a restart too

The restart sweep ended every running call by the death evidence alone, so after
a person's Stop with no proof the child died the call read failed under a turn
that read interrupted. The sweep and the live dead-generation settlement now ask
the same rule the assembler does: a call in a turn already settled ends as that
row ended; only a turn still running leaves its calls to the evidence.

* Keep the dead-generation settlement under the line cap

* refactor(native-chat): drop saved-history adoption from the timeline assembler

The common pattern discards the history a provider replays while loading a
session, so the assembler has no use for an input.history event.

* refactor(native-chat): a pending input is only Orca's send now

Review follow-up to the adoption removal: drop the comment naming the
provider's saved message, and make requestedAt required since every pending
input comes from input.accepted.

* test(ratchet): require src/main/provider-process now that it has landed

* test(native-chat): build this stack's journal identities with main's opaque provider handle

Main's #24991 replaced the {kind, ...} handle with {transport, agent, nativeId}; three test
files from this stack still wrote the old shape. Same lines the downstream ACP branch uses.
2026-10-06 00:40:47 -07:00

888 lines
30 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
import { agentSessionFailureWords } from './agent-session-failure-words'
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
import { AgentJournalRenderItemSchema } from './agent-session-journal-schemas'
import { parsePaneKey } from './stable-pane-id'
import {
activeStructuredAgentSessionTurnId,
hasUnansweredStructuredAgentSessionDispatch,
projectStructuredItemToNativeChat,
projectStructuredItemsToNativeChat,
projectStructuredAgentSessionStatus,
projectStructuredAgentSessionStatusState,
projectStructuredAgentSessionStatusSummary,
structuredAgentSessionPaneKey
} from './structured-agent-session-projection'
import { statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn'
import { latestStructuredAgentSessionAssistantMessage } from './structured-agent-session-latest-request'
function item(
itemId: string,
sequence: number,
body: AgentJournalRenderItem['body']
): AgentJournalRenderItem {
return { itemId, sequence, revision: 1, observedAt: sequence, body }
}
function submission(
clientMessageId: string,
dispatchState: AgentJournalSubmission['dispatchState']
): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState,
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: dispatchState === 'pending' ? null : 2
}
}
describe('structured agent session status projection', () => {
it('reuses immutable item projections and refreshes revisions and resolved prompts', () => {
const original = item('diff', 1, {
kind: 'diff',
path: 'a.ts',
patch: {
head: '@@\n+first',
digest: 'one',
byteLength: 10,
truncated: false
}
})
const first = projectStructuredItemToNativeChat(original)
expect(projectStructuredItemToNativeChat(original)).toBe(first)
const revised = {
...original,
revision: 2,
observedAt: 2000,
body: {
kind: 'diff' as const,
path: 'a.ts',
patch: {
head: '@@\n+second',
digest: 'two',
byteLength: 11,
truncated: false
}
}
}
const second = projectStructuredItemToNativeChat(revised)
expect(second).not.toBe(first)
expect(second).toMatchObject({
timestamp: 2000,
blocks: [{ type: 'tool-call' }, { type: 'tool-result', output: '@@\n+second' }]
})
expect(second?.blocks).toEqual([
{ type: 'tool-call', name: 'Diff', input: { path: 'a.ts' } },
{ type: 'tool-result', output: '@@\n+second' }
])
const pending = item('approval', 2, {
kind: 'approval',
title: 'Allow?',
detail: null,
options: [],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
})
expect(projectStructuredItemToNativeChat(pending)).toBeNull()
if (pending.body.kind !== 'approval') {
throw new Error('fixture')
}
const resolved = {
...pending,
revision: 2,
body: {
...pending.body,
resolution: { ...pending.body.resolution, state: 'resolved' as const }
}
}
expect(projectStructuredItemToNativeChat(resolved)).toMatchObject({
id: 'approval',
role: 'system'
})
})
it("forwards a status row's failure fact and drops one this build cannot place", () => {
const words = agentSessionFailureWords(
{
kind: 'providerExited',
detail: { text: 'stderr tail', audience: 'log' }
},
{ surface: 'row' }
)
expect(
projectStructuredItemToNativeChat(item('exit', 1, { kind: 'status', ...words }))?.blocks[0]
).toEqual({ type: 'text', ...words })
const future = item('future', 2, { kind: 'status', text: 'Stopped.' })
// A newer host's kind reads as no fact, so the row keeps its text and nothing else.
Object.assign(future.body, { failure: { kind: 'futureKind' } })
expect(projectStructuredItemToNativeChat(future)?.blocks[0]).toEqual({
type: 'text',
text: 'Stopped.'
})
})
it('names the call a tool row output answers, so a run pairs it by id', () => {
const projected = projectStructuredItemToNativeChat(
item('wait', 1, {
kind: 'tool-call',
name: 'wait_agent',
callId: 'call-wait',
input: null,
state: 'completed',
output: { head: 'CHILD_REPLY', digest: 'd', byteLength: 11, truncated: false }
})
)
expect(projected?.blocks).toEqual([
expect.objectContaining({ type: 'tool-call', callId: 'call-wait' }),
{ type: 'tool-result', output: 'CHILD_REPLY', isError: false, callId: 'call-wait' }
])
})
it('carries a cut-short call through the schema, and its partial output is not an error', () => {
const body = {
kind: 'tool-call' as const,
name: 'shell',
callId: 'call-sleep',
input: { command: 'sleep 20' },
state: 'failed' as const,
endedAs: 'interrupted' as const,
output: { head: 'partial', digest: 'd', byteLength: 7, truncated: false }
}
const parsed = AgentJournalRenderItemSchema.parse(item('sleep', 1, body))
expect(parsed.body).toMatchObject({ state: 'failed', endedAs: 'interrupted' })
expect(projectStructuredItemToNativeChat(item('sleep', 1, body))?.blocks).toEqual([
expect.objectContaining({ type: 'tool-call', state: 'failed', endedAs: 'interrupted' }),
{ type: 'tool-result', output: 'partial', isError: false, callId: 'call-sleep' }
])
})
it('projects running, attention, and completed lifecycle states', () => {
const running = item('running', 1, {
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
})
const prompt = item('prompt', 2, {
kind: 'approval',
title: 'Run command?',
detail: null,
options: [{ id: 'yes', label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
})
const completed = item('completed', 3, {
kind: 'status',
text: 'Done',
turnLifecycle: { turnId: 'turn-1', state: 'completed' }
})
expect(activeStructuredAgentSessionTurnId([running])).toBe('turn-1')
expect(projectStructuredAgentSessionStatus([running])).toBe('working')
expect(projectStructuredAgentSessionStatus([running, prompt])).toBe('attention')
expect(activeStructuredAgentSessionTurnId([running, completed])).toBeNull()
expect(projectStructuredAgentSessionStatus([running, completed])).toBe('idle')
})
it('summarizes status with the newest user prompt, and null before any request', () => {
const running = item('running', 3, {
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
})
const first = item('first', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'first' }]
})
const second = item('second', 2, {
kind: 'message',
role: 'user',
blocks: [
{ type: 'text', text: 'second' },
{ type: 'text', text: 'line' }
]
})
expect(projectStructuredAgentSessionStatusSummary([])).toEqual({
status: null,
latestPrompt: ''
})
// A turn the provider opened on its own is a request, with no prompt to quote.
expect(projectStructuredAgentSessionStatusSummary([running])).toEqual({
status: 'working',
latestPrompt: '',
statusStartedAt: 3
})
expect(projectStructuredAgentSessionStatusSummary([first, second, running])).toEqual({
status: 'working',
latestPrompt: 'second line',
statusStartedAt: 3
})
expect(projectStructuredAgentSessionStatusSummary([first, second])).toEqual({
status: 'idle',
latestPrompt: 'second line'
})
})
it('reads a session as working while a dispatch is unanswered, before any lifecycle row', () => {
const asked = item('asked', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
const pending = [submission('m1', 'pending')]
expect(hasUnansweredStructuredAgentSessionDispatch(pending)).toBe(true)
expect(projectStructuredAgentSessionStatus([asked], pending)).toBe('working')
// The first send has no journalled message until the provider replays it.
expect(projectStructuredAgentSessionStatusSummary([], pending)).toEqual({
status: 'working',
latestPrompt: '',
statusStartedAt: 1
})
expect(projectStructuredAgentSessionStatusSummary([asked], pending)).toEqual({
status: 'working',
latestPrompt: 'go',
statusStartedAt: 1
})
})
it('does not resurrect old-host unknown work after its execution fence advances', () => {
const oldHostSubmission = { ...submission('m1', 'unknown'), fence: 2 }
expect(hasUnansweredStructuredAgentSessionDispatch([oldHostSubmission], 2)).toBe(true)
expect(hasUnansweredStructuredAgentSessionDispatch([oldHostSubmission], 3)).toBe(false)
})
it('recognizes recovery from an older host without the optional marker', () => {
expect(
hasUnansweredStructuredAgentSessionDispatch([
{ ...submission('m1', 'unknown'), reason: 'host_restarted_before_acknowledgement' }
])
).toBe(false)
})
it('stops reading a resolved dispatch as work, and lets a pending prompt outrank it', () => {
const asked = item('asked', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
const prompt = item('prompt', 2, {
kind: 'approval',
title: 'Run command?',
detail: null,
options: [{ id: 'yes', label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
})
for (const state of ['accepted', 'rejected'] as const) {
expect(hasUnansweredStructuredAgentSessionDispatch([submission('m1', state)])).toBe(false)
expect(projectStructuredAgentSessionStatus([asked], [submission('m1', state)])).toBe('idle')
}
// The ack budget elapsing is a delivery answer, not an answer about the turn.
expect(hasUnansweredStructuredAgentSessionDispatch([submission('m1', 'unknown')])).toBe(true)
expect(
hasUnansweredStructuredAgentSessionDispatch([
{ ...submission('m1', 'unknown'), recovered: true }
])
).toBe(false)
expect(
projectStructuredAgentSessionStatus([asked, prompt], [submission('m1', 'pending')])
).toBe('attention')
expect(projectStructuredAgentSessionStatusSummary([], [])).toEqual({
status: null,
latestPrompt: ''
})
})
it('still reports owed work beneath a pending prompt, which the attention status hides', () => {
const asked = item('asked', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
const prompt = item('prompt', 3, {
kind: 'approval',
title: 'Run command?',
detail: null,
options: [{ id: 'yes', label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
})
const running = item('turn', 2, { kind: 'turn', turnId: 't1', state: 'running' })
const settled = item('turn', 2, {
kind: 'turn',
turnId: 't1',
state: 'completed',
outcome: 'success'
})
const accepted = [submission('m1', 'accepted')]
const owes = (items: AgentJournalRenderItem[], submissions = accepted) => {
const state = projectStructuredAgentSessionStatusState(items, submissions)
return [state.summary.status, state.owesWork]
}
expect(owes([asked, running, prompt])).toEqual(['attention', true])
expect(owes([asked, settled, prompt])).toEqual(['attention', false])
expect(owes([asked, settled, prompt], [...accepted, submission('m2', 'pending')])).toEqual([
'attention',
true
])
expect(owes([asked, running])).toEqual(['working', true])
expect(owes([asked, settled])).toEqual(['idle', false])
})
it('carries the running tool and the newest assistant prose the sidebar row shows', () => {
const ask = item('ask', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'look at the sidebar' }]
})
const running = item('running', 2, {
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
})
const said = item('said', 3, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Reading the card first.' }]
})
const tool = item('tool', 4, {
kind: 'tool-call',
name: 'Read',
input: { file_path: '/repo/src/WorktreeCard.tsx' },
state: 'running'
})
expect(projectStructuredAgentSessionStatusSummary([ask, running, said, tool])).toEqual({
status: 'working',
latestPrompt: 'look at the sidebar',
toolName: 'Read',
toolInput: '/repo/src/WorktreeCard.tsx',
lastAssistantMessage: 'Reading the card first.',
statusStartedAt: 2
})
})
it('clears the previous answer as soon as the next prompt is persisted', () => {
const firstAsk = item('first-ask', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'first task' }]
})
const previousAnswer = item('previous-answer', 2, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'The first task is done.' }]
})
const nextAsk = item('next-ask', 3, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'second task' }]
})
expect(projectStructuredAgentSessionStatusSummary([firstAsk, previousAnswer, nextAsk])).toEqual(
{
status: 'idle',
latestPrompt: 'second task'
}
)
})
it('reports no tool line once the turn settles, even with an abandoned running call', () => {
const ask = item('ask', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
const abandoned = item('abandoned', 2, {
kind: 'tool-call',
name: 'Bash',
input: { command: 'sleep 600' },
state: 'running'
})
expect(projectStructuredAgentSessionStatusSummary([ask, abandoned])).toEqual({
status: 'idle',
latestPrompt: 'go'
})
})
it('never adopts a running call from a turn older than the live one', () => {
const ask = item('ask', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
const abandoned = item('abandoned', 2, {
kind: 'tool-call',
name: 'Bash',
input: { command: 'sleep 600' },
state: 'running'
})
const running = item('running', 3, {
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-2', state: 'running' }
})
expect(projectStructuredAgentSessionStatusSummary([ask, abandoned, running])).toEqual({
status: 'working',
latestPrompt: 'go',
statusStartedAt: 3
})
})
it('skips a tool-only assistant item to reach the newest prose', () => {
const ask = item('ask', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
const said = item('said', 2, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Done — the card now aligns.' }]
})
const wordless = item('wordless', 3, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'tool-call', name: 'Read', input: {} }]
})
expect(
projectStructuredAgentSessionStatusSummary([ask, said, wordless]).lastAssistantMessage
).toBe('Done — the card now aligns.')
})
it('bounds the assistant preview at the shared agent-status preview cap', () => {
const ask = item('ask', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
const rambled = item('rambled', 2, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'y'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH * 40) }]
})
expect(
projectStructuredAgentSessionStatusSummary([ask, rambled]).lastAssistantMessage
).toHaveLength(AGENT_STATUS_MAX_FIELD_LENGTH)
})
it('bounds the wire prompt at the shared agent-status preview cap', () => {
const pasted = item('pasted', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH * 40) }]
})
expect(projectStructuredAgentSessionStatusSummary([pasted]).latestPrompt).toHaveLength(
AGENT_STATUS_MAX_FIELD_LENGTH
)
})
it('creates a deterministic pane identity for status stores', () => {
const paneKey = structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1')
expect(structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1')).toBe(paneKey)
expect(parsePaneKey(paneKey)).toMatchObject({ tabId: 'structured-agent-session-1' })
})
it('preserves provider-frame detail on the backward-compatible status line', () => {
const projected = projectStructuredItemToNativeChat(
item('frame', 1, {
kind: 'status',
text: 'codex · notification:new/event',
providerFrame: {
provider: 'codex',
kind: 'notification:new/event',
payload: { head: '{}', byteLength: 2, digest: 'digest', truncated: false }
}
})
)
expect(projected?.blocks).toEqual([
expect.objectContaining({
type: 'text',
text: 'codex · notification:new/event',
providerFrame: expect.objectContaining({ kind: 'notification:new/event' })
})
])
})
it('preserves structured tool lifecycle state for the live renderer', () => {
const projected = projectStructuredItemToNativeChat(
item('running-tool', 1, {
kind: 'tool-call',
name: 'shell',
input: { command: 'cat package.json' },
state: 'running'
})
)
expect(projected?.blocks).toEqual([
{ type: 'tool-call', name: 'shell', input: { command: 'cat package.json' }, state: 'running' }
])
})
it('projects a turn record to no message', () => {
const turn = item('turn', 1, {
kind: 'turn',
turnId: 't1',
state: 'completed',
startedAt: 1_000,
completedAt: 4_000
})
expect(projectStructuredItemToNativeChat(turn)).toBeNull()
})
it('projects an item kind this build does not know to no message, never a text bubble', () => {
const unknown = item('future', 1, {
kind: 'future-kind',
text: 'a newer host wrote this'
} as unknown as AgentJournalRenderItem['body'])
expect(projectStructuredItemToNativeChat(unknown)).toBeNull()
})
})
describe('notice projection for desktop and mobile consumers', () => {
it.each([
{ presentation: 'compaction' },
{ presentation: 'plan-document' },
{ tone: 'warning' },
{ tone: 'error' },
{ tone: 'notice' },
{ presentation: 'future-presentation', tone: 'future-tone' }
])('preserves readable text alongside optional metadata: %j', (metadata) => {
const projected = projectStructuredItemToNativeChat(
item('notice', 1, {
kind: 'status',
text: 'A readable document or notice',
...metadata
})
)
expect(projected).toMatchObject({
role: 'system',
blocks: [{ type: 'text', text: 'A readable document or notice', ...metadata }]
})
})
})
it('preserves optional tool annotations for desktop and mobile projection', () => {
const metadata = {
callId: 'call-1',
exitCode: 127,
durationMs: 400,
webSearchResults: [{ title: 'Docs', url: 'https://example.com' }]
}
const projected = projectStructuredItemToNativeChat(
item('annotated', 1, {
kind: 'tool-call',
name: 'shell',
input: null,
state: 'failed',
...metadata
})
)
expect(projected?.blocks[0]).toEqual({
type: 'tool-call',
name: 'shell',
input: null,
state: 'failed',
...metadata
})
})
it('preserves confirmed MCP identity and the raw name through projection', () => {
const body = {
kind: 'tool-call' as const,
name: 'my_server/ns.tool',
input: null,
state: 'running' as const,
mcpIdentity: { server: 'my_server', tool: 'ns.tool' }
}
const projected = projectStructuredItemToNativeChat(item('mcp', 1, body))
expect(projected?.blocks[0]).toMatchObject({
name: body.name,
mcpIdentity: body.mcpIdentity,
type: 'tool-call'
})
})
describe("producer linkage — a subagent's output never speaks for the parent", () => {
/** A row a subagent produced. Same journal, same session; only linkage differs. */
function childItem(
itemId: string,
sequence: number,
body: AgentJournalRenderItem['body'],
agentId = 'task-1'
): AgentJournalRenderItem {
return { ...item(itemId, sequence, body), agentId, producerKind: 'agent' }
}
const userAsk = item('user-1', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'summarise the repo' }]
})
const turnRunning = item('turn-1', 2, { kind: 'turn', turnId: 'turn-1', state: 'running' })
const parentProse = item('root-prose', 3, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'delegating' }]
})
const spawnCall = item('root-task', 4, {
kind: 'tool-call',
name: 'Task',
input: { description: 'explore the lane' },
state: 'running'
})
const childProse = childItem('child-prose', 5, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'looking' }]
})
const childCall = childItem('child-grep', 6, {
kind: 'tool-call',
name: 'Grep',
input: { pattern: 'x' },
state: 'running'
})
const items = [userAsk, turnRunning, parentProse, spawnCall, childProse, childCall]
it("shows the parent's own prose and its own running call, not the child's newer ones", () => {
expect(latestStructuredAgentSessionAssistantMessage(items)).toBe('delegating')
expect(statusStructuredAgentSessionToolCall(items)?.name).toBe('Task')
})
it("publishes the parent's own line and call on the summary the sidebar reads", () => {
const summary = projectStructuredAgentSessionStatusSummary(items)
expect(summary.status).toBe('working')
expect(summary.lastAssistantMessage).toBe('delegating')
expect(summary.toolName).toBe('Task')
// The row does not go blank while a child runs: the spawn call is still the
// parent's own live work.
expect(summary.toolInput).toBeTruthy()
})
it("names the parent's own settled call, not a child's newer settled one", () => {
const parentRead = item('root-read', 4, {
kind: 'tool-call',
name: 'Read',
input: { file_path: '/repo/a.ts' },
state: 'completed'
})
const childFailed = childItem('child-grep', 6, {
kind: 'tool-call',
name: 'Grep',
input: { pattern: 'x' },
state: 'failed'
})
expect(
projectStructuredAgentSessionStatusSummary([userAsk, turnRunning, parentRead, childFailed])
.toolName
).toBe('Read')
})
it("still renders the child's output in the transcript", () => {
// The other direction: scoping the STATUS readers must not delete subagent
// output from the chat.
const prose = projectStructuredItemsToNativeChat(items).flatMap((message) =>
message.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : []))
)
expect(prose).toContain('looking')
expect(prose).toContain('delegating')
})
it("keeps the child's output as the child's: the transcript message names its producer", () => {
// Rendering the child's rows is not enough; unless the message still says who
// wrote it, the transcript can only present it as the parent speaking.
const messages = projectStructuredItemsToNativeChat(items)
const byId = new Map(messages.map((message) => [message.id, message]))
expect(byId.get('child-prose')).toMatchObject({ agentId: 'task-1', producerKind: 'agent' })
expect(byId.get('child-grep')).toMatchObject({ agentId: 'task-1' })
// The session's own rows name no producer: absence is the claim that they are its own.
expect(byId.get('root-prose')).not.toHaveProperty('agentId')
expect(byId.get('root-task')).not.toHaveProperty('agentId')
})
it("falls back to nothing rather than a child's line when the parent said nothing", () => {
const summary = projectStructuredAgentSessionStatusSummary([
userAsk,
turnRunning,
spawnCall,
childProse,
childCall
])
expect(summary.lastAssistantMessage).toBeUndefined()
expect(summary.toolName).toBe('Task')
})
it('attributes rows without reading their neighbours', () => {
// A window holding only the child's own rows: no spawn call, no turn record.
// Nothing here is re-derived from a start row, so attribution does not
// depend on how much of the timeline a reader happens to hold. (This store
// has no compaction and paginates complete-or-reset, so such a window is not
// reachable today — the point is that the rule does not rely on that.)
const windowed = [childProse, childCall]
expect(latestStructuredAgentSessionAssistantMessage(windowed)).toBe('')
expect(statusStructuredAgentSessionToolCall(windowed)).toBeNull()
})
it("does not quote a subagent's own user-role prompt as the session's", () => {
const childPrompt = childItem('child-prompt', 5, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'explore the lane' }]
})
expect(
projectStructuredAgentSessionStatusSummary([userAsk, turnRunning, spawnCall, childPrompt])
.latestPrompt
).toBe('summarise the repo')
})
it('keeps a nested child off the parent, read through the projection', () => {
// A grandchild: its own agent id, and a parent that is not the session root.
const grandchild: AgentJournalRenderItem = {
...item('grandchild-prose', 7, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'deeper' }]
}),
agentId: 'task-2',
parentAgentId: 'task-1',
producerKind: 'agent'
}
const nested = [...items, grandchild]
expect(latestStructuredAgentSessionAssistantMessage(nested)).toBe('delegating')
// Naming a parent does not make the row that parent's: the summary the
// sidebar reads still shows the session's own line.
expect(projectStructuredAgentSessionStatusSummary(nested).lastAssistantMessage).toBe(
'delegating'
)
// And the transcript still renders it, so naming a parent is not a filter.
expect(
projectStructuredItemsToNativeChat(nested).some((block) =>
JSON.stringify(block).includes('deeper')
)
).toBe(true)
})
it('treats an agent id that failed to resolve as a child rather than as the parent', () => {
const unresolved = childItem(
'child-unresolved',
5,
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'looking' }] },
''
)
expect(
latestStructuredAgentSessionAssistantMessage([userAsk, turnRunning, parentProse, unresolved])
).toBe('delegating')
})
it("reads a row written before linkage existed as the parent's own", () => {
// A journal open across the upgrade has unmarked child rows below marked
// ones. Absence means root, which reproduces exactly what those journals
// always showed — it is never "unknown".
const legacyChildProse = item('legacy-child', 5, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'legacy child line' }]
})
expect(
latestStructuredAgentSessionAssistantMessage([
userAsk,
turnRunning,
parentProse,
spawnCall,
legacyChildProse,
childProse
])
).toBe('legacy child line')
})
})
describe('the turn verdict on the status summary', () => {
const user = item('u1', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
it('carries the newest settled turn verdict only while the session is idle', () => {
const running = item('turn-running', 2, {
kind: 'turn',
turnId: 'turn-1',
state: 'running'
})
expect(projectStructuredAgentSessionStatusSummary([user, running])).not.toHaveProperty(
'turnOutcome'
)
const cancelled = item('turn-cancelled', 3, {
kind: 'turn',
turnId: 'turn-1',
state: 'interrupted',
outcome: 'cancellation'
})
expect(projectStructuredAgentSessionStatusSummary([user, cancelled])).toMatchObject({
status: 'idle',
turnOutcome: 'cancellation'
})
})
it('reports no verdict for a completed turn the provider never judged', () => {
const completed = item('turn-completed', 2, {
kind: 'turn',
turnId: 'turn-1',
state: 'completed'
})
expect(projectStructuredAgentSessionStatusSummary([user, completed])).not.toHaveProperty(
'turnOutcome'
)
})
// With no verdict from the provider, the lifecycle the host settled is what the row reports.
it.each([
['interrupted', 'interruption'],
['unverifiable', 'unconfirmed']
] as const)(
'reads an %s turn the provider never judged as its host-observed end',
(state, outcome) => {
const ended = item('turn-ended', 2, { kind: 'turn', turnId: 'turn-1', state })
expect(projectStructuredAgentSessionStatusSummary([user, ended])).toMatchObject({
status: 'idle',
turnOutcome: outcome
})
}
)
it("keeps the provider's own verdict over what the host observed of the end", () => {
for (const outcome of ['cancellation', 'failure', 'success'] as const) {
const judged = item('turn-judged', 2, {
kind: 'turn',
turnId: 'turn-1',
state: 'interrupted',
outcome
})
expect(projectStructuredAgentSessionStatusSummary([user, judged])).toMatchObject({
turnOutcome: outcome
})
}
})
it('keeps the observed end off the request the completion feed announces', () => {
const ended = item('turn-ended', 2, { kind: 'turn', turnId: 'turn-1', state: 'interrupted' })
expect(projectStructuredAgentSessionStatusState([user, ended]).latestRequest).toMatchObject({
turnState: 'interrupted',
outcome: null
})
})
})