From c49b8cd53420e5d142008cecf59e61ade99b5bf4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:11:32 -0400 Subject: [PATCH] =?UTF-8?q?Revert=20"fix(native-chat):=20stop=20a=20subage?= =?UTF-8?q?nt's=20output=20speaking=20for=20the=20agent=20tha=E2=80=A6"=20?= =?UTF-8?q?(#22058)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 33ba1ff3df247652c546985201d9a6f4edaec80b. --- 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, 80 insertions(+), 685 deletions(-) delete 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 6d36c91bb19..5a278f55210 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -65,12 +65,6 @@ 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) } @@ -92,7 +86,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, producer) + ctx.sink.appendItem(identity, body) changed = true } for (const tool of claudeToolUses(outputEnvelope)) { @@ -103,11 +97,7 @@ export function journalClaudeMessage( if (!envelope.parentToolUseId) { ctx.forwardedTools.record(tool.id) } - ctx.sink.appendItem( - claudeToolIdentity(envelope.sessionId, tool.id), - claudeToolBody({ tool }), - producer - ) + ctx.sink.appendItem(claudeToolIdentity(envelope.sessionId, tool.id), claudeToolBody({ tool })) changed = true } const results = claudeToolResults(envelope) @@ -119,8 +109,7 @@ export function journalClaudeMessage( } ctx.sink.appendItem( claudeToolIdentity(envelope.sessionId, result.toolUseId), - claudeToolBody({ tool, result }), - producer + claudeToolBody({ tool, result }) ) ctx.subagents.observeToolResult(result.toolUseId, result.failed) if ( @@ -136,27 +125,17 @@ 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 } - ] - }, - producer - ) + ctx.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ] + }) changed = true } changed = - appendUnmodeledContent( - ctx.providerFallback, - outputEnvelope, - message, - openOutputTurn, - producer - ) || changed + appendUnmodeledContent(ctx.providerFallback, outputEnvelope, message, openOutputTurn) || 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 4514153a75d..5cbf6674159 100644 --- a/src/main/claude/claude-streamed-block-identity.ts +++ b/src/main/claude/claude-streamed-block-identity.ts @@ -7,13 +7,7 @@ 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 - /** 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 -} +export type ClaudeStreamedTextDelta = { identity: AgentJournalItemIdentity; text: string } type StreamedMessage = { messageId: string | null @@ -70,11 +64,7 @@ export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry if (frame.type !== 'stream_event' || !event || !sessionId || !uuid) { return null } - const parentToolUseId = claudeText(frame.parent_tool_use_id) - const scope = scopeKey(sessionId, parentToolUseId) - const producer: { producedBySubagent?: true } = parentToolUseId - ? { producedBySubagent: true } - : {} + const scope = scopeKey(sessionId, claudeText(frame.parent_tool_use_id)) if (event.type === 'message_start') { messages.set(scope, { messageId: claudeText(claudeRecord(event.message)?.id), @@ -91,7 +81,7 @@ export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry } const identity = mint(messageFor(scope), sessionId, index, uuid) const text = claudeText(block.text) - return text ? { identity, text, ...producer } : null + return text ? { identity, text } : null } if (event.type !== 'content_block_delta') { return null @@ -103,7 +93,7 @@ export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry } const streamed = messageFor(scope) const identity = streamed.blocks.get(index) ?? mint(streamed, sessionId, index, uuid) - return { identity, text, ...producer } + return { identity, text } }, 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 ade437928ba..348ecd99558 100644 --- a/src/main/claude/claude-streamed-text-checkpoints.ts +++ b/src/main/claude/claude-streamed-text-checkpoints.ts @@ -7,22 +7,14 @@ import { export type ClaudeStreamedTextCheckpointDeps = { /** Rewrites the block's journal row with the text accumulated so far. */ - persist: ( - identity: AgentJournalItemIdentity, - text: string, - options: { producedBySubagent?: true } - ) => void + persist: (identity: AgentJournalItemIdentity, text: string) => 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, - options?: { producedBySubagent?: true } - ) => void + append: (identity: AgentJournalItemIdentity, text: string) => 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. */ @@ -48,9 +40,6 @@ 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() @@ -66,7 +55,7 @@ export function createClaudeStreamedTextCheckpoints( return } checkpointLengths.set(key, text.length) - deps.persist(identity, text, producers.get(key) ?? {}) + deps.persist(identity, text) } const coalescer = createAgentSessionDeltaCoalescer({ @@ -78,16 +67,14 @@ 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, options) => { + append: (identity, text) => { const key = agentJournalItemKey(identity) identities.set(key, identity) - producers.set(key, options?.producedBySubagent ? { producedBySubagent: true } : {}) coalescer.append(key, text) }, flush: () => { @@ -111,7 +98,6 @@ 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 2916860a1f7..3b280020c31 100644 --- a/src/main/claude/claude-structured-journal-translation-subagents.test.ts +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -7,10 +7,7 @@ import type { NativeChatSubagentEntry, NativeChatSubagentGroupBlock } from '../../shared/native-chat-types' -import type { - StructuredAgentSessionAppendOptions, - StructuredAgentSessionEventSink -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { 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' @@ -20,16 +17,10 @@ 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: AppendedItem[] = [] + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] const sink: StructuredAgentSessionEventSink = { - appendItem: (identity, body, options) => items.push({ identity, body, options }), + appendItem: (identity, body) => items.push({ identity, body }), appendTombstone: vi.fn(), publish: vi.fn() } @@ -59,10 +50,7 @@ function harness() { items .filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:')) .map((item) => item.body) - /** 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 } + return { translator, groupRows, roster, rosterIn, rosterOf, fallbackRows } } function userTurn(uuid: string) { @@ -119,64 +107,6 @@ 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() @@ -327,80 +257,3 @@ 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 80c1e5d94b4..112f4319b17 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, options) => { - deps.sink.appendItem(identity, claudeStreamingMessageBody(text), options) + persist: (identity, text) => { + deps.sink.appendItem(identity, claudeStreamingMessageBody(text)) deps.sink.publish() } }) @@ -130,11 +130,7 @@ export function createClaudeJournalTranslator( if (!delta) { return false } - streamedText.append( - delta.identity, - delta.text, - delta.producedBySubagent ? { producedBySubagent: true } : undefined - ) + streamedText.append(delta.identity, delta.text) return true } @@ -202,13 +198,7 @@ export function createClaudeJournalTranslator( const kind = claudeProviderFrameKind(event.message) const failure = claudeResultFailure(event.message) if (failure || !isSettledClaudeResultKind(kind)) { - providerFallback.append( - kind, - event.message, - failure?.text, - undefined, - settlesTurn ? undefined : { producedBySubagent: true } - ) + providerFallback.append(kind, event.message, failure?.text) } } else if (event.type === 'message') { subagents.observeSystemFrame(event.message) @@ -230,10 +220,7 @@ export function createClaudeJournalTranslator( event.message, taskFrameSentence(event.message), undefined, - { - coveredByTypedTranslator: backgroundTaskCovered, - ...(isRootClaudeFrame(event.message) ? {} : { producedBySubagent: true as const }) - } + { coveredByTypedTranslator: backgroundTaskCovered } ) } 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 fc8f3111e43..ebce2762c55 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 & { producedBySubagent?: true } + options?: UnhandledProviderFrameJournalItemOptions ) => boolean } { let sequence = 0 @@ -139,8 +139,7 @@ export function createClaudeProviderFrameFallback( provider: 'orca', clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` }, - bounded ? { ...translated.body, text: bounded } : translated.body, - options?.producedBySubagent ? { producedBySubagent: true } : undefined + bounded ? { ...translated.body, text: bounded } : translated.body ) sink.publish() return true @@ -157,8 +156,7 @@ export function appendUnmodeledContent( fallback: ClaudeProviderFrameFallback, envelope: ClaudeMessageEnvelope, message: Record, - beforeAppend: () => void, - producer: { producedBySubagent?: true } = {} + beforeAppend: () => void ): boolean { let changed = false for (const part of envelope.content.filter((part) => !isModeledClaudeContent(part))) { @@ -168,16 +166,13 @@ export function appendUnmodeledContent( `message:${envelope.role}:content:${partType}`, part, readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT, - beforeAppend, - producer + beforeAppend ) || 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, undefined, undefined, producer) || - changed + changed = fallback.append(`message:${envelope.role}:empty`, message) || 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 dddff586348..1bbc3d4fab3 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,12 +8,7 @@ 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 - producedBySubagent?: true -} +type ItemAppendOptions = { fence: number; observedAt?: number; recovered?: 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 d002192dacd..184e4a19a01 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,63 +658,3 @@ 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 ec29eb002cc..5c4917bec46 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -9,7 +9,6 @@ import type { AgentJournalAcceptanceReceipt, - AgentJournalItemBody, AgentJournalRenderItem, AgentJournalSnapshot, AgentJournalSubmission @@ -74,7 +73,14 @@ 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, renderedItem(itemId, row.revision, row.body, row)) + upsertItem(state, itemId, row.revision, { + itemId, + revision: row.revision, + body: row.body, + sequence: row.seq, + observedAt: row.ts, + ...(row.recovered ? { recovered: row.recovered } : {}) + }) return } if (row.kind === 'tombstone') { @@ -92,8 +98,14 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } const itemId = resolveJournalItemId(state, mutation.itemId, mutation.body) acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) - const next = renderedItem(itemId, mutation.revision, mutation.body, row) - upsertItem(state, itemId, mutation.revision, next) + upsertItem(state, itemId, mutation.revision, { + itemId, + revision: mutation.revision, + body: mutation.body, + sequence: row.seq, + observedAt: row.ts, + ...(row.recovered ? { recovered: row.recovered } : {}) + }) } else { removeItem(state, resolveItemId(state, mutation.itemId), mutation.revision) } @@ -173,27 +185,6 @@ 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, @@ -260,7 +251,13 @@ function applySubmission( resolvedAt: null }) const itemId = agentJournalSubmissionKey(row.clientMessageId) - upsertItem(state, itemId, 0, renderedItem(itemId, 0, row.body, row)) + upsertItem(state, itemId, 0, { + itemId, + revision: 0, + body: row.body, + sequence: row.seq, + observedAt: row.ts + }) } 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 53264a8b5aa..e5e376940fe 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; producedBySubagent?: true } + options: { fence: number; observedAt?: number; recovered?: true } ): RowBuilder { return (seq, ts) => buildJournalItemRow({ @@ -39,8 +39,7 @@ export function journalItemRowBuilder( seq, fence: options.fence, ts: options.observedAt ?? ts, - recovered: options.recovered, - producedBySubagent: options.producedBySubagent + recovered: options.recovered }) } @@ -165,7 +164,6 @@ 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 @@ -182,8 +180,7 @@ 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.producedBySubagent ? { producedBySubagent: input.producedBySubagent } : {}) + ...(input.recovered ? { recovered: input.recovered } : {}) } } 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 68368d21a24..4ba1ad349a5 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,12 +1,6 @@ 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, - type JournalRow -} from './journal-row-schema' -import { createJournalReducerState } from './journal-reducer' -import { buildJournalItemRow } from './journal-row-builders' +import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS, parseJournalRow } from './journal-row-schema' const BASE = { v: 1, epoch: 'epoch-1', seq: 1, fence: 1, ts: 1 } @@ -244,50 +238,3 @@ 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 cfad017e12c..7dc02dd197b 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,11 +28,6 @@ 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 fa66c135f13..80c806b02e3 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,12 +39,7 @@ export type JournalAppendResult = { revision: number } -export type JournalItemAppendOptions = { - fence: number - observedAt?: number - recovered?: true - producedBySubagent?: true -} +export type JournalItemAppendOptions = { fence: number; observedAt?: number; recovered?: 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 d14e844f3d1..8a2485473f8 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,7 +27,6 @@ type Recorded = { ordinal?: number settlementId?: string activity?: AgentSessionTurnActivity | null - producedBySubagent?: true } function target( @@ -254,40 +253,6 @@ 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 113be37f11e..b27e466d94b 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,10 +30,6 @@ 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< @@ -207,8 +203,7 @@ export function createDeferredStructuredAgentSessionEventSink( run: (bound) => bound.journal.appendItem(identity, body, { fence: bound.fence, - ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), - ...(options.producedBySubagent ? { producedBySubagent: true as const } : {}) + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) }) }, options @@ -222,8 +217,7 @@ export function createDeferredStructuredAgentSessionEventSink( run: (bound) => bound.journal.appendItem(identity, body, { fence: bound.fence, - ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), - ...(options.producedBySubagent ? { producedBySubagent: true as const } : {}) + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) }) }, 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 5e8929b79f4..b8fb2f1986c 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,8 +27,7 @@ export function createStructuredAgentSessionResolvedAppend( } await bound.journal.appendItem(identity, body, { fence: bound.fence, - ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), - ...(options.producedBySubagent ? { producedBySubagent: true as const } : {}) + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) }) } }, @@ -50,8 +49,7 @@ export function createStructuredAgentSessionResolvedAppend( } await bound.journal.appendItem(identity, body, { fence: bound.fence, - ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), - ...(options.producedBySubagent ? { producedBySubagent: true as const } : {}) + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) }) bound.publish() } diff --git a/src/shared/agent-session-journal-producer.ts b/src/shared/agent-session-journal-producer.ts deleted file mode 100644 index 37d342c5a21..00000000000 --- a/src/shared/agent-session-journal-producer.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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 e8c0f6c3783..ca95cd2892c 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -243,8 +243,7 @@ export const AgentJournalRenderItemSchema = z.object({ body: AgentJournalItemBodySchema, sequence: z.number().int(), observedAt: z.number(), - recovered: z.literal(true).optional(), - producedBySubagent: z.literal(true).optional() + recovered: 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 d2a02aa8e0d..287e07e2a5d 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -261,11 +261,6 @@ 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 2732b9145d9..b9ce5f2c022 100644 --- a/src/shared/structured-agent-session-live-turn.test.ts +++ b/src/shared/structured-agent-session-live-turn.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from './agent-session-journal-types' -import { - activeStructuredAgentSessionToolCall, - isStructuredAgentSessionThinking -} from './structured-agent-session-live-turn' +import { isStructuredAgentSessionThinking } from './structured-agent-session-live-turn' function item( itemId: string, @@ -114,62 +111,3 @@ 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 d1cdf21c3b3..b53651acf1c 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -2,17 +2,12 @@ // 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( @@ -81,13 +76,12 @@ export function isStructuredAgentSessionThinking( ): boolean { let newestContentIsReasoning: boolean | null = null for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - const body = item?.body + const body = items[index]?.body const turn = readAgentJournalTurn(body) if (turn) { return turn.state === 'running' && newestContentIsReasoning === true } - if (newestContentIsReasoning !== null || !isRootAgentJournalItem(item)) { + if (newestContentIsReasoning !== null) { continue } if (body?.kind === 'message') { @@ -105,20 +99,18 @@ export function isStructuredAgentSessionThinking( return false } -/** 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. */ +/** 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. */ export function activeStructuredAgentSessionToolCall( items: readonly AgentJournalRenderItem[] ): AgentJournalToolCallItem | null { for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - const body = item?.body + const body = items[index]?.body if (readAgentJournalTurn(body)) { return null } - if (body?.kind === 'tool-call' && body.state === 'running' && isRootAgentJournalItem(item)) { + if (body?.kind === 'tool-call' && body.state === 'running') { return body } } diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 253899b071f..928180e7a6c 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -7,9 +7,6 @@ import { hasPersistedStructuredAgentSessionTurn, hasUnansweredStructuredAgentSessionDispatch, projectStructuredItemToNativeChat, - projectStructuredItemsToNativeChat, - latestStructuredAgentSessionAssistantMessage, - activeStructuredAgentSessionToolCall, projectStructuredAgentSessionStatus, projectStructuredAgentSessionStatusSummary, structuredAgentSessionPaneKey @@ -516,106 +513,3 @@ 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 09ce2674afe..6032f5cc86e 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -4,7 +4,6 @@ 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 @@ -136,9 +135,6 @@ 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[] { @@ -174,9 +170,6 @@ 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 { @@ -243,9 +236,7 @@ function messageProse(blocks: readonly NativeChatBlock[]): string { return blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') } -/** 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. */ +/** The newest user prompt, as the sidebar quotes it. */ export function latestStructuredAgentSessionPrompt( items: readonly AgentJournalRenderItem[] ): string { @@ -258,30 +249,20 @@ 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' && - isRootAgentJournalItem(item) - ) { + if (item?.body.kind === 'message' && item.body.role === 'user') { return item } } return null } -/** 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. */ +/** The newest assistant prose in the latest user turn. 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 item = items[index] - const body = item?.body - if (!isRootAgentJournalItem(item)) { - continue - } + const body = items[index]?.body if (body?.kind === 'message' && body.role === 'user') { return '' }