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
This commit is contained in:
Brennan Benson
2026-09-21 11:31:43 -07:00
committed by GitHub
parent 83128ea7ae
commit 33ba1ff3df
23 changed files with 685 additions and 80 deletions
+32 -11
View File
@@ -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,
@@ -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))
@@ -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<string, AgentJournalItemIdentity>()
// 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<string, { producedBySubagent?: true }>()
const latestText = new Map<string, string>()
const checkpointLengths = new Map<string, number>()
@@ -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()
}
@@ -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<string, unknown>[]
) {
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<string, unknown>) {
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)
})
})
@@ -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)
@@ -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<string, unknown>,
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
}
@@ -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(
@@ -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()
})
})
@@ -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(
@@ -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<JournalItemRow> {
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 } : {})
}
}
@@ -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)
})
})
@@ -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. */
@@ -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 = {
@@ -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[] = []
@@ -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
@@ -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()
}
@@ -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<AgentJournalRenderItem, 'producedBySubagent'> | undefined
): boolean {
return item?.producedBySubagent !== true
}
+2 -1
View File
@@ -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({
@@ -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 ────────────────────────────────────────────────────────────
@@ -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()
})
})
@@ -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
}
}
@@ -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')
})
})
@@ -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<AgentJournalRenderItem, NativeChatMessage | null>()
/** 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 ''
}