From 33ba1ff3df247652c546985201d9a6f4edaec80b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:31:43 -0400 Subject: [PATCH] fix(native-chat): stop a subagent's output speaking for the agent that spawned it (#21398) * docs(attr-parent-label): record the attribution defect and its constraints * docs(attr-parent-label): add reference findings and the feasibility fact * docs(attr-parent-label): verify at source and decide the attribution mechanism Re-baselined against origin/main (one unrelated commit; no drift in any cited file). Confirmed the two unverified items at source, found a third reader with the same defect and a fourth append path a naive fix would miss, and recorded the producer-attribution decision with its field shape, migration behaviour, wire category, tests and implementation order. * fix(native-chat): stop a subagent's output speaking for the agent that spawned it One journal is the durable record of one agent session, but a session that runs subagents journals their rows into it too, with nothing on the row saying which agent wrote it. Every "what is this agent doing right now" reader is a backward scan bounded by markers only the root agent writes, so the window is guaranteed to hold foreign rows and, while a subagent runs, the newest row in it is the child's. The sidebar therefore showed a child's prose and a child's running tool on the parent's row. Attribute at the producer instead of guessing at the reader. The Claude translator already parses `parent_tool_use_id` on every envelope and threw it away; it now stamps `producedBySubagent` on every row that envelope produces, including the streamed-text path, which persists from a callback with no envelope in scope and takes the flag from the block identity registry that already scopes itself on that id. The three status readers skip non-root rows through one shared predicate. The transcript is deliberately left unscoped: it shows every agent's output. No schema version bump, no upcaster, no backfill. An unknown `v` makes a row unreadable and latches the host read-only, while an unknown key is ignored, so an older host reads a stamped row and behaves exactly as it does today. Rows written before the flag read as root, which reproduces today's behaviour for that history exactly. * docs(attr-parent-label): add the PR body for the producer-attribution change * style(native-chat): apply formatter to the merge resolution * fix(native-chat): preserve producer attribution in resolved appends * chore: keep attribution review artifacts under docs * chore: remove review artifacts --- src/main/claude/claude-message-journaling.ts | 43 +++-- .../claude/claude-streamed-block-identity.ts | 18 +- .../claude-streamed-text-checkpoints.ts | 22 ++- ...ured-journal-translation-subagents.test.ts | 155 +++++++++++++++++- .../claude-structured-journal-translation.ts | 23 ++- .../claude-structured-provider-fallback.ts | 15 +- .../journal-item-appender.ts | 7 +- .../journal-reducer.test.ts | 60 +++++++ .../agent-session-journal/journal-reducer.ts | 49 +++--- .../journal-row-builders.ts | 9 +- .../journal-row-schema.test.ts | 55 ++++++- .../journal-row-schema.ts | 5 + .../journal-store-contracts.ts | 7 +- ...tructured-agent-session-event-sink.test.ts | 35 ++++ .../structured-agent-session-event-sink.ts | 10 +- ...tructured-agent-session-resolved-append.ts | 6 +- src/shared/agent-session-journal-producer.ts | 17 ++ src/shared/agent-session-journal-schemas.ts | 3 +- src/shared/agent-session-journal-types.ts | 5 + ...structured-agent-session-live-turn.test.ts | 64 +++++++- .../structured-agent-session-live-turn.ts | 22 ++- ...tructured-agent-session-projection.test.ts | 106 ++++++++++++ .../structured-agent-session-projection.ts | 29 +++- 23 files changed, 685 insertions(+), 80 deletions(-) create mode 100644 src/shared/agent-session-journal-producer.ts diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 5a278f55210..6d36c91bb19 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -65,6 +65,12 @@ export function journalClaudeMessage( return false } let changed = false + // Everything this envelope journals belongs to whoever produced the envelope. A + // child's rows live in the parent's journal, so without this the parent's own + // "what am I doing" readers report the child's newest output as their own. + const producer: { producedBySubagent?: true } = envelope.parentToolUseId + ? { producedBySubagent: true } + : {} if (envelope.parentToolUseId) { ctx.subagents.observeChildActivity(envelope.parentToolUseId) } @@ -86,7 +92,7 @@ export function journalClaudeMessage( // output; a reader that scans back to the turn record and stops would // otherwise look straight past the row that opened it. ctx.turn.ensureOpen(message, source, observedAt) - ctx.sink.appendItem(identity, body) + ctx.sink.appendItem(identity, body, producer) changed = true } for (const tool of claudeToolUses(outputEnvelope)) { @@ -97,7 +103,11 @@ export function journalClaudeMessage( if (!envelope.parentToolUseId) { ctx.forwardedTools.record(tool.id) } - ctx.sink.appendItem(claudeToolIdentity(envelope.sessionId, tool.id), claudeToolBody({ tool })) + ctx.sink.appendItem( + claudeToolIdentity(envelope.sessionId, tool.id), + claudeToolBody({ tool }), + producer + ) changed = true } const results = claudeToolResults(envelope) @@ -109,7 +119,8 @@ export function journalClaudeMessage( } ctx.sink.appendItem( claudeToolIdentity(envelope.sessionId, result.toolUseId), - claudeToolBody({ tool, result }) + claudeToolBody({ tool, result }), + producer ) ctx.subagents.observeToolResult(result.toolUseId, result.failed) if ( @@ -125,17 +136,27 @@ export function journalClaudeMessage( } if (thinking) { ctx.turn.ensureOpen(message, source, observedAt) - ctx.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { - kind: 'message', - role: 'reasoning', - blocks: [ - { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } - ] - }) + ctx.sink.appendItem( + claudeThinkingIdentity(envelope.sessionId, envelope.uuid), + { + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ] + }, + producer + ) changed = true } changed = - appendUnmodeledContent(ctx.providerFallback, outputEnvelope, message, openOutputTurn) || changed + appendUnmodeledContent( + ctx.providerFallback, + outputEnvelope, + message, + openOutputTurn, + producer + ) || changed // The send's turn is anchored to the user row journaled just above it. const sendEchoTurn = claudeTurnOpenedBySendEcho({ envelope, diff --git a/src/main/claude/claude-streamed-block-identity.ts b/src/main/claude/claude-streamed-block-identity.ts index 5cbf6674159..4514153a75d 100644 --- a/src/main/claude/claude-streamed-block-identity.ts +++ b/src/main/claude/claude-streamed-block-identity.ts @@ -7,7 +7,13 @@ import { claudeRecord, claudeText } from './claude-structured-item-translation' // journal identity, and the final frame lands on it in block order instead of // appending a duplicate under its own uuid. -export type ClaudeStreamedTextDelta = { identity: AgentJournalItemIdentity; text: string } +export type ClaudeStreamedTextDelta = { + identity: AgentJournalItemIdentity + text: string + /** The block's own scope, which the registry already keys its map on — not a + * second store. Streamed prose has no message envelope when it is persisted. */ + producedBySubagent?: true +} type StreamedMessage = { messageId: string | null @@ -64,7 +70,11 @@ export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry if (frame.type !== 'stream_event' || !event || !sessionId || !uuid) { return null } - const scope = scopeKey(sessionId, claudeText(frame.parent_tool_use_id)) + const parentToolUseId = claudeText(frame.parent_tool_use_id) + const scope = scopeKey(sessionId, parentToolUseId) + const producer: { producedBySubagent?: true } = parentToolUseId + ? { producedBySubagent: true } + : {} if (event.type === 'message_start') { messages.set(scope, { messageId: claudeText(claudeRecord(event.message)?.id), @@ -81,7 +91,7 @@ export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry } const identity = mint(messageFor(scope), sessionId, index, uuid) const text = claudeText(block.text) - return text ? { identity, text } : null + return text ? { identity, text, ...producer } : null } if (event.type !== 'content_block_delta') { return null @@ -93,7 +103,7 @@ export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry } const streamed = messageFor(scope) const identity = streamed.blocks.get(index) ?? mint(streamed, sessionId, index, uuid) - return { identity, text } + return { identity, text, ...producer } }, reconcile: (frame) => { const streamed = messages.get(scopeKey(frame.sessionId, frame.parentToolUseId)) diff --git a/src/main/claude/claude-streamed-text-checkpoints.ts b/src/main/claude/claude-streamed-text-checkpoints.ts index 348ecd99558..ade437928ba 100644 --- a/src/main/claude/claude-streamed-text-checkpoints.ts +++ b/src/main/claude/claude-streamed-text-checkpoints.ts @@ -7,14 +7,22 @@ import { export type ClaudeStreamedTextCheckpointDeps = { /** Rewrites the block's journal row with the text accumulated so far. */ - persist: (identity: AgentJournalItemIdentity, text: string) => void + persist: ( + identity: AgentJournalItemIdentity, + text: string, + options: { producedBySubagent?: true } + ) => void coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } export type ClaudeStreamedTextCheckpoints = { /** Accumulate a delta; the row is rewritten on the coalescer's own cadence. */ - append: (identity: AgentJournalItemIdentity, text: string) => void + append: ( + identity: AgentJournalItemIdentity, + text: string, + options?: { producedBySubagent?: true } + ) => void /** Write every block whose row is behind the text received for it. */ flush: () => void /** Drop one block's state, for a block whose final frame has now landed. */ @@ -40,6 +48,9 @@ export function createClaudeStreamedTextCheckpoints( deps: ClaudeStreamedTextCheckpointDeps ): ClaudeStreamedTextCheckpoints { const identities = new Map() + // A block's producer is fixed when its identity is minted, so it rides the same + // entry rather than a parallel map that could disagree. + const producers = new Map() const latestText = new Map() const checkpointLengths = new Map() @@ -55,7 +66,7 @@ export function createClaudeStreamedTextCheckpoints( return } checkpointLengths.set(key, text.length) - deps.persist(identity, text) + deps.persist(identity, text, producers.get(key) ?? {}) } const coalescer = createAgentSessionDeltaCoalescer({ @@ -67,14 +78,16 @@ export function createClaudeStreamedTextCheckpoints( const drop = (key: string): void => { coalescer.forget(key) identities.delete(key) + producers.delete(key) latestText.delete(key) checkpointLengths.delete(key) } return { - append: (identity, text) => { + append: (identity, text, options) => { const key = agentJournalItemKey(identity) identities.set(key, identity) + producers.set(key, options?.producedBySubagent ? { producedBySubagent: true } : {}) coalescer.append(key, text) }, flush: () => { @@ -98,6 +111,7 @@ export function createClaudeStreamedTextCheckpoints( dispose: () => { coalescer.dispose() identities.clear() + producers.clear() latestText.clear() checkpointLengths.clear() } diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts index 3b280020c31..2916860a1f7 100644 --- a/src/main/claude/claude-structured-journal-translation-subagents.test.ts +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -7,7 +7,10 @@ import type { NativeChatSubagentEntry, NativeChatSubagentGroupBlock } from '../../shared/native-chat-types' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { + StructuredAgentSessionAppendOptions, + StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' const GROUP_ITEM_ID = 'claude-subagents:claude-session:user-1' @@ -17,10 +20,16 @@ function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null return identity.provider === 'orca' ? identity.clientMessageId : null } +type AppendedItem = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options: StructuredAgentSessionAppendOptions | undefined +} + function harness() { - const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const items: AppendedItem[] = [] const sink: StructuredAgentSessionEventSink = { - appendItem: (identity, body) => items.push({ identity, body }), + appendItem: (identity, body, options) => items.push({ identity, body, options }), appendTombstone: vi.fn(), publish: vi.fn() } @@ -50,7 +59,10 @@ function harness() { items .filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:')) .map((item) => item.body) - return { translator, groupRows, roster, rosterIn, rosterOf, fallbackRows } + /** Every append the translator made, with the options it passed — the third argument + * this harness used to discard, which is where producer attribution rides. */ + const appended = (): AppendedItem[] => items + return { translator, groupRows, roster, rosterIn, rosterOf, fallbackRows, appended } } function userTurn(uuid: string) { @@ -107,6 +119,64 @@ function resultFrame() { } } +/** An assistant frame; a string `parentToolUseId` makes it a subagent's. */ +function assistantFrame( + uuid: string, + parentToolUseId: string | null, + content: readonly Record[] +) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid, + session_id: 'claude-session', + parent_tool_use_id: parentToolUseId, + message: { role: 'assistant', content } + } + } +} + +/** A tool_result frame; a string `parentToolUseId` makes it a subagent's inner result. */ +function toolResultFrame(uuid: string, parentToolUseId: string | null, toolUseId: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'user', + uuid, + session_id: 'claude-session', + parent_tool_use_id: parentToolUseId, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'found it' }] + } + } + } +} + +// Partial-message cadence: every stream_event carries its own uuid, and the block's +// scope is (session_id, parent_tool_use_id) — the only place a streamed delta says +// who produced it, because the persist callback sees no message envelope. +function streamEvent(uuid: string, parentToolUseId: string | null, event: Record) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid, + session_id: 'claude-session', + parent_tool_use_id: parentToolUseId, + event + } + } +} + +function textDeltaEvent(index: number, text: string) { + return { type: 'content_block_delta', index, delta: { type: 'text_delta', text } } +} + describe('claude journal translation — subagents', () => { it('rosters a spawned subagent and settles it on the spawn call result', () => { const { translator, roster, fallbackRows } = harness() @@ -257,3 +327,80 @@ describe('claude journal translation — subagents', () => { expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) }) }) + +describe('producer attribution', () => { + it("stamps a subagent frame's message, tool call and tool result, and leaves the roster row alone", () => { + const { translator, appended } = harness() + translator.handle(userTurn('user-1')) + translator.handle(assistantFrame('root-1', null, [{ type: 'text', text: 'delegating' }])) + translator.handle( + assistantFrame('child-1', 'toolu_1', [ + { type: 'text', text: 'looking' }, + { type: 'tool_use', id: 'toolu_child', name: 'Grep', input: { pattern: 'x' } } + ]) + ) + translator.handle(toolResultFrame('child-2', 'toolu_1', 'toolu_child')) + + const stamped = appended() + .filter((item) => item.options?.producedBySubagent === true) + .map((item) => item.body.kind) + expect(stamped).toEqual(['message', 'tool-call', 'tool-call']) + + // The parent's own prose is NOT stamped, or the row would go blank instead of + // showing what the session's own agent said. + const rootProse = appended().find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(rootProse?.options?.producedBySubagent).toBeUndefined() + + // The roster group row is the PARENT's own display of its children, so it must + // stay root — stamping it would hide the subagent list from the parent. + const rosterRow = appended().findLast( + (item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID + ) + expect(rosterRow).toBeDefined() + expect(rosterRow?.options?.producedBySubagent).toBeUndefined() + }) + + it("stamps a subagent's STREAMED prose, which carries no message envelope when it persists", () => { + const { translator, appended } = harness() + translator.handle(userTurn('user-1')) + translator.handle(streamEvent('root-s1', null, textDeltaEvent(0, 'thinking it over'))) + translator.handle(streamEvent('child-s1', 'toolu_1', textDeltaEvent(0, 'searching the tree'))) + translator.flush() + + const streamed = appended().filter( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(streamed.map((item) => item.options?.producedBySubagent)).toEqual([undefined, true]) + }) + + it('leaves every row of a root-only turn unstamped', () => { + const { translator, appended } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + assistantFrame('root-1', null, [ + { type: 'text', text: 'on it' }, + { type: 'tool_use', id: 'toolu_1', name: 'Task', input: { description: 'explore' } } + ]) + ) + translator.handle(streamEvent('root-s1', null, textDeltaEvent(0, 'more prose'))) + translator.flush() + translator.handle(resultFrame()) + + expect(appended().every((item) => item.options?.producedBySubagent === undefined)).toBe(true) + // Positive control: the instrument can see a stamp when there is one. + expect(appended().length).toBeGreaterThan(0) + }) + + it('leaves the turn record unstamped — a turn is only ever opened by a root frame', () => { + const { translator, appended } = harness() + translator.handle(userTurn('user-1')) + translator.handle(assistantFrame('child-1', 'toolu_1', [{ type: 'text', text: 'looking' }])) + translator.handle(resultFrame()) + + const turnRows = appended().filter((item) => item.body.kind === 'turn') + expect(turnRows.length).toBeGreaterThan(0) + expect(turnRows.every((item) => item.options?.producedBySubagent === undefined)).toBe(true) + }) +}) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 112f4319b17..80c1e5d94b4 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -104,8 +104,8 @@ export function createClaudeJournalTranslator( const streamedText = createClaudeStreamedTextCheckpoints({ ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), - persist: (identity, text) => { - deps.sink.appendItem(identity, claudeStreamingMessageBody(text)) + persist: (identity, text, options) => { + deps.sink.appendItem(identity, claudeStreamingMessageBody(text), options) deps.sink.publish() } }) @@ -130,7 +130,11 @@ export function createClaudeJournalTranslator( if (!delta) { return false } - streamedText.append(delta.identity, delta.text) + streamedText.append( + delta.identity, + delta.text, + delta.producedBySubagent ? { producedBySubagent: true } : undefined + ) return true } @@ -198,7 +202,13 @@ export function createClaudeJournalTranslator( const kind = claudeProviderFrameKind(event.message) const failure = claudeResultFailure(event.message) if (failure || !isSettledClaudeResultKind(kind)) { - providerFallback.append(kind, event.message, failure?.text) + providerFallback.append( + kind, + event.message, + failure?.text, + undefined, + settlesTurn ? undefined : { producedBySubagent: true } + ) } } else if (event.type === 'message') { subagents.observeSystemFrame(event.message) @@ -220,7 +230,10 @@ export function createClaudeJournalTranslator( event.message, taskFrameSentence(event.message), undefined, - { coveredByTypedTranslator: backgroundTaskCovered } + { + coveredByTypedTranslator: backgroundTaskCovered, + ...(isRootClaudeFrame(event.message) ? {} : { producedBySubagent: true as const }) + } ) } publishActivity(kind, event.message) diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index ebce2762c55..fc8f3111e43 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -113,7 +113,7 @@ export function createClaudeProviderFrameFallback( /** Runs only when a row is actually going to be written, so a frame that * translates to nothing never opens a turn. */ beforeAppend?: () => void, - options?: UnhandledProviderFrameJournalItemOptions + options?: UnhandledProviderFrameJournalItemOptions & { producedBySubagent?: true } ) => boolean } { let sequence = 0 @@ -139,7 +139,8 @@ export function createClaudeProviderFrameFallback( provider: 'orca', clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` }, - bounded ? { ...translated.body, text: bounded } : translated.body + bounded ? { ...translated.body, text: bounded } : translated.body, + options?.producedBySubagent ? { producedBySubagent: true } : undefined ) sink.publish() return true @@ -156,7 +157,8 @@ export function appendUnmodeledContent( fallback: ClaudeProviderFrameFallback, envelope: ClaudeMessageEnvelope, message: Record, - beforeAppend: () => void + beforeAppend: () => void, + producer: { producedBySubagent?: true } = {} ): boolean { let changed = false for (const part of envelope.content.filter((part) => !isModeledClaudeContent(part))) { @@ -166,13 +168,16 @@ export function appendUnmodeledContent( `message:${envelope.role}:content:${partType}`, part, readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT, - beforeAppend + beforeAppend, + producer ) || changed } if (envelope.content.length === 0 && envelope.role === 'assistant') { // Empty provider placeholders do not prove work began, and may have no // later result capable of closing a turn. - changed = fallback.append(`message:${envelope.role}:empty`, message) || changed + changed = + fallback.append(`message:${envelope.role}:empty`, message, undefined, undefined, producer) || + changed } return changed } diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 1bbc3d4fab3..dddff586348 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -8,7 +8,12 @@ import type { JournalReducerState } from './journal-reducer' import type { JournalAppendResult } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' -type ItemAppendOptions = { fence: number; observedAt?: number; recovered?: true } +type ItemAppendOptions = { + fence: number + observedAt?: number + recovered?: true + producedBySubagent?: true +} export class JournalItemAppender { constructor( diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 184e4a19a01..d002192dacd 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -658,3 +658,63 @@ describe('re-adding a tombstoned row', () => { expect(renderJournalState(state).items).toEqual([]) }) }) + +describe('producer attribution round-trips through the reducer', () => { + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 'claude-session', + uuid: 'child-1' + } + + it('copies the marker onto the render item on the plain item path, and omits it otherwise', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + ...buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 1, + fence: 1, + ts: 1_001, + producedBySubagent: true + }) + }) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'root-1' }, + body: text('delegating'), + seq: 2, + fence: 1, + ts: 1_002 + }) + ) + const rendered = renderJournalState(state).items + expect(rendered.map((item) => item.producedBySubagent)).toEqual([true, undefined]) + }) + + it('copies the marker on the lifecycle-batch path too — a separate spread', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations: [{ kind: 'item', itemId: 'i-child', revision: 1, body: text('looking') }], + ...base(1), + producedBySubagent: true + }) + expect(renderJournalState(state).items[0]?.producedBySubagent).toBe(true) + }) + + it("renders a row that predates the marker as the session's own", () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'item', + itemId: 'i-legacy', + revision: 1, + body: text('written before the marker existed'), + ...base(1) + }) + expect(renderJournalState(state).items[0]?.producedBySubagent).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 5c4917bec46..ec29eb002cc 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -9,6 +9,7 @@ import type { AgentJournalAcceptanceReceipt, + AgentJournalItemBody, AgentJournalRenderItem, AgentJournalSnapshot, AgentJournalSubmission @@ -73,14 +74,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } const itemId = resolveJournalItemId(state, row.itemId, row.body) acceptSubmissionFromProviderItem(state, row.itemId, itemId, row) - upsertItem(state, itemId, row.revision, { - itemId, - revision: row.revision, - body: row.body, - sequence: row.seq, - observedAt: row.ts, - ...(row.recovered ? { recovered: row.recovered } : {}) - }) + upsertItem(state, itemId, row.revision, renderedItem(itemId, row.revision, row.body, row)) return } if (row.kind === 'tombstone') { @@ -98,14 +92,8 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } const itemId = resolveJournalItemId(state, mutation.itemId, mutation.body) acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) - upsertItem(state, itemId, mutation.revision, { - itemId, - revision: mutation.revision, - body: mutation.body, - sequence: row.seq, - observedAt: row.ts, - ...(row.recovered ? { recovered: row.recovered } : {}) - }) + const next = renderedItem(itemId, mutation.revision, mutation.body, row) + upsertItem(state, itemId, mutation.revision, next) } else { removeItem(state, resolveItemId(state, mutation.itemId), mutation.revision) } @@ -185,6 +173,27 @@ function resolveItemId(state: JournalReducerState, itemId: string): string { return state.aliases.get(itemId) ?? itemId } +/** One render item, built the same way by every upsert path. Both row-level markers + * are copied here rather than at each call site: they used to be two spreads that + * had to stay in sync, and absence is the claim in both cases — appended live, and + * produced by the session's own agent. */ +function renderedItem( + itemId: string, + revision: number, + body: AgentJournalItemBody, + row: JournalRow +): AgentJournalRenderItem { + return { + itemId, + revision, + body, + sequence: row.seq, + observedAt: row.ts, + ...(row.recovered ? { recovered: row.recovered } : {}), + ...(row.producedBySubagent ? { producedBySubagent: row.producedBySubagent } : {}) + } +} + function upsertItem( state: JournalReducerState, itemId: string, @@ -251,13 +260,7 @@ function applySubmission( resolvedAt: null }) const itemId = agentJournalSubmissionKey(row.clientMessageId) - upsertItem(state, itemId, 0, { - itemId, - revision: 0, - body: row.body, - sequence: row.seq, - observedAt: row.ts - }) + upsertItem(state, itemId, 0, renderedItem(itemId, 0, row.body, row)) } function applyDispatch( diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index e5e376940fe..53264a8b5aa 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -29,7 +29,7 @@ export function journalItemRowBuilder( state: () => JournalReducerState, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: { fence: number; observedAt?: number; recovered?: true } + options: { fence: number; observedAt?: number; recovered?: true; producedBySubagent?: true } ): RowBuilder { return (seq, ts) => buildJournalItemRow({ @@ -39,7 +39,8 @@ export function journalItemRowBuilder( seq, fence: options.fence, ts: options.observedAt ?? ts, - recovered: options.recovered + recovered: options.recovered, + producedBySubagent: options.producedBySubagent }) } @@ -164,6 +165,7 @@ export function buildJournalItemRow(input: { fence: number ts: number recovered?: true + producedBySubagent?: true }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -180,7 +182,8 @@ export function buildJournalItemRow(input: { revision, body: input.body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [input.body]), - ...(input.recovered ? { recovered: input.recovered } : {}) + ...(input.recovered ? { recovered: input.recovered } : {}), + ...(input.producedBySubagent ? { producedBySubagent: input.producedBySubagent } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts index 4ba1ad349a5..68368d21a24 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS, parseJournalRow } from './journal-row-schema' +import { + MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS, + parseJournalRow, + type JournalRow +} from './journal-row-schema' +import { createJournalReducerState } from './journal-reducer' +import { buildJournalItemRow } from './journal-row-builders' const BASE = { v: 1, epoch: 'epoch-1', seq: 1, fence: 1, ts: 1 } @@ -238,3 +244,50 @@ describe('journal row validation', () => { ).toBe(false) }) }) + +describe('producer attribution on the persisted row', () => { + const state = createJournalReducerState('session-1', 'epoch-1') + const identity = { provider: 'claude' as const, sessionId: 'claude-session', uuid: 'u-1' } + const body = { kind: 'status' as const, text: 'child work' } + + /** The full durable path: build the row the appender would write, serialize it the + * way the journal file does, and read it back. */ + function roundTrip(producedBySubagent?: true): JournalRow | null { + const row = buildJournalItemRow({ + state, + identity, + body, + seq: 1, + fence: 1, + ts: 1_700_000_000_000, + ...(producedBySubagent ? { producedBySubagent } : {}) + }) + const parsed = parseJournalRow(JSON.stringify(row)) + return parsed.ok ? parsed.row : null + } + + it('writes and reads back the marker without bumping the schema version', () => { + const row = roundTrip(true) + expect(row?.producedBySubagent).toBe(true) + // Deliberately NOT a version bump: an unknown `v` is unreadable and latches the + // host read-only, while an unknown KEY is simply ignored by an older host. + expect(row?.v).toBe(AGENT_SESSION_JOURNAL_SCHEMA_VERSION) + }) + + it("omits the key entirely on a row the session's own agent produced", () => { + const row = roundTrip() + expect(row && 'producedBySubagent' in row).toBe(false) + }) + + it('accepts a real pre-change journal line, which carries no marker at all', () => { + // A literal line rather than a constructed row, so this also pins that no + // unknown-key rejection crept in. + const legacy = + '{"v":3,"epoch":"epoch-1","seq":7,"fence":1,"ts":1700000000000,"kind":"item",' + + '"itemId":"i-1","revision":1,"body":{"kind":"message","role":"assistant",' + + '"blocks":[{"type":"text","text":"hello"}]}}' + const parsed = parseJournalRow(legacy) + expect(parsed.ok).toBe(true) + expect(parsed.ok && 'producedBySubagent' in parsed.row).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 7dc02dd197b..cfad017e12c 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -28,6 +28,11 @@ type JournalRowBase = { ts: number /** Set when crash reconciliation appended the row after the fact. */ recovered?: true + /** Set when a subagent, not the session's own agent, produced the row. Deliberately + * NOT a `v` bump: an unknown `v` makes a row unreadable and latches the host + * read-only, whereas an unknown KEY is ignored below, so an older host reads a + * stamped row and behaves exactly as it does today. */ + producedBySubagent?: true } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 80c806b02e3..fa66c135f13 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -39,7 +39,12 @@ export type JournalAppendResult = { revision: number } -export type JournalItemAppendOptions = { fence: number; observedAt?: number; recovered?: true } +export type JournalItemAppendOptions = { + fence: number + observedAt?: number + recovered?: true + producedBySubagent?: true +} export type JournalTombstoneInput = { fence: number } export type JournalLifecycleBatchInput = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 8a2485473f8..d14e844f3d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -27,6 +27,7 @@ type Recorded = { ordinal?: number settlementId?: string activity?: AgentSessionTurnActivity | null + producedBySubagent?: true } function target( @@ -253,6 +254,40 @@ describe('deferred structured agent-session event sink', () => { ]) }) + it('preserves producer attribution through resolved append paths', async () => { + for (const append of ['tryAppendResolvedItem', 'tryAppendResolvedItemAndPublish'] as const) { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + const bound = target(5, log) + vi.spyOn(bound.journal, 'appendItem').mockImplementation(async (id, _body, options) => { + log.push({ + call: 'appendItem', + fence: 5, + ordinal: id.provider === 'codex' ? id.ordinal : -1, + ...(options?.producedBySubagent ? { producedBySubagent: true } : {}) + }) + return { + cursor: { epoch: 'e', sequence: 1 }, + itemId: 'test-item', + revision: 1 + } + }) + deferred.bind(bound) + const admission = deferred.sink[append]?.(identity(1), BODY, () => identity(1), { + producedBySubagent: true + }) + expect(admission).toEqual({ accepted: true }) + await deferred.drained() + expect(log).toContainEqual({ + call: 'appendItem', + fence: 5, + ordinal: 1, + producedBySubagent: true + }) + deferred.close() + } + }) + it('pauses provider reading at the soft byte watermark before rejecting writes', async () => { const log: Recorded[] = [] const changes: boolean[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index b27e466d94b..113be37f11e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -30,6 +30,10 @@ export type StructuredAgentSessionAppendOptions = { lifecycle?: boolean /** Host clock to stamp on the row instead of its append time. */ observedAt?: number + /** Set by a producer journaling a subagent's output into the session's journal. + * Lifecycle appends never carry it: a turn is only ever opened by a root frame, + * so those rows are root by construction. */ + producedBySubagent?: true } export type StructuredAgentSessionLifecycleJournal = Pick< @@ -203,7 +207,8 @@ export function createDeferredStructuredAgentSessionEventSink( run: (bound) => bound.journal.appendItem(identity, body, { fence: bound.fence, - ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), + ...(options.producedBySubagent ? { producedBySubagent: true as const } : {}) }) }, options @@ -217,7 +222,8 @@ export function createDeferredStructuredAgentSessionEventSink( run: (bound) => bound.journal.appendItem(identity, body, { fence: bound.fence, - ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), + ...(options.producedBySubagent ? { producedBySubagent: true as const } : {}) }) }, options diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts index b8fb2f1986c..5e8929b79f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts @@ -27,7 +27,8 @@ export function createStructuredAgentSessionResolvedAppend( } await bound.journal.appendItem(identity, body, { fence: bound.fence, - ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), + ...(options.producedBySubagent ? { producedBySubagent: true as const } : {}) }) } }, @@ -49,7 +50,8 @@ export function createStructuredAgentSessionResolvedAppend( } await bound.journal.appendItem(identity, body, { fence: bound.fence, - ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), + ...(options.producedBySubagent ? { producedBySubagent: true as const } : {}) }) bound.publish() } diff --git a/src/shared/agent-session-journal-producer.ts b/src/shared/agent-session-journal-producer.ts new file mode 100644 index 00000000000..37d342c5a21 --- /dev/null +++ b/src/shared/agent-session-journal-producer.ts @@ -0,0 +1,17 @@ +// Which agent produced a journal row. +// +// One journal is the durable record of one agent SESSION, and a session may run +// subagents. Rows from both land in the same timeline, so every "what is this +// agent doing right now" reader needs to say which producer it means. This is +// the only place absence of the marker is interpreted. + +import type { AgentJournalRenderItem } from './agent-session-journal-types' + +/** Whether the session's own agent produced this item, rather than a subagent it + * spawned. Absence means root: the producer stamps every child row it writes, and + * rows written before the marker existed are root as far as any reader can tell. */ +export function isRootAgentJournalItem( + item: Pick | undefined +): boolean { + return item?.producedBySubagent !== true +} diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index ca95cd2892c..e8c0f6c3783 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -243,7 +243,8 @@ export const AgentJournalRenderItemSchema = z.object({ body: AgentJournalItemBodySchema, sequence: z.number().int(), observedAt: z.number(), - recovered: z.literal(true).optional() + recovered: z.literal(true).optional(), + producedBySubagent: z.literal(true).optional() }) export const AgentJournalSubmissionSchema = z.object({ diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 287e07e2a5d..d2a02aa8e0d 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -261,6 +261,11 @@ export type AgentJournalRenderItem = { observedAt: number /** Set when the row was appended by crash reconciliation rather than live. */ recovered?: true + /** Set when a subagent running inside this session produced the row, rather than + * the session's own agent. Absent on every root row and on every row written + * before this field existed, so absence is a positive claim of root-ness and + * never "unknown" — the one producer of these rows always knows which it is. */ + producedBySubagent?: true } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/src/shared/structured-agent-session-live-turn.test.ts b/src/shared/structured-agent-session-live-turn.test.ts index b9ce5f2c022..2732b9145d9 100644 --- a/src/shared/structured-agent-session-live-turn.test.ts +++ b/src/shared/structured-agent-session-live-turn.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from './agent-session-journal-types' -import { isStructuredAgentSessionThinking } from './structured-agent-session-live-turn' +import { + activeStructuredAgentSessionToolCall, + isStructuredAgentSessionThinking +} from './structured-agent-session-live-turn' function item( itemId: string, @@ -111,3 +114,62 @@ describe('isStructuredAgentSessionThinking', () => { ).toBe(false) }) }) + +describe('producer attribution in the live-turn readers', () => { + const turnStart = item('turn-start', 1, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }) + const spawnCall = item('root-task', 2, { + kind: 'tool-call', + name: 'Task', + input: { description: 'explore' }, + state: 'running' + }) + const child = ( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] + ): AgentJournalRenderItem => ({ ...item(itemId, sequence, body), producedBySubagent: true }) + + it('does not report the parent as thinking because a subagent is reasoning', () => { + const childReasoning = child('child-reasoning', 3, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + expect(isStructuredAgentSessionThinking([turnStart, spawnCall, childReasoning])).toBe(false) + }) + + it('still reports the parent as thinking when the parent itself is reasoning', () => { + const ownReasoning = item('own-reasoning', 3, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + expect(isStructuredAgentSessionThinking([turnStart, spawnCall, ownReasoning])).toBe(true) + }) + + it("reports the parent's own running call while a subagent runs its own", () => { + const childCall = child('child-grep', 3, { + kind: 'tool-call', + name: 'Grep', + input: { pattern: 'x' }, + state: 'running' + }) + expect(activeStructuredAgentSessionToolCall([turnStart, spawnCall, childCall])?.name).toBe( + 'Task' + ) + }) + + it('reports nothing running when only a subagent has a live call', () => { + const childCall = child('child-grep', 2, { + kind: 'tool-call', + name: 'Grep', + input: { pattern: 'x' }, + state: 'running' + }) + expect(activeStructuredAgentSessionToolCall([turnStart, childCall])).toBeNull() + }) +}) diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index b53651acf1c..d1cdf21c3b3 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -2,12 +2,17 @@ // tail of the item list. Every scan here stops at the turn's own record — the // typed `turn` item, or the legacy status row that carries one — because state // from an earlier turn is never this turn's state. +// +// These scans answer for the SESSION'S OWN agent. A subagent's rows share this +// journal, so each scan skips anything a subagent produced; the transcript still +// renders every agent's output. import type { AgentJournalRenderItem, AgentJournalToolCallItem, AgentJournalTurnLifecycle } from './agent-session-journal-types' +import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn } from './agent-session-turn-record' export function activeStructuredAgentSessionTurnId( @@ -76,12 +81,13 @@ export function isStructuredAgentSessionThinking( ): boolean { let newestContentIsReasoning: boolean | null = null for (let index = items.length - 1; index >= 0; index -= 1) { - const body = items[index]?.body + const item = items[index] + const body = item?.body const turn = readAgentJournalTurn(body) if (turn) { return turn.state === 'running' && newestContentIsReasoning === true } - if (newestContentIsReasoning !== null) { + if (newestContentIsReasoning !== null || !isRootAgentJournalItem(item)) { continue } if (body?.kind === 'message') { @@ -99,18 +105,20 @@ export function isStructuredAgentSessionThinking( return false } -/** The tool call the newest turn is still inside, or null when nothing is running. - * An abandoned `running` call from an earlier crashed turn can never be reported - * as live work. */ +/** The tool call the SESSION'S OWN agent is still inside, or null when nothing is + * running. An abandoned `running` call from an earlier crashed turn can never be + * reported as live work, and neither can a subagent's — while a child runs a tool, + * the parent is still inside the call that spawned it. */ export function activeStructuredAgentSessionToolCall( items: readonly AgentJournalRenderItem[] ): AgentJournalToolCallItem | null { for (let index = items.length - 1; index >= 0; index -= 1) { - const body = items[index]?.body + const item = items[index] + const body = item?.body if (readAgentJournalTurn(body)) { return null } - if (body?.kind === 'tool-call' && body.state === 'running') { + if (body?.kind === 'tool-call' && body.state === 'running' && isRootAgentJournalItem(item)) { return body } } diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 928180e7a6c..253899b071f 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -7,6 +7,9 @@ import { hasPersistedStructuredAgentSessionTurn, hasUnansweredStructuredAgentSessionDispatch, projectStructuredItemToNativeChat, + projectStructuredItemsToNativeChat, + latestStructuredAgentSessionAssistantMessage, + activeStructuredAgentSessionToolCall, projectStructuredAgentSessionStatus, projectStructuredAgentSessionStatusSummary, structuredAgentSessionPaneKey @@ -513,3 +516,106 @@ it('preserves confirmed MCP identity and the raw name through projection', () => type: 'tool-call' }) }) + +describe("producer attribution — a subagent's output never speaks for the parent", () => { + /** A row a subagent produced. Same journal, same session id; only the marker differs. */ + function childItem( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] + ): AgentJournalRenderItem { + return { ...item(itemId, sequence, body), producedBySubagent: true } + } + + 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(activeStructuredAgentSessionToolCall(items)?.name).toBe('Task') + }) + + it("publishes the parent's own line and call on the status 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("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("reads a row written before the marker existed as the parent's own", () => { + // A journal open across the upgrade has unmarked child rows below marked ones. + // Absence means root, which reproduces today\'s behaviour for that history + // exactly — 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') + }) + + 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') + }) +}) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 6032f5cc86e..09ce2674afe 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -4,6 +4,7 @@ import { normalizePromptField } from './agent-status-field-normalization' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { isRootAgentJournalItem } from './agent-session-journal-producer' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH @@ -135,6 +136,9 @@ function itemBlocks(item: AgentJournalRenderItem): { const projectedItems = new WeakMap() +/** Deliberately NOT scoped by producer: the transcript shows every agent's output. + * The line this module draws is that the transcript renders every item, while every + * "what is this agent doing right now" scan renders only the session's own agent's. */ export function projectStructuredItemsToNativeChat( items: readonly AgentJournalRenderItem[] ): NativeChatMessage[] { @@ -170,6 +174,9 @@ export function projectStructuredItemToNativeChat( return message } +/** Deliberately NOT scoped by producer: this is an existence test ("is this session + * listable at all"), not an attribution one. A session whose only content came from a + * subagent still has content, and a missing row is worse than an attributed one. */ export function hasPersistedStructuredAgentSessionTurn( items: readonly AgentJournalRenderItem[] ): boolean { @@ -236,7 +243,9 @@ function messageProse(blocks: readonly NativeChatBlock[]): string { return blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') } -/** The newest user prompt, as the sidebar quotes it. */ +/** The newest prompt the session's own user turn carries, as the sidebar quotes it. + * Scoped to root rows for the same reason the assistant line is: a provider that + * journals a subagent's own prompt would otherwise requote it as the session's. */ export function latestStructuredAgentSessionPrompt( items: readonly AgentJournalRenderItem[] ): string { @@ -249,20 +258,30 @@ export function latestStructuredAgentSessionUserItem( ): AgentJournalRenderItem | null { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] - if (item?.body.kind === 'message' && item.body.role === 'user') { + if ( + item?.body.kind === 'message' && + item.body.role === 'user' && + isRootAgentJournalItem(item) + ) { return item } } return null } -/** The newest assistant prose in the latest user turn. Tool-only assistant items - * are skipped; the user boundary clears prose from the preceding turn. */ +/** The newest prose THE SESSION'S OWN AGENT wrote in the latest user turn — not a + * subagent's, whose rows share this journal and are usually the newer ones while a + * child runs. Tool-only assistant items are skipped; the user boundary clears prose + * from the preceding turn. */ export function latestStructuredAgentSessionAssistantMessage( items: readonly AgentJournalRenderItem[] ): string { for (let index = items.length - 1; index >= 0; index -= 1) { - const body = items[index]?.body + const item = items[index] + const body = item?.body + if (!isRootAgentJournalItem(item)) { + continue + } if (body?.kind === 'message' && body.role === 'user') { return '' }