Files
orca/src/shared/structured-agent-session-projection.test.ts
T
Brennan Benson e8e144bf3c fix(native-chat): a subagent's words are presented as that subagent's, never the parent's (#23605)
* fix(native-chat): a subagent's words are presented as that subagent's, never the parent's

The journal already names the agent that produced every row, but the transcript
projection dropped it, so a subagent's prose rendered as the parent's reply, its
tool calls folded into the parent's runs, and a settled turn could fold down to
a subagent's words as its only visible answer.

The transcript message now keeps the row's producer. The fold keeps each agent's
calls in that agent's own run, a turn's answer is the session's own agent's last
prose, and a subagent's row names the subagent on desktop, mobile and a worker's
transcript text.

* test(native-chat): give the window fixture's slot the attribution field it now carries

* fix(mobile): read the subagent label the row is given, and pin the caption

* fix(native-chat): keep interleaved agents in order and each agent's own run live

Review follow-ups:
- the fold is main's adjacency fold plus one condition: a row never folds into
  another agent's run, so an agent's later call stays below its subagent's work
  instead of jumping back into its earlier row
- each agent has its own live frontier, so a parent still inside its spawn call
  reads as running while its subagent works below it
- mobile names no one on a row whose only content is hidden behind its settled turn
- a pending question from a subagent keeps its producer
- worker reads serve only the producing agent's id, bounded like the roster key
  that names it, and drop the provenance fields
- the single-message worker formatter is private, so no caller can drop names
2026-09-28 15:28:01 -07:00

815 lines
27 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 { parsePaneKey } from './stable-pane-id'
import {
activeStructuredAgentSessionTurnId,
hasUnansweredStructuredAgentSessionDispatch,
projectStructuredItemToNativeChat,
projectStructuredItemsToNativeChat,
latestStructuredAgentSessionAssistantMessage,
projectStructuredAgentSessionStatus,
projectStructuredAgentSessionStatusState,
projectStructuredAgentSessionStatusSummary,
structuredAgentSessionPaneKey
} from './structured-agent-session-projection'
import { statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn'
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('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 settled 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'
)
})
})