diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index c72c8927c5a..a5c7a838cfd 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -171,6 +171,36 @@ describe('buildMobileNativeChatTransientData', () => { expect(folded.map((message) => message.id)).toEqual(['failure', 'sentence', 'a1']) }) + it('draws a turn’s subagents, each written as its own row, as one roster row', () => { + const own = (id: string): NativeChatMessage => ({ + id: `subagent:${id}`, + role: 'system', + blocks: [ + { type: 'text', text: `Kicked off subagent ${id}` }, + { type: 'subagent', id, label: id, state: 'working', groupId: 'turn-1' } + ], + timestamp: 0, + source: 'transcript' + }) + const folded = foldMobileNativeChatMessages([ + user('u1', 'audit'), + own('a'), + assistant('a1', 'started'), + own('b') + ]) + expect(folded.map((message) => message.id)).toEqual(['u1', 'subagent:a', 'a1']) + expect(folded[1]?.blocks).toEqual([ + { + type: 'subagent-group', + groupId: 'turn-1', + agents: [ + { id: 'a', label: 'a', state: 'working' }, + { id: 'b', label: 'b', state: 'working' } + ] + } + ]) + }) + it('renders a lone image marker turn (no caption) as an image-ref block', () => { const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, []) expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 4d96759d92f..66ff533520e 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -7,6 +7,7 @@ import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journa import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' import { isWordlessProviderFrameMessage } from '../../../src/shared/native-chat-provider-frame-summary' import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' +import { foldSubagentItems } from '../../../src/shared/native-chat-subagent-items' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { isImageSourceUserTurn, @@ -64,9 +65,11 @@ export function foldMobileNativeChatMessages( // goes too, before folding, so it never splits a tool run. return stripNoiseMessages( foldToolMessages( - normalizeImageTranscriptMessages( - messages.filter( - (message) => isRootAgentJournalItem(message) && !isWordlessProviderFrameMessage(message) + foldSubagentItems( + normalizeImageTranscriptMessages( + messages.filter( + (message) => isRootAgentJournalItem(message) && !isWordlessProviderFrameMessage(message) + ) ) ) ) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-item.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-item.test.ts new file mode 100644 index 00000000000..cf464c4445b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-subagent-item.test.ts @@ -0,0 +1,153 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AgentJournalItemBodySchema } from '../../../shared/agent-session-journal-schemas' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { isSubagentBlock, type NativeChatSubagentBlock } from '../../../shared/native-chat-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { openAgentSessionJournal } from './journal-store-factory' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { + journaledSubagent, + subagentJournalBody, + subagentJournalItemIdentity +} from './journal-subagent-item' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} + +function child(overrides: Partial = {}): NativeChatSubagentBlock { + return { + type: 'subagent', + id: 'child-1', + label: 'Audit', + state: 'working', + groupId: 'turn-1', + run: 'call-1', + startedAt: 10, + ...overrides + } +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +async function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-subagent-item-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('a subagent’s own journal row', () => { + it('is one key per child, whatever its run or state', () => { + expect(subagentJournalItemIdentity('child-1')).toEqual(subagentJournalItemIdentity('child-1')) + expect(subagentJournalItemIdentity('child-1')).not.toEqual( + subagentJournalItemIdentity('child-2') + ) + // Two long ids sharing a head stay two rows. + const head = 'x'.repeat(600) + expect(subagentJournalItemIdentity(`${head}a`)).not.toEqual( + subagentJournalItemIdentity(`${head}b`) + ) + }) + + it('carries the block beside the sentence an older client draws instead', () => { + const body = subagentJournalBody(child()) + expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true) + const broken = { ...body, blocks: [{ type: 'subagent', id: 'child-1', label: 'Audit' }] } + expect(AgentJournalItemBodySchema.safeParse(broken).success).toBe(false) + expect(body.blocks).toEqual([{ type: 'text', text: 'Kicked off subagent Audit' }, child()]) + const stopped = subagentJournalBody( + child({ state: 'stopped', settledAt: 20, outcomeBasis: 'stop-acknowledged' }) + ) + expect(stopped.blocks[0]).toEqual({ type: 'text', text: 'Ran subagent Audit (stopped)' }) + }) + + it('bounds the provider strings it stores', () => { + const body = subagentJournalBody(child({ label: '😀'.repeat(400), id: 'i'.repeat(900) })) + const block = body.blocks.find(isSubagentBlock)! + expect(block.label.length).toBeLessThanOrEqual(512) + expect(block.label.at(-1)).toBe('…') + // No lone surrogate before the ellipsis. + expect(block.label.at(-2)).toMatch(/[\uDC00-\uDFFF]/) + expect(block.id.length).toBe(512) + }) + + it('is read back by key, and every revision stays the row its spawn created', async () => { + const journal = await open() + await journal.appendItem( + subagentJournalItemIdentity('earlier'), + subagentJournalBody(child({ id: 'earlier' })), + { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + await journal.appendItem(subagentJournalItemIdentity('child-1'), subagentJournalBody(child()), { + fence: 0, + turnScope: { kind: 'turn', turnItemId: 'turn-1' } + }) + const spawned = journal.snapshot().items.at(-1)! + await journal.appendItem( + subagentJournalItemIdentity('child-1'), + subagentJournalBody(child({ state: 'completed', run: 'call-2', settledAt: 30 })), + { fence: 0, turnScope: { kind: 'turn', turnItemId: 'turn-9' } } + ) + + expect(journaledSubagent(journal, 'child-1')).toMatchObject({ + state: 'completed', + run: 'call-2' + }) + expect(journaledSubagent(journal, 'never-spawned')).toBeNull() + const revised = journal.item(agentJournalItemKey(subagentJournalItemIdentity('child-1')))! + expect(revised.revision).toBe(2) + expect(revised.sequence).toBe(spawned.sequence) + expect(revised.turnScope).toEqual({ kind: 'turn', turnItemId: 'turn-1' }) + }) + + it('reads unverifiable once a host that died left it working, twin and all', async () => { + const live = await open() + await live.appendItem(subagentJournalItemIdentity('child-1'), subagentJournalBody(child()), { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await live.close() + + const reopened = await open() + const row = reopened.snapshot().items.at(-1)! + expect(row.recovered).toBe(true) + expect(row.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Ran subagent Audit (unverifiable)' }, + { type: 'subagent', id: 'child-1', state: 'unverifiable', run: 'call-1' } + ] + }) + // Contact was lost at an unknown moment, so no run length is invented. + expect(row.body.kind === 'message' && row.body.blocks[1]).not.toHaveProperty('settledAt') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-item.ts b/src/main/native-chat/agent-session-journal/journal-subagent-item.ts new file mode 100644 index 00000000000..4c416ec35d9 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-subagent-item.ts @@ -0,0 +1,58 @@ +// One subagent's own journal row: its key, its body, and reading it back. +// +// Keyed by the child's own id, so every change and every later run of the child revises the one +// row its spawn created, and a provider process that restarts finds that row by key instead of +// scanning for it. + +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { subagentFallbackText } from '../../../shared/native-chat-subagent-items' +import { MAX_SUBAGENT_FIELD_CHARS } from '../../../shared/native-chat-subagent-summary' +import { isSubagentBlock, type NativeChatSubagentBlock } from '../../../shared/native-chat-types' +import { boundSubagentEntryId } from '../subagent-entry-id-bounds' + +/** Scoped by the journal, which is one session's with one provider. */ +export function subagentJournalItemIdentity(id: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `subagent:${boundSubagentEntryId(id)}` } +} + +/** The row: the block, and the sentence an older client draws in its place. */ +export function subagentJournalBody(block: NativeChatSubagentBlock): AgentJournalMessageItem { + const bounded: NativeChatSubagentBlock = { + ...block, + id: boundSubagentEntryId(block.id), + label: boundLabel(block.label), + groupId: boundSubagentEntryId(block.groupId), + ...(block.parentId !== undefined ? { parentId: boundSubagentEntryId(block.parentId) } : {}), + ...(block.run !== undefined ? { run: boundSubagentEntryId(block.run) } : {}) + } + return { + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: subagentFallbackText(bounded) }, bounded] + } +} + +/** The child's saved row, read by key; null when none was written. */ +export function journaledSubagent( + journal: { itemBody: (itemId: string) => AgentJournalItemBody | null }, + id: string +): NativeChatSubagentBlock | null { + const body = journal.itemBody(agentJournalItemKey(subagentJournalItemIdentity(id))) + return body?.kind === 'message' ? (body.blocks.find(isSubagentBlock) ?? null) : null +} + +/** The bound every reader clips a label to. A clip never splits a surrogate pair: a lone one is + * malformed in a durable row. */ +function boundLabel(label: string): string { + if (label.length <= MAX_SUBAGENT_FIELD_CHARS) { + return label + } + const keep = MAX_SUBAGENT_FIELD_CHARS - 1 + const last = label.charCodeAt(keep - 1) + return `${label.slice(0, last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep)}…` +} diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts index 9a99ecf8d65..e65c6d10990 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts @@ -1,6 +1,6 @@ // A roster row left claiming live children by a host that is gone. // -// The writing host revises its `subagent-group` rows in place while it can see +// The writing host revises its subagent rows in place while it can see // the children, and sweeps whatever is still `working` when the provider goes // away. A host that DIED — crash, quit, force-restart — does neither: its last // revision goes on saying `working`, and nothing replays those children, so no @@ -32,11 +32,14 @@ import { isSettledBackgroundTaskState, normalizeBackgroundTaskState } from '../../../shared/native-chat-background-task-row' +import { subagentFallbackText } from '../../../shared/native-chat-subagent-items' import { isBackgroundTaskBlock, + isSubagentBlock, isSubagentGroupBlock, type NativeChatBlock, type NativeChatBackgroundTaskBlock, + type NativeChatSubagentBlock, type NativeChatSubagentGroupBlock } from '../../../shared/native-chat-types' @@ -81,7 +84,11 @@ export function lostLiveWorkJournalBody( } function hasStaleLiveWork(block: NativeChatBlock): boolean { - return hasWorkingChild(block) || hasLiveBackgroundTask(block) + return hasWorkingChild(block) || isWorkingSubagent(block) || hasLiveBackgroundTask(block) +} + +function isWorkingSubagent(block: NativeChatBlock): block is NativeChatSubagentBlock { + return isSubagentBlock(block) && normalizeSubagentState(block.state) === 'working' } function hasWorkingChild(block: NativeChatBlock): block is NativeChatSubagentGroupBlock { @@ -103,35 +110,39 @@ function hasLiveBackgroundTask(block: NativeChatBlock): block is NativeChatBackg * down as how long it ran. Readers already draw an unverifiable child with no * stamp as having no known run length. */ function settleBlocks(blocks: readonly NativeChatBlock[], lostAt?: number): NativeChatBlock[] { - const backgroundTaskTwinText = new Map() + const twinText = new Map() const settled = blocks.map((block) => { if (hasWorkingChild(block)) { return settleGroup(block, lostAt) } + if (isWorkingSubagent(block)) { + const next: NativeChatSubagentBlock = { + ...block, + state: 'unverifiable', + ...(lostAt !== undefined ? { settledAt: lostAt } : {}) + } + twinText.set(subagentFallbackText(block), subagentFallbackText(next)) + return next + } if (hasLiveBackgroundTask(block)) { const next = settleBackgroundTask(block) - backgroundTaskTwinText.set( - backgroundTaskFallbackText(block), - backgroundTaskFallbackText(next) - ) + twinText.set(backgroundTaskFallbackText(block), backgroundTaskFallbackText(next)) return next } return block }) - const withBackgroundTaskTwins = settled.map((block) => - block.type === 'text' - ? { ...block, text: backgroundTaskTwinText.get(block.text) ?? block.text } - : block + const withTwins = settled.map((block) => + block.type === 'text' ? { ...block, text: twinText.get(block.text) ?? block.text } : block ) - const rosters = withBackgroundTaskTwins.filter(isSubagentGroupBlock) + const rosters = withTwins.filter(isSubagentGroupBlock) const only = rosters.length === 1 ? rosters[0] : undefined if (!only) { - return withBackgroundTaskTwins + return withTwins } // The plain-text twin is all a client without the block type ever shows, so it // has to move with the block or the two would disagree about the same row. const twin = subagentGroupFallbackText(only.agents) - return withBackgroundTaskTwins.map((block) => + return withTwins.map((block) => block.type === 'text' && isSubagentGroupFallbackText(block.text) ? { ...block, text: twin } : block diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts index 9cbfb94a907..fbb73a6a1d4 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts @@ -20,6 +20,10 @@ import { readAgentSessionHydrationPage } from './agent-session-history-page' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + subagentJournalBody, + subagentJournalItemIdentity +} from '../agent-session-journal/journal-subagent-item' // A backward page windows over the session's own rows, and a subagent's rows in // that range ride along, so a burst cannot crowd the conversation off the page. @@ -146,6 +150,28 @@ describe('a history page over a session with a subagent', () => { expect(tail.hasOlder).toBe(false) }) + it("carries a fan-out's subagent rows without counting them as conversation", async () => { + await append(named('own-', 1, 5)) + for (const id of named('task-', 1, 300)) { + await journal.appendItem( + subagentJournalItemIdentity(id), + subagentJournalBody({ type: 'subagent', id, label: id, state: 'working', groupId: 't' }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await append(['own-6']) + + const tail = read({ direction: 'tail', limit: 3 }) + + expect(textsOf(tail)).toEqual([ + 'own-4', + 'own-5', + ...named('Kicked off subagent task-', 1, 300), + 'own-6' + ]) + expect(tail.hasOlder).toBe(true) + }) + it('still bounds a page by bytes, and paging back still reaches every row', async () => { const large = 'x'.repeat(250 * 1024) await append(['own-1']) @@ -185,6 +211,33 @@ describe('a history page names the subagents whose roster row is older than it', ]) }) + it('names a subagent whose own row is above the opening page, by that row', async () => { + await append(['own-1']) + await journal.appendItem( + subagentJournalItemIdentity('task-1'), + subagentJournalBody({ + type: 'subagent', + id: 'task-1', + label: 'run task-1', + state: 'working', + groupId: 'turn-1', + run: 'call-1' + }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await append(named('own-', 2, 200)) + await append(['child-2'], child) + + const page = readAgentSessionHydrationPage(journal) + + expect(page.subagentRoster).toEqual([ + expect.objectContaining({ + itemId: 'orca:subagent%3Atask-1', + entry: { id: 'task-1', label: 'run task-1', state: 'working' } + }) + ]) + }) + it('names nothing beside the items when the page carries the roster row', async () => { await append(['own-1']) await appendRoster(['task-1']) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index b1667daa512..acd0240c23f 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -10,6 +10,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' import { latestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn' +import { isSubagentBlock } from '../../../shared/native-chat-types' import type { AgentJournalCursor, AgentJournalRenderItem, @@ -55,17 +56,18 @@ export type AgentSessionHistoryScope = 'every-agent' | 'own-agent' /** * The newest `limit` of the session's own rows, in whole sequence groups, and every - * row after the oldest of them: a subagent's rows ride along with the conversation - * they happened in, so its burst cannot crowd the conversation off the page. The page - * stays contiguous and starts at its first item, as the cursor means. Fewer own rows - * than `limit` reach back to the start. With no subagent rows this is the newest - * `limit` rows, as before. + * row after the oldest of them: a subagent's rows, and each subagent's own row, ride + * along with the conversation they happened in, so a burst or a fan-out cannot crowd + * the conversation off the page. The page stays contiguous and starts at its first + * item, as the cursor means; the byte budget still bounds it. Fewer own rows than + * `limit` reach back to the start. With no subagent rows this is the newest `limit` + * rows, as before. */ function conversationWindow( items: readonly AgentJournalRenderItem[], limit: number ): AgentJournalRenderItem[] { - const own = items.filter(isRootAgentJournalItem) + const own = items.filter((item) => isRootAgentJournalItem(item) && !isSubagentRow(item)) if (own.length === items.length) { return newestWholeSequenceGroups(items, limit) } @@ -74,6 +76,10 @@ function conversationWindow( return start === undefined ? [...items] : items.filter((item) => item.sequence >= start) } +function isSubagentRow(item: AgentJournalRenderItem): boolean { + return item.body.kind === 'message' && item.body.blocks.some(isSubagentBlock) +} + export function readAgentSessionHistory( journal: AgentSessionJournal, request: AgentSessionHistoryRequest, diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-subagent-roster.ts b/src/main/native-chat/agent-session-wire/agent-session-history-subagent-roster.ts index adf24fb9444..9818a2b9381 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-subagent-roster.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-subagent-roster.ts @@ -11,10 +11,8 @@ import { } from '../../../shared/agent-session-journal-producer' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubagentRosterEntry } from '../../../shared/agent-session-wire' -import { - isSubagentGroupBlock, - type NativeChatSubagentEntry -} from '../../../shared/native-chat-types' +import { namedSubagents } from '../../../shared/native-chat-subagent-items' +import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' /** Bounds the field well inside the page's envelope reserve. */ const MAX_ENTRIES = 64 @@ -24,7 +22,7 @@ function rosterEntries(item: AgentJournalRenderItem): readonly NativeChatSubagen if (!isRootAgentJournalItem(item) || item.body.kind !== 'message') { return [] } - return item.body.blocks.flatMap((block) => (isSubagentGroupBlock(block) ? block.agents : [])) + return item.body.blocks.flatMap(namedSubagents) } /** `journal` in journal order; `page` a run of it. Undefined when the page names every diff --git a/src/main/runtime/orchestration/worker-transcript-activity-block-bounds.ts b/src/main/runtime/orchestration/worker-transcript-activity-block-bounds.ts index 6e4d682da74..47ef5e94e18 100644 --- a/src/main/runtime/orchestration/worker-transcript-activity-block-bounds.ts +++ b/src/main/runtime/orchestration/worker-transcript-activity-block-bounds.ts @@ -7,7 +7,7 @@ import type { NativeChatBlock, NativeChatSubagentState } from '../../../shared/n type WorkerTranscriptActivityBlock = Extract< NativeChatBlock, - { type: 'subagent-group' | 'background-task' } + { type: 'subagent-group' | 'subagent' | 'background-task' } > export type WorkerTranscriptActivityBlockBounders = { @@ -39,6 +39,17 @@ export function boundWorkerTranscriptActivityBlock( })) } } + if (block.type === 'subagent') { + return { + ...block, + id: bounders.boundEntryId(block.id), + label: bounders.clipMetadata(block.label), + state: clipSubagentState(block.state, bounders), + groupId: bounders.clipMetadata(block.groupId), + ...(block.parentId !== undefined ? { parentId: bounders.boundEntryId(block.parentId) } : {}), + ...(block.run !== undefined ? { run: bounders.clipMetadata(block.run) } : {}) + } + } const { outputFile, ...carried } = block if (outputFile) { bounders.markClipped('Background task output paths were omitted from transcript output.') diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index 2a05d74a645..9f7e829deeb 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -139,7 +139,11 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native input: boundToolInput(block.input, budget, 0, state) } } - if (block.type === 'subagent-group' || block.type === 'background-task') { + if ( + block.type === 'subagent-group' || + block.type === 'subagent' || + block.type === 'background-task' + ) { return boundWorkerTranscriptActivityBlock(block, { clipMetadata: (value) => clipMetadata(value, state), clipText: (value) => clipText(value, state), diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts index de25ea7e45d..2817ffc0dc0 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts @@ -32,4 +32,21 @@ describe('mobile subagent roster bounds', () => { const block = sanitizeNativeChatRpcBlock(rosterBlock(['task-1']), 'mobile') expect(block.type === 'subagent-group' && block.agents[0]?.id).toBe('task-1') }) + + it('bounds a subagent’s own row like a roster’s entry, and leaves other clients its fields', () => { + const own: NativeChatBlock = { + type: 'subagent', + id: `${'p'.repeat(512)}a`, + label: 'l'.repeat(600), + state: 'working', + groupId: 'turn-1', + run: 'call-1', + outcomeBasis: 'stop-acknowledged' + } + const block = sanitizeNativeChatRpcBlock(own, 'mobile') + expect(block).toMatchObject({ type: 'subagent', groupId: 'turn-1', run: 'call-1' }) + expect(block.type === 'subagent' && block.id.length).toBe(512) + expect(block.type === 'subagent' && block.label.length).toBeLessThan(600) + expect(sanitizeNativeChatRpcBlock(own, 'runtime')).toBe(own) + }) }) diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts index 79f1da4fc53..a231c620581 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts @@ -72,6 +72,17 @@ export function sanitizeNativeChatRpcBlock( })) } } + if (block.type === 'subagent') { + return { + ...block, + id: boundSubagentEntryId(block.id), + label: clip(block.label, MAX_SUBAGENT_FIELD_CHARS), + state: clipSubagentState(block.state), + groupId: clip(block.groupId, MAX_SUBAGENT_FIELD_CHARS), + ...(block.parentId !== undefined ? { parentId: boundSubagentEntryId(block.parentId) } : {}), + ...(block.run !== undefined ? { run: clip(block.run, MAX_SUBAGENT_FIELD_CHARS) } : {}) + } + } return block } diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts b/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts index 323bd0d820f..45f5de33044 100644 --- a/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts @@ -240,6 +240,34 @@ describe("a subagent's rows live in its own section", () => { expect(new Set(slots.map(nativeChatSlotKey)).size).toBe(slots.length) }) + it('opens each under the row its turn’s subagents draw as, from their own rows alike', () => { + const own = (agentId: string, label: string) => + row( + `subagent:${agentId}`, + [ + { type: 'text', text: `Ran subagent ${label}` }, + { type: 'subagent', id: agentId, label, state: 'completed', groupId: 'turn-1' } + ], + { role: 'system' } + ) + const rows = [ + row('ask', say('review the PR'), { role: 'user' }), + own('task-1', 'explore'), + row('child-look', say('Looking at the diff.'), by('task-1')), + row('answer', say('Delegated.')), + own('task-2', 'explore'), + row('child-2-look', say('Me too.'), by('task-2')) + ] + expect(outline(slotsOf(rows, { 'task-2': true }))).toEqual([ + 'ask', + 'subagent:task-1', + '•task-1', + '•task-2 open', + '>child-2-look', + 'answer' + ]) + }) + // The parent's live line speaks for the session's own agent only, so a child's open block draws. it("draws a working agent's open reasoning in its section", () => { const thinking = row('child-think', say('Comparing the two diffs'), { diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 9e526269351..5cd2f87caf2 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -110,6 +110,15 @@ const Block = openDiscriminatedUnion( groupId: z.string(), agents: z.array(SubagentEntry) }), + // `outcomeBasis` and `background` stay open for the same reason `state` does. + SubagentEntry.extend({ + type: z.literal('subagent'), + groupId: z.string(), + parentId: z.string().optional(), + run: z.string().optional(), + outcomeBasis: z.string().optional(), + background: z.boolean().optional() + }), // `kind` and `state` stay open strings for the same reason a child's // lifecycle does: a vocabulary a newer build writes must not turn the row // malformed. The renderer falls back on anything it cannot name. diff --git a/src/shared/native-chat-subagent-items.test.ts b/src/shared/native-chat-subagent-items.test.ts new file mode 100644 index 00000000000..97f7ba227a6 --- /dev/null +++ b/src/shared/native-chat-subagent-items.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { + foldSubagentItems, + namedSubagents, + subagentFallbackText +} from './native-chat-subagent-items' +import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection' +import { formatWorkerTranscriptMessage } from './worker-transcript-text' +import type { + NativeChatMessage, + NativeChatSubagentBlock, + NativeChatSubagentGroupBlock +} from './native-chat-types' + +function subagent( + id: string, + overrides: Partial = {} +): NativeChatSubagentBlock { + return { type: 'subagent', id, label: id, state: 'working', groupId: 'turn-1', ...overrides } +} + +function item(sequence: number, block: NativeChatSubagentBlock): NativeChatMessage { + return { + id: `subagent:${block.id}`, + role: 'system', + blocks: [{ type: 'text', text: subagentFallbackText(block) }, block], + timestamp: sequence, + source: 'transcript', + journalPosition: { sequence, index: 0 } + } +} + +function text(sequence: number, role: NativeChatMessage['role'], body: string): NativeChatMessage { + return { + id: `row-${sequence}`, + role, + blocks: [{ type: 'text', text: body }], + timestamp: sequence, + source: 'transcript', + journalPosition: { sequence, index: 0 } + } +} + +function groupOf(message: NativeChatMessage | undefined): NativeChatSubagentGroupBlock { + const [block] = message?.blocks ?? [] + if (block?.type !== 'subagent-group') { + throw new Error('expected a roster row') + } + return block +} + +describe('a turn’s subagents drawn as one row', () => { + it('folds siblings into one roster row at the first one’s place, whatever lies between', () => { + const rows = foldSubagentItems([ + text(1, 'user', 'audit it'), + item(2, subagent('a', { label: 'Audit' })), + text(3, 'assistant', 'and another'), + item(4, subagent('b', { label: 'Review', state: 'completed', settledAt: 9 })) + ]) + + expect(rows.map((row) => row.id)).toEqual(['row-1', 'subagent:a', 'row-3']) + expect(groupOf(rows[1])).toEqual({ + type: 'subagent-group', + groupId: 'turn-1', + agents: [ + { id: 'a', label: 'Audit', state: 'working' }, + { id: 'b', label: 'Review', state: 'completed', settledAt: 9 } + ] + }) + }) + + it('keeps another turn’s, or another spawner’s, subagents in rows of their own', () => { + const rows = foldSubagentItems([ + item(1, subagent('a')), + item(2, subagent('b', { groupId: 'turn-2' })), + item(3, subagent('c', { parentId: 'a' })) + ]) + + expect(rows.map((row) => groupOf(row).agents.map((agent) => agent.id))).toEqual([ + ['a'], + ['b'], + ['c'] + ]) + // The phone keys a group's open state by groupId, so a helper's helpers get their own. + expect(new Set(rows.map((row) => groupOf(row).groupId)).size).toBe(3) + }) + + it('tells same-named siblings apart without taking a name a sibling already has', () => { + const rows = foldSubagentItems([ + item(1, subagent('a', { label: 'Audit' })), + item(2, subagent('b', { label: 'Audit' })), + item(3, subagent('c', { label: 'Audit 2' })) + ]) + + expect(groupOf(rows[0]).agents.map((agent) => agent.label)).toEqual([ + 'Audit', + 'Audit 3', + 'Audit 2' + ]) + }) + + it('hands back the same row while no sibling changed, and a new one when one did', () => { + const a = item(1, subagent('a')) + const b = item(2, subagent('b')) + const first = foldSubagentItems([a, b])[0] + expect(foldSubagentItems([a, b])[0]).toBe(first) + + const settled = item(2, subagent('b', { state: 'completed' })) + const next = foldSubagentItems([a, settled])[0] + expect(next).not.toBe(first) + // The sibling that did not change keeps its line, so its memoized row stays put. + expect(groupOf(next).agents[0]).toBe(groupOf(first).agents[0]) + }) + + it('leaves an older host’s roster row, and rows that are not one subagent, as they came', () => { + const roster: NativeChatMessage = { + ...text(1, 'system', 'Ran 1 subagent'), + blocks: [ + { type: 'text', text: 'Ran 1 subagent' }, + { + type: 'subagent-group', + groupId: 'turn-0', + agents: [{ id: 'old', label: 'old', state: 'completed' }] + } + ] + } + const mixed: NativeChatMessage = { + ...text(2, 'assistant', 'x'), + blocks: [subagent('z'), { type: 'tool-call', name: 'Agent', input: {} }] + } + const messages = [roster, mixed] + expect(foldSubagentItems(messages)).toBe(messages) + }) + + it('is what the transcript draws, so the row folds no tool run apart', () => { + const rows = projectNativeChatTranscriptMessages([ + text(1, 'user', 'go'), + { + ...text(2, 'assistant', ''), + blocks: [{ type: 'tool-call', name: 'Agent', input: {}, callId: 'c1' }] + }, + item(3, subagent('a')), + { ...text(4, 'tool', ''), blocks: [{ type: 'tool-result', output: 'ok', callId: 'c1' }] } + ]) + + expect(rows.map((row) => row.blocks.map((block) => block.type))).toEqual([ + ['text'], + ['tool-call', 'tool-result'], + ['subagent-group'] + ]) + }) +}) + +describe('the sentence an older client draws', () => { + it('claims no liveness it cannot keep, and names an adverse outcome', () => { + expect(subagentFallbackText(subagent('a', { label: 'Audit' }))).toBe( + 'Kicked off subagent Audit' + ) + expect(subagentFallbackText(subagent('a', { label: 'Audit', state: 'completed' }))).toBe( + 'Ran subagent Audit' + ) + expect(subagentFallbackText(subagent('a', { label: 'Audit', state: 'failed' }))).toBe( + 'Ran subagent Audit (failed)' + ) + }) + + it('is what a text surface prints, once, and the block alone still prints it', () => { + const own = item(1, subagent('a', { label: 'Audit' })) + expect(formatWorkerTranscriptMessage(own)).toBe('[system] Kicked off subagent Audit') + expect(formatWorkerTranscriptMessage({ ...own, blocks: own.blocks.slice(1) })).toBe( + '[system] [subagents] Kicked off subagent Audit' + ) + }) +}) + +describe('the subagents a row names', () => { + it('reads an older host’s roster and a subagent’s own row alike', () => { + expect( + namedSubagents(subagent('a', { run: 'r', outcomeBasis: 'stop-acknowledged', tokens: 3 })) + ).toEqual([{ id: 'a', label: 'a', state: 'working', tokens: 3 }]) + expect( + namedSubagents({ + type: 'subagent-group', + groupId: 'g', + agents: [{ id: 'b', label: 'b', state: 'idle' }] + }) + ).toEqual([{ id: 'b', label: 'b', state: 'idle' }]) + expect(namedSubagents({ type: 'text', text: 'x' })).toEqual([]) + }) +}) diff --git a/src/shared/native-chat-subagent-items.ts b/src/shared/native-chat-subagent-items.ts new file mode 100644 index 00000000000..0425e32550d --- /dev/null +++ b/src/shared/native-chat-subagent-items.ts @@ -0,0 +1,171 @@ +// A subagent's own row, and the group its siblings draw as. +// +// The host writes one row per subagent, where it was spawned, and revises that row alone for +// every change, so no row ever lists siblings and nothing caps how many a turn spawns. "Ran N +// subagents" is how readers draw the siblings one turn spawned: every transcript (desktop and +// the phone) folds them here into the roster row older hosts wrote, at the first sibling's +// place, so one renderer draws both. Ordinals that tell same-named siblings apart are derived +// here too, never stored. + +import { normalizeSubagentState } from './native-chat-subagent-summary' +import { + isSubagentBlock, + isSubagentGroupBlock, + type NativeChatBlock, + type NativeChatMessage, + type NativeChatSubagentBlock, + type NativeChatSubagentEntry, + type NativeChatSubagentGroupBlock +} from './native-chat-types' + +/** The plain sentence an older client draws in the block's place, frozen at write time. Like the + * group's, it claims no live count: `Kicked off` only says no outcome had landed yet. */ +export function subagentFallbackText(block: NativeChatSubagentEntry): string { + const state = normalizeSubagentState(block.state) + if (state === 'working') { + return `Kicked off subagent ${block.label}` + } + const adverse = state === 'failed' || state === 'stopped' || state === 'unverifiable' + return `Ran subagent ${block.label}${adverse ? ` (${state})` : ''}` +} + +/** The entry a group row draws for this subagent: the block less what only its writer reads. */ +export function subagentEntryOf( + block: NativeChatSubagentBlock, + label = block.label +): NativeChatSubagentEntry { + return { + id: block.id, + label, + state: block.state, + ...(block.tokens !== undefined ? { tokens: block.tokens } : {}), + ...(block.startedAt !== undefined ? { startedAt: block.startedAt } : {}), + ...(block.settledAt !== undefined ? { settledAt: block.settledAt } : {}) + } +} + +/** The subagents a block names: an older host's group row lists them, a subagent's own row is one. */ +export function namedSubagents(block: NativeChatBlock): readonly NativeChatSubagentEntry[] { + if (isSubagentGroupBlock(block)) { + return block.agents + } + return isSubagentBlock(block) ? [subagentEntryOf(block)] : [] +} + +/** The subagent a row is, when it is one: its block, beside nothing but its twin sentence. */ +function subagentItemBlock(message: NativeChatMessage): NativeChatSubagentBlock | null { + let found: NativeChatSubagentBlock | null = null + for (const block of message.blocks) { + if (isSubagentBlock(block) && found === null) { + found = block + } else if (block.type !== 'text') { + return null + } + } + return found +} + +type Folded = { members: readonly NativeChatMessage[]; row: NativeChatMessage } + +/** Keyed by the first sibling: an unchanged group hands back the same row, so the transcript's + * memoized rows stay put while one sibling streams. */ +const foldedGroups = new WeakMap() +const foldedEntries = new WeakMap() + +/** `messages` in transcript order. Sibling subagent rows become one roster row at the first + * sibling's place; every other row is handed back as it came. */ +export function foldSubagentItems( + messages: readonly NativeChatMessage[] +): readonly NativeChatMessage[] { + const groups = new Map() + let any = false + for (const message of messages) { + const block = subagentItemBlock(message) + if (block === null) { + continue + } + any = true + // Also the folded row's groupId: a helper's own helpers keep open state apart from the turn's. + const key = + block.parentId === undefined ? block.groupId : JSON.stringify([block.groupId, block.parentId]) + const siblings = groups.get(key) + if (siblings) { + siblings.push(message) + } else { + groups.set(key, [message]) + } + } + if (!any) { + return messages + } + const rows = new Map() + for (const [groupId, members] of groups) { + const [first, ...rest] = members + rows.set(first!, groupRow(groupId, members)) + for (const member of rest) { + rows.set(member, null) + } + } + const folded: NativeChatMessage[] = [] + for (const message of messages) { + const row = rows.get(message) + if (row === undefined) { + folded.push(message) + } else if (row !== null) { + folded.push(row) + } + } + return folded +} + +function groupRow(groupId: string, members: readonly NativeChatMessage[]): NativeChatMessage { + const first = members[0]! + const held = foldedGroups.get(first) + if ( + held && + held.members.length === members.length && + held.members.every((member, index) => member === members[index]) + ) { + return held.row + } + const blocks = members.map((member) => subagentItemBlock(member)!) + const group: NativeChatSubagentGroupBlock = { + type: 'subagent-group', + groupId, + agents: labelledEntries(blocks) + } + const row: NativeChatMessage = { ...first, blocks: [group] } + foldedGroups.set(first, { members: [...members], row }) + return row +} + +/** Two siblings can share a name; an ordinal keeps their lines apart without inventing a name the + * provider never sent. Probed over the labels already taken, so a derived `Audit 2` never + * collides with a sibling the provider itself named `Audit 2`. */ +function labelledEntries(blocks: readonly NativeChatSubagentBlock[]): NativeChatSubagentEntry[] { + const taken = new Set() + const repeated = new Set() + for (const block of blocks) { + if (taken.has(block.label)) { + repeated.add(block.label) + } + taken.add(block.label) + } + const claimed = new Set() + return blocks.map((block) => { + let label = block.label + if (repeated.has(label) && claimed.has(label)) { + for (let ordinal = 2; taken.has(label) || claimed.has(label); ordinal++) { + label = `${block.label} ${ordinal}` + } + } + claimed.add(label) + const held = foldedEntries.get(block) + if (held?.label === label) { + return held + } + const entry = subagentEntryOf(block, label) + foldedEntries.set(block, entry) + return entry + }) +} diff --git a/src/shared/native-chat-transcript-projection.ts b/src/shared/native-chat-transcript-projection.ts index 70d6283341c..97278ffc4e8 100644 --- a/src/shared/native-chat-transcript-projection.ts +++ b/src/shared/native-chat-transcript-projection.ts @@ -12,6 +12,7 @@ import { compareAgentJournalPositions } from './agent-session-journal-position' import { agentJournalItemSubagentId } from './agent-session-journal-producer' import { stripNoiseMessages } from './native-chat-noise' import { foldToolMessages } from './native-chat-tool-fold' +import { foldSubagentItems } from './native-chat-subagent-items' import { nativeChatTurnMembership, type NativeChatTurnJournal } from './native-chat-turn-membership' /** Timestamp, then id. A null timestamp sorts first so a source that cannot supply @@ -85,7 +86,7 @@ export type NativeChatTranscriptProjection = { const NO_SUBAGENT_ROWS: ReadonlyMap = new Map() function projectRows(messages: readonly NativeChatMessage[]): NativeChatMessage[] { - return stripNoiseMessages(foldToolMessages(messages)) + return stripNoiseMessages(foldToolMessages(foldSubagentItems(messages))) } function sortedCopy( diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 28e9f86f9f3..ea35e3ac531 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -162,6 +162,24 @@ export type NativeChatSubagentGroupBlock = { agents: NativeChatSubagentEntry[] } +/** One subagent's own row: written where it was spawned and revised in place for every later + * change and run. Siblings sharing `groupId` and `parentId` draw as one group, which readers + * derive; no row lists them together. */ +export type NativeChatSubagentBlock = NativeChatSubagentEntry & { + type: 'subagent' + /** The turn that spawned it: siblings with the same key draw together. */ + groupId: string + /** The subagent that spawned it. Absent ⇒ the session's own agent did. */ + parentId?: string + /** The provider's id for the run `state` describes. Absent when the provider names none. */ + run?: string + /** `stop-acknowledged`: stopped because the agent accepted a Stop, not because the child + * reported its own end; that run's own ending may still replace it. */ + outcomeBasis?: 'stop-acknowledged' + /** Told to outlive the turn that spawned it. */ + background?: true +} + /** One provider background task — a shell command, workflow or monitor run * beside the turn — as its own durable row, revised in place from the * provider's lifecycle frames. Sibling of the roster block rather than a @@ -203,6 +221,7 @@ export type NativeChatBlock = | NativeChatToolResultBlock | NativeChatImageRefBlock | NativeChatSubagentGroupBlock + | NativeChatSubagentBlock | NativeChatBackgroundTaskBlock /** A transcript row. Structured rows carry the journal row's producer linkage, so @@ -362,6 +381,10 @@ export function isSubagentGroupBlock( return block.type === 'subagent-group' } +export function isSubagentBlock(block: NativeChatBlock): block is NativeChatSubagentBlock { + return block.type === 'subagent' +} + export function isBackgroundTaskBlock( block: NativeChatBlock ): block is NativeChatBackgroundTaskBlock { diff --git a/src/shared/structured-agent-session-subagent-roster.test.ts b/src/shared/structured-agent-session-subagent-roster.test.ts index 7ddea204a0b..2195978bbe6 100644 --- a/src/shared/structured-agent-session-subagent-roster.test.ts +++ b/src/shared/structured-agent-session-subagent-roster.test.ts @@ -104,4 +104,29 @@ describe('the client roster of subagents', () => { expect(named.has('agent-599')).toBe(true) expect(named.has('agent-0')).toBe(false) }) + + it('names a subagent from its own row, and forgets it with that row', () => { + const own: AgentJournalRenderItem = { + itemId: 'orca:subagent:a', + revision: 1, + sequence: 4, + observedAt: 4, + body: { + kind: 'message', + role: 'system', + blocks: [ + { type: 'text', text: 'Kicked off subagent review' }, + { type: 'subagent', id: 'a', label: 'review', state: 'working', groupId: 't', run: 'x' } + ] + } + } + const named = fold([own]) + expect(named.get('a')).toEqual({ + entry: { id: 'a', label: 'review', state: 'working' }, + rosterItemId: 'orca:subagent:a', + rosterPosition: { sequence: 4, index: 0 }, + rosterRevision: 1 + }) + expect(foldStructuredAgentSubagentRoster(named, [], ['orca:subagent:a']).size).toBe(0) + }) }) diff --git a/src/shared/structured-agent-session-subagent-roster.ts b/src/shared/structured-agent-session-subagent-roster.ts index e95f2a91d87..95305aa942a 100644 --- a/src/shared/structured-agent-session-subagent-roster.ts +++ b/src/shared/structured-agent-session-subagent-roster.ts @@ -15,7 +15,8 @@ import { compareAgentJournalPositions } from './agent-session-journal-position' import type { AgentSessionHistoryPage, AgentSessionSubagentRosterEntry } from './agent-session-wire' -import { isSubagentGroupBlock, type NativeChatSubagentEntry } from './native-chat-types' +import { namedSubagents } from './native-chat-subagent-items' +import type { NativeChatSubagentEntry } from './native-chat-types' /** One subagent as the first roster row naming it records it. */ export type StructuredAgentSubagentRosterEntry = { @@ -93,18 +94,13 @@ export function foldStructuredAgentSubagentRoster( if (!isRootAgentJournalItem(item) || item.body.kind !== 'message') { continue } - for (const block of item.body.blocks) { - if (!isSubagentGroupBlock(block)) { - continue - } - for (const entry of block.agents) { - take({ - entry, - rosterItemId: item.itemId, - rosterPosition: agentJournalItemPosition(item), - rosterRevision: item.revision - }) - } + for (const entry of item.body.blocks.flatMap(namedSubagents)) { + take({ + entry, + rosterItemId: item.itemId, + rosterPosition: agentJournalItemPosition(item), + rosterRevision: item.revision + }) } } const next = draft.next @@ -125,10 +121,8 @@ function rosterAgentIds(item: AgentJournalRenderItem): ReadonlySet | nul if (!isRootAgentJournalItem(item) || item.body.kind !== 'message') { return null } - const groups = item.body.blocks.filter(isSubagentGroupBlock) - return groups.length === 0 - ? null - : new Set(groups.flatMap((group) => group.agents.map((agent) => agent.id))) + const named = item.body.blocks.flatMap(namedSubagents) + return named.length === 0 ? null : new Set(named.map((agent) => agent.id)) } /** Folds a history page: its rows, its removals, and the entries it names beside them. */ diff --git a/src/shared/worker-transcript-text.ts b/src/shared/worker-transcript-text.ts index 45a98a87835..d33481a54c0 100644 --- a/src/shared/worker-transcript-text.ts +++ b/src/shared/worker-transcript-text.ts @@ -11,6 +11,7 @@ import { isSubagentGroupFallbackText, subagentGroupFallbackText } from './native-chat-subagent-summary' +import { subagentFallbackText } from './native-chat-subagent-items' import type { NativeChatMessage } from './native-chat-types' export function formatWorkerTranscriptMessage(message: NativeChatMessage): string { @@ -44,6 +45,12 @@ export function formatWorkerTranscriptMessage(message: NativeChatMessage): strin if (block.type === 'subagent-group') { return standIns.get(index) ?? null } + if (block.type === 'subagent') { + // A subagent's own row is its block and its twin, nothing else. + return message.blocks.some((other) => other.type === 'text') + ? null + : `[subagents] ${subagentFallbackText(block)}` + } // The journal deliberately admits block types this build does not know, and // a newer remote host can send one over the wire. Degrade to a marker rather // than reading fields off a shape that has none.