From e8e144bf3ca6bdc1bdb1a2d92af4872d0ea31b9e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:28:01 -0700 Subject: [PATCH] fix(native-chat): a subagent's words are presented as that subagent's, never the parent's (#23605) * fix(native-chat): a subagent's words are presented as that subagent's, never the parent's The journal already names the agent that produced every row, but the transcript projection dropped it, so a subagent's prose rendered as the parent's reply, its tool calls folded into the parent's runs, and a settled turn could fold down to a subagent's words as its only visible answer. The transcript message now keeps the row's producer. The fold keeps each agent's calls in that agent's own run, a turn's answer is the session's own agent's last prose, and a subagent's row names the subagent on desktop, mobile and a worker's transcript text. * test(native-chat): give the window fixture's slot the attribution field it now carries * fix(mobile): read the subagent label the row is given, and pin the caption * fix(native-chat): keep interleaved agents in order and each agent's own run live Review follow-ups: - the fold is main's adjacency fold plus one condition: a row never folds into another agent's run, so an agent's later call stays below its subagent's work instead of jumping back into its earlier row - each agent has its own live frontier, so a parent still inside its spawn call reads as running while its subagent works below it - mobile names no one on a row whose only content is hidden behind its settled turn - a pending question from a subagent keeps its producer - worker reads serve only the producing agent's id, bounded like the roster key that names it, and drop the provenance fields - the single-message worker formatter is private, so no caller can drop names --- .../session/MobileNativeChatMessage.test.ts | 71 +++++++++++++++++++ .../src/session/MobileNativeChatMessage.tsx | 36 +++++++++- mobile/src/session/MobileNativeChatView.tsx | 8 ++- .../mobile-native-chat-message-styles.ts | 11 +++ .../handlers/orchestration/worker-output.ts | 4 +- .../worker-transcript-payload.test.ts | 45 ++++++++++++ .../worker-transcript-payload.ts | 16 ++++- .../structured-worker-terminal-read.ts | 6 +- .../native-chat/NativeChatMessageList.tsx | 6 +- .../native-chat/NativeChatMessageRow.test.tsx | 38 ++++++++++ .../native-chat/NativeChatMessageRow.tsx | 38 +++++++++- .../native-chat/NativeChatTranscriptRow.tsx | 1 + .../native-chat-row-height-estimate.ts | 6 ++ .../native-chat/native-chat-tool-fold.test.ts | 54 ++++++++++++++ .../native-chat-transcript-slots.test.ts | 69 ++++++++++++++++++ .../native-chat-transcript-slots.ts | 39 ++++++++-- ...ructured-agent-question-projection.test.ts | 16 +++++ .../structured-agent-question-projection.ts | 2 + ...ve-chat-transcript-window.options.test.tsx | 1 + .../src/i18n/en-runtime-required.json | 4 ++ src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 ++ src/renderer/src/i18n/locales/fr.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- src/shared/agent-session-journal-producer.ts | 10 ++- .../native-chat-subagent-attribution.ts | 42 +++++++++++ src/shared/native-chat-tool-fold.ts | 20 ++++-- src/shared/native-chat-turn-fold.test.ts | 30 ++++++++ src/shared/native-chat-turn-fold.ts | 16 ++++- src/shared/native-chat-types.ts | 7 +- ...tructured-agent-session-projection.test.ts | 12 ++++ .../structured-agent-session-projection.ts | 7 +- ...er-transcript-background-task-text.test.ts | 5 +- ...er-transcript-subagent-attribution.test.ts | 49 +++++++++++++ .../worker-transcript-text-scaling.test.ts | 5 +- src/shared/worker-transcript-text.ts | 32 ++++++++- 38 files changed, 688 insertions(+), 42 deletions(-) create mode 100644 src/shared/native-chat-subagent-attribution.ts create mode 100644 src/shared/worker-transcript-subagent-attribution.test.ts diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 67e90132866..935fe2ef0f3 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -285,3 +285,74 @@ describe('MobileNativeChatMessage', () => { }) }) }) + +describe("MobileNativeChatMessage — a subagent's row speaks as that subagent", () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function renderAgentRow(agentId: string | undefined, subagentLabel?: string): ReactTestRenderer { + const message: NativeChatMessage = { + id: 'a1', + role: 'assistant', + blocks: [{ type: 'text', text: 'The PR is CLEAN.' }], + timestamp: null, + source: 'transcript', + ...(agentId === undefined ? {} : { agentId }) + } + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message, subagentLabel })) + }) + return renderer! + } + + const captions = (tree: ReactTestRenderer): ReactTestInstance[] => + tree.root + .findAllByType('Text' as never) + .filter((node) => typeof node.props.accessibilityLabel === 'string') + + it('names the subagent that wrote the row', () => { + const [caption] = captions(renderAgentRow('task-1', 'review the PR')) + expect(caption?.props.accessibilityLabel).toBe('Written by subagent review the PR') + expect(caption?.children.join('')).toBe('review the PR') + }) + + it('still marks the row as a subagent when no loaded roster names it', () => { + const [caption] = captions(renderAgentRow('task-9')) + expect(caption?.children.join('')).toBe('Subagent') + }) + + it("adds nothing to the session's own row", () => { + expect(captions(renderAgentRow(undefined, 'review the PR'))).toEqual([]) + }) + + it('names no one on a settled row whose only content is hidden, and names the live one', () => { + const toolOnly: NativeChatMessage = { + id: 'a2', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Grep', input: {}, state: 'completed' }], + timestamp: null, + source: 'transcript', + agentId: 'task-1' + } + const renderToolOnly = (activeTurnIsWorking: boolean): ReactTestRenderer => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessage, { + message: toolOnly, + subagentLabel: 'review the PR', + structuredActivityUi: true, + activeTurnIsWorking + }) + ) + }) + return renderer! + } + expect(captions(renderToolOnly(false))).toEqual([]) + act(() => renderer?.unmount()) + expect(captions(renderToolOnly(true))).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index a290a999161..08feb9bc7ce 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -4,6 +4,8 @@ import { Image, Text as NativeText, View } from 'react-native' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' +import { agentJournalItemSubagentId } from '../../../src/shared/agent-session-journal-producer' +import { NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY } from '../../../src/shared/native-chat-subagent-attribution' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileMarkdown } from '../components/MobileMarkdown' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' @@ -75,7 +77,8 @@ function MobileNativeChatMessageImpl({ turnKey, onToggleTurn, activeTurnIsWorking, - structuredActivityUi = false + structuredActivityUi = false, + subagentLabel }: { message: NativeChatMessage toolsExpanded?: boolean @@ -94,6 +97,8 @@ function MobileNativeChatMessageImpl({ activeTurnIsWorking?: boolean /** Structured lane only: live tool progress plus the turn-status disclosure. */ structuredActivityUi?: boolean + /** The roster's name for the subagent that wrote this row, when one names it. */ + subagentLabel?: string }): React.JSX.Element { const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' @@ -115,13 +120,40 @@ function MobileNativeChatMessageImpl({ !turnExpanded && !toolsExpanded const showToolRun = tools.length > 0 && !settledToolsHidden + // A subagent's row sits where it happened but speaks as that subagent. A row + // whose only content is hidden behind its settled turn names no one. + const subagentName = + isUser || agentJournalItemSubagentId(message) === null || (prose.length === 0 && !showToolRun) + ? null + : (subagentLabel ?? NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.unnamed) return ( <> + {subagentName !== null ? ( + + {subagentName} + + ) : null} {prose.map((block, index) => ( nativeChatSubagentLabels(messages), [messages]) const { listRef, showJumpToTail, @@ -281,10 +286,11 @@ export function MobileNativeChatView({ onOpenFile={onOpenFile} structuredActivityUi={structuredActivityUi} onToggleTurn={turns.onToggleTurn} + subagentLabel={nativeChatSubagentLabel(subagentLabels, item)} {...turns.resolveRow(index, item)} /> ), - [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns] + [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, subagentLabels, turns] ) const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 51ff9ab1f36..88e97018521 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -32,6 +32,17 @@ export const styles = StyleSheet.create({ reasoning: { opacity: 0.7 }, + // A subagent's row is an aside to the conversation, set off the way desktop sets it off. + subagent: { + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + paddingLeft: spacing.md + }, + subagentCaption: { + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, toolRun: { marginTop: spacing.xs }, diff --git a/src/cli/handlers/orchestration/worker-output.ts b/src/cli/handlers/orchestration/worker-output.ts index 9d6d1949911..d9b5b9a46fa 100644 --- a/src/cli/handlers/orchestration/worker-output.ts +++ b/src/cli/handlers/orchestration/worker-output.ts @@ -1,6 +1,6 @@ import type { RuntimeTerminalRead } from '../../../shared/runtime-types' import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output' -import { formatWorkerTranscriptMessage } from '../../../shared/worker-transcript-text' +import { formatWorkerTranscriptMessages } from '../../../shared/worker-transcript-text' export type LegacyWorkerReadResult = { dispatchId: string @@ -57,7 +57,7 @@ export function formatWorkerRead( const output = value.source === 'terminal' ? value.terminal.tail.join('\n') - : value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n') + : formatWorkerTranscriptMessages(value.transcript.messages).join('\n\n') if (output) { return `${details}\n\n${output}` } diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index 802b724a2aa..d0b4ce23127 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -299,3 +299,48 @@ describe('worker transcript wire bounds', () => { ) }) }) + +describe("worker transcript wire bounds — a subagent's line names its agent and nothing more", () => { + it('serves the producing agent id, bounded like the roster key, and drops provenance', () => { + const longId = `task-${'x'.repeat(2_000)}` + const result = boundWorkerTranscriptMessages([ + { + id: 'child-line', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [{ type: 'text', text: 'The PR is CLEAN.' }], + agentId: longId, + parentAgentId: 'task-parent', + providerParentRef: 'toolu_provider_call', + producerKind: 'agent', + attempt: 2 + }, + { + id: 'roster', + role: 'system', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'group-1', + agents: [{ id: longId, label: 'review the PR', state: 'working' }] + } + ] + } + ]) + const [child, roster] = result.messages + expect(child).not.toHaveProperty('providerParentRef') + expect(child).not.toHaveProperty('parentAgentId') + expect(child).not.toHaveProperty('producerKind') + expect(child).not.toHaveProperty('attempt') + // The line's agent id and the roster entry that names it bound to the same key, + // so the reader can still put a name to the line. + const rosterBlock = roster?.blocks[0] + expect(rosterBlock?.type).toBe('subagent-group') + const entryId = rosterBlock?.type === 'subagent-group' ? rosterBlock.agents[0]?.id : undefined + expect(child?.agentId).toBeDefined() + expect(child?.agentId).toBe(entryId) + }) +}) diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index 684dfc771e2..b62687bd813 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' +import { agentJournalItemSubagentId } from '../../../shared/agent-session-journal-producer' import { boundSubagentEntryId } from '../../native-chat/subagent-entry-id-bounds' import { boundWorkerTranscriptActivityBlock } from './worker-transcript-activity-block-bounds' @@ -109,11 +110,24 @@ function boundMessage( markClipped(state, 'Some transcript blocks were omitted from oversized messages.') } // The journal position only orders a live list; a worker read is already in order. - const { journalPosition: _journalPosition, ...served } = message + // Of the producer linkage only the agent's id is served, so a reader can say whose + // line it is; the rest is provenance. + const { + journalPosition: _journalPosition, + agentId: _agentId, + parentAgentId: _parentAgentId, + providerParentRef: _providerParentRef, + producerKind: _producerKind, + attempt: _attempt, + ...served + } = message + const subagentId = agentJournalItemSubagentId(message) return { ...served, id: boundIdentifier(message.id, transcriptPath, state), ...(message.turnId ? { turnId: boundIdentifier(message.turnId, transcriptPath, state) } : {}), + // Same key as the roster entry that names it, so it is bounded the same way. + ...(subagentId === null ? {} : { agentId: boundEntryId(subagentId, state) }), blocks: blocks.map((block) => boundBlock(block, state)) } } diff --git a/src/main/runtime/structured-worker-terminal-read.ts b/src/main/runtime/structured-worker-terminal-read.ts index 3d31c4c863c..60dd1126067 100644 --- a/src/main/runtime/structured-worker-terminal-read.ts +++ b/src/main/runtime/structured-worker-terminal-read.ts @@ -29,7 +29,7 @@ */ import type { RuntimeTerminalRead } from '../../shared/runtime-types' -import { formatWorkerTranscriptMessage } from '../../shared/worker-transcript-text' +import { formatWorkerTranscriptMessages } from '../../shared/worker-transcript-text' import { AGENT_SESSION_NOT_ATTACHED } from '../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import type { OrchestrationDb } from './orchestration/db' import { boundStructuredJournalTail } from './orchestration/structured-worker-journal-archive' @@ -84,9 +84,7 @@ export async function readStructuredWorkerTerminal(args: { } // Redacts dispatch capabilities and clips oversized blocks under the archive path's byte bound. const bounded = boundStructuredJournalTail(page.items) - const lines = bounded.messages.flatMap((message) => - formatWorkerTranscriptMessage(message).split('\n') - ) + const lines = formatWorkerTranscriptMessages(bounded.messages).flatMap((text) => text.split('\n')) const read = readTerminalTail({ handle: args.handle, status: structuredWorkerTerminalState(observeStructuredWorker(identity).status), diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 3562065c151..d81bfffb52f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -42,6 +42,7 @@ import { nativeChatReaderScrollInputHandlers } from './native-chat-reader-scroll import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { isStructuredAgentSessionThinking } from '../../../../shared/structured-agent-session-live-turn' +import { nativeChatSubagentLabels } from '../../../../shared/native-chat-subagent-attribution' import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' import { nativeChatTurnDiffs, @@ -147,6 +148,7 @@ export function NativeChatMessageList({ // Structured sessions show goal state in the banner above the composer. return journalItems ? omitNativeChatThreadGoalRows(projected) : projected }, [journalItems, projectMessages, session.messages]) + const subagentLabels = useMemo(() => nativeChatSubagentLabels(messages), [messages]) const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) const showTypingIndicator = showTurnStatus @@ -201,7 +203,8 @@ export function NativeChatMessageList({ showTurnStatus, expandedTurnKeys: expandedTurnIds, isWorking, - lifecycleWorking + lifecycleWorking, + subagentLabels }), [ currentTurnKey, @@ -212,6 +215,7 @@ export function NativeChatMessageList({ messages, receipts, showTurnStatus, + subagentLabels, turnDiffs, turnKeys, turnStatuses diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index f11e41a6e2d..996716a838f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -204,3 +204,41 @@ describe('a user message that did not go through', () => { expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() }) }) + +describe("MessageRow — a subagent's row speaks as that subagent", () => { + function renderAgentRow(agentId: string | undefined, subagentLabel?: string) { + return render( + + ) + } + + it('names the subagent that wrote the row', () => { + renderAgentRow('task-1', 'explore the lane') + expect( + screen.getByRole('note', { name: 'Written by subagent explore the lane' }) + ).toBeInTheDocument() + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + }) + + it('still marks the row as a subagent when no loaded roster names it', () => { + renderAgentRow('task-9') + expect(screen.getByRole('note', { name: 'Subagent' })).toBeInTheDocument() + }) + + it("adds nothing to the session's own row", () => { + renderAgentRow(undefined, 'explore the lane') + expect(screen.queryByRole('note')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index c7ed087e918..c6505f86bf6 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useRef } from 'react' -import { Goal, RotateCcw } from 'lucide-react' +import { Bot, Goal, RotateCcw } from 'lucide-react' import { Button } from '@/components/ui/button' import CommentMarkdown, { type CommentMarkdownLinkClickHandler @@ -11,6 +11,8 @@ import type { NativeChatToolCallBlock } from '../../../../shared/native-chat-types' import { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-content' +import { agentJournalItemSubagentId } from '../../../../shared/agent-session-journal-producer' +import { NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY } from '../../../../shared/native-chat-subagent-attribution' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatNoticeRow } from './NativeChatNoticeRow' @@ -47,6 +49,7 @@ export const MessageRow = memo(function MessageRow({ deliveryNotice, structuredActivityUi = true, folded = false, + subagentLabel, runtimeContext }: { message: NativeChatMessage @@ -65,6 +68,8 @@ export const MessageRow = memo(function MessageRow({ structuredActivityUi?: boolean /** Behind a folded turn: the row keeps only what outlives the turn. */ folded?: boolean + /** The roster's name for the subagent that wrote this row, when one names it. */ + subagentLabel?: string runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element | null { const rowRef = useRef(null) @@ -193,6 +198,24 @@ export const MessageRow = memo(function MessageRow({ // Plain assistant prose is the copyable unit; reasoning/system asides stay // chrome-free. Controls reveal on hover/keyboard focus and stay visible on touch. const showControls = !isReasoning && !isSystem && markdown.length > 0 + // A subagent's row sits where it happened but speaks as that subagent, never as + // the agent the reader is talking to. + const subagentName = + agentJournalItemSubagentId(message) === null + ? null + : (subagentLabel ?? + translate( + 'components.native-chat.subagents.unnamed', + NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.unnamed + )) + const subagentCaption = + subagentLabel === undefined + ? subagentName + : translate( + 'components.native-chat.subagents.writtenBy', + NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.writtenBy, + { value0: subagentLabel } + ) return (
+ {subagentName !== null ? ( +
+ + + {subagentName} + +
+ ) : null} )} diff --git a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts index 60075385292..368eea2699b 100644 --- a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts +++ b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts @@ -29,6 +29,8 @@ export type NativeChatRowChromeMetrics = { /** Behind a folded turn: prose and tool activity draw nothing, so estimating * them would reserve a screen of height for a row that paints a roster. */ folded?: boolean + /** A subagent wrote the row, so it draws a caption naming that subagent. */ + attributed?: boolean } const LINE_HEIGHT_PX = 22 @@ -38,6 +40,7 @@ const USER_BUBBLE_CHROME_PX = 32 const IMAGE_STRIP_PX = 88 const TOOL_RUN_PX = 40 const SUBAGENT_ROW_PX = 32 +const SUBAGENT_CAPTION_PX = 24 const STATUS_ROW_PX = 28 const TURN_DIFF_PX = 28 const RECEIPT_PX = 56 @@ -117,6 +120,9 @@ export function estimateNativeChatRowHeight( height += TOOL_RUN_PX } height += content.subagentGroupCount * SUBAGENT_ROW_PX + if (chrome.attributed === true && height > 0) { + height += SUBAGENT_CAPTION_PX + } partCount = height > 0 ? 1 : 0 } if (chrome.hasStatus) { diff --git a/src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts b/src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts index 2e15d24eb2a..3c83b6a2796 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts @@ -252,3 +252,57 @@ describe('spawn-group roster rows', () => { expect(prose.map((block) => block.type)).toEqual(['text', 'subagent-group']) }) }) + +describe('foldToolMessages — each agent folds into its own run', () => { + const child = ( + overrides: Partial & Pick + ): NativeChatMessage => msg({ agentId: 'task-1', ...overrides }) + const text = (value: string) => ({ type: 'text' as const, text: value }) + const call = (name: string) => ({ type: 'tool-call' as const, name, input: {} }) + + it("does not fold a subagent's tool calls into its parent's message", () => { + const folded = foldToolMessages([ + msg({ id: 'parent', blocks: [text('delegating')] }), + child({ id: 'child-grep', blocks: [call('Grep')] }) + ]) + expect(folded.map((message) => message.id)).toEqual(['parent', 'child-grep']) + expect(folded[0]?.blocks).toEqual([text('delegating')]) + }) + + it("keeps interleaved agents in order, each call in its own agent's run", () => { + const folded = foldToolMessages([ + msg({ id: 'parent', blocks: [text('delegating')] }), + child({ id: 'child', blocks: [text('looking')] }), + child({ id: 'child-grep', blocks: [call('Grep')] }), + msg({ id: 'parent-read', blocks: [call('Read')] }) + ]) + // The parent's later call stays where it happened, below the child's work, + // rather than jumping back up into the parent's earlier row. + expect(folded.map((message) => message.id)).toEqual(['parent', 'child', 'parent-read']) + expect(folded[0]?.blocks).toEqual([text('delegating')]) + expect(folded[1]?.blocks).toEqual([text('looking'), call('Grep')]) + expect(folded[1]?.agentId).toBe('task-1') + expect(folded[2]?.agentId).toBeUndefined() + }) + + it("ends every agent's run at a turn boundary", () => { + const folded = foldToolMessages([ + child({ id: 'child', blocks: [text('looking')] }), + msg({ id: 'ask', role: 'user', blocks: [text('next')] }), + child({ id: 'child-grep', blocks: [call('Grep')] }) + ]) + // The child's later call stays in the turn it happened in. + expect(folded.map((message) => message.id)).toEqual(['child', 'ask', 'child-grep']) + }) + + it('folds a transcript that names no producer exactly as a single run', () => { + const folded = foldToolMessages([ + msg({ id: 'a', blocks: [text('one')] }), + msg({ id: 'a-call', blocks: [call('Bash')] }), + msg({ id: 'notice', role: 'system', blocks: [text('Something happened')] }), + msg({ id: 'b-call', blocks: [call('Read')] }) + ]) + expect(folded.map((message) => message.id)).toEqual(['a', 'notice', 'b-call']) + expect(folded[0]?.blocks).toEqual([text('one'), call('Bash')]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index f22d25bfa8f..6bc5a2d9b1d 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -206,3 +206,72 @@ describe('a send the host rejected', () => { ]) }) }) + +describe("a subagent's rows speak as that subagent", () => { + const settled: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 5 } + const roster: NativeChatMessage = { + id: 'roster', + role: 'system', + blocks: [ + { + type: 'subagent-group', + groupId: 'group-1', + agents: [{ id: 'task-1', label: 'explore the lane', state: 'working' }] + } + ], + timestamp: 1, + source: 'transcript' + } + const child = (id: string, body: string, agentId = 'task-1'): NativeChatMessage => ({ + ...text(id, body), + agentId + }) + const messages = [ + text('ask', 'summarise the repo', 'user'), + text('answer', 'Delegated; the summary follows.'), + roster, + child('child-said', 'The PR is CLEAN.') + ] + + it("keeps the parent's answer on a settled turn and folds the subagent's later words", () => { + const slots = build(messages, { + turnStatuses: { active: null, completedByTurn: { ask: settled } }, + subagentLabels: new Map([['task-1', 'explore the lane']]) + }) + const drawn = slots.filter((slot) => !slot.folded).map((slot) => slot.message.id) + expect(drawn).toContain('answer') + expect(drawn).not.toContain('child-said') + }) + + it("names the subagent on its row from the roster, and only on a subagent's row", () => { + const slots = build(messages, { + subagentLabels: new Map([['task-1', 'explore the lane']]) + }) + const labelOf = (id: string) => slots.find((slot) => slot.message.id === id)?.subagentLabel + expect(labelOf('child-said')).toBe('explore the lane') + expect(labelOf('answer')).toBeUndefined() + }) + + it("keeps the parent's run live while its subagent works below it", () => { + const trailing = (rows: NativeChatMessage[]) => + build(rows) + .filter((slot) => slot.trailingRun) + .map((slot) => slot.message.id) + const childRun: NativeChatMessage = { ...toolRun('child-run'), agentId: 'task-1' } + // The parent is still inside its spawn call; the child's work does not move it past. + expect(trailing([text('ask', 'go', 'user'), toolRun('spawn'), childRun])).toEqual([ + 'spawn', + 'child-run' + ]) + // The parent answering does move it past its own run, whatever the child does. + expect( + trailing([text('ask', 'go', 'user'), toolRun('spawn'), text('said', 'Done.'), childRun]) + ).toEqual(['said', 'child-run']) + }) + + it('reserves room for the caption on a subagent row', () => { + const [parentSlot] = build([text('mine', 'same words')]) + const [childSlot] = build([child('theirs', 'same words')]) + expect(childSlot!.estimatedHeight).toBeGreaterThan(parentSlot!.estimatedHeight) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index a0fe23b6df3..cf2acedfc68 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -13,6 +13,8 @@ import { isToolCallBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' +import { agentJournalItemSubagentId } from '../../../../shared/agent-session-journal-producer' +import { nativeChatSubagentLabel } from '../../../../shared/native-chat-subagent-attribution' import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' import { nativeChatTurnFold, @@ -48,6 +50,9 @@ export type NativeChatTranscriptSlot = { /** Whether this row's turn hides anything, so its status row offers a caret. */ turnFolds: boolean turnDiff: NativeChatTurnDiff | undefined + /** The roster's name for the subagent that wrote this row, when one names it. + * Whether a subagent wrote it at all is the message's own linkage. */ + subagentLabel: string | undefined /** Height to reserve before the row has ever been measured. */ estimatedHeight: number } @@ -69,6 +74,8 @@ export type NativeChatTranscriptSlotsInput = { isWorking: boolean /** Session-level lifecycle, which outlives a transcript that never said "done". */ lifecycleWorking: boolean + /** Each subagent's roster label, by the id its rows carry. */ + subagentLabels?: ReadonlyMap } export function buildNativeChatTranscriptSlots( @@ -85,12 +92,14 @@ export function buildNativeChatTranscriptSlots( showTurnStatus, expandedTurnKeys, isWorking, - lifecycleWorking + lifecycleWorking, + subagentLabels } = input // One pass to decide what each row draws, then the fold over those readings — // so "is this the answer" and "does this row render prose" cannot disagree. const foldRows: NativeChatTurnFoldRow[] = messages.map((message, index) => { const content = deriveNativeChatRowContent(message.blocks) + const agentId = agentJournalItemSubagentId(message) return { turnKey: turnKeys[index], role: message.role, @@ -99,20 +108,32 @@ export function buildNativeChatTranscriptSlots( // and its plain-text twin is then the only record the spawn happened. outlivesTurn: message.blocks.some( (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) - ) + ), + ...(agentId === null ? {} : { agentId }) } }) // Liveness is the turn's, not any one call's: the run at the frontier stays // live between its calls, and a run the agent has moved past is settled even // while its last call is still reporting. An approval's receipt decides a call // of the run above it, which then runs, so it does not move past that run. - const trailingRunIndex = foldRows.findLastIndex( - (row, index) => + // Each agent has its own frontier: a subagent working below its parent's run + // has not moved the parent past it. + const trailingRunIndexes = new Set() + const agentsWithFrontier = new Set() + for (let index = foldRows.length - 1; index >= 0; index -= 1) { + const row = foldRows[index]! + const agent = agentJournalItemSubagentId(messages[index]) + if ( + !agentsWithFrontier.has(agent) && row.role !== 'user' && row.role !== 'reasoning' && receipts.get(messages[index].id)?.kind !== 'approval' && (row.rendersProse || messages[index].blocks.some(isToolCallBlock)) - ) + ) { + trailingRunIndexes.add(index) + agentsWithFrontier.add(agent) + } + } const settledTurnKeys = new Set( showTurnStatus ? Object.entries(turnStatuses.completedByTurn) @@ -147,23 +168,27 @@ export function buildNativeChatTranscriptSlots( if (!drawsRow && status === undefined && turnDiff === undefined) { continue } + const subagentId = agentJournalItemSubagentId(message) slots.push({ message, turnKey, activeTurnIsWorking: (currentTurnKey ? turnKey === currentTurnKey : turnKey === undefined) && (isWorking || lifecycleWorking), - trailingRun: index === trailingRunIndex, + trailingRun: trailingRunIndexes.has(index), receipt, status: status ?? undefined, folded, turnFolds: turnKey !== undefined && foldableTurnKeys.has(turnKey), turnDiff, + subagentLabel: nativeChatSubagentLabel(subagentLabels, message), estimatedHeight: estimateNativeChatRowHeight(nativeChatRowContentMetrics(message), { hasReceipt: receipt !== undefined, hasStatus: status !== undefined, hasTurnDiff: turnDiff !== undefined, - folded + folded, + // Only an agent's own row draws the caption; a receipt stands in for it. + attributed: subagentId !== null && receipt === undefined && message.role !== 'user' }) }) } diff --git a/src/renderer/src/components/native-chat/structured-agent-question-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-question-projection.test.ts index ab74c2efef2..09154a2512a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-question-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-question-projection.test.ts @@ -215,3 +215,19 @@ describe('structured agent session ask-row projection', () => { ).toHaveLength(1) }) }) + +describe("a subagent's pending question keeps its producer", () => { + it('carries the linkage onto the row that stands in for the question', () => { + const projected = projectQuestion({ + ...item('child-q', { + kind: 'question', + question: 'Which branch?', + options: [{ id: 'q1:main', label: 'main' }], + resolution: { ...PENDING } + }), + agentId: 'task-1', + producerKind: 'agent' + }) + expect(projected).toMatchObject({ role: 'system', agentId: 'task-1' }) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-question-projection.ts b/src/renderer/src/components/native-chat/structured-agent-question-projection.ts index 9469c443d0a..da247876119 100644 --- a/src/renderer/src/components/native-chat/structured-agent-question-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-question-projection.ts @@ -5,6 +5,7 @@ import type { import { isAskUserQuestionTool } from '../../../../shared/agent-question-answered-intent' import { parseAskFromToolInput } from '../../../../shared/native-chat-ask' import { agentJournalItemRowOrigin } from '../../../../shared/agent-session-journal-position' +import { agentJournalLinkageFields } from '../../../../shared/agent-session-journal-producer' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' import { readAgentJournalTurn } from '../../../../shared/agent-session-turn-record' @@ -71,6 +72,7 @@ function projectItem(item: AgentJournalRenderItem): Projection { // A system row preserves question identity through tool folding; the receipt renders its body. message = { ...agentJournalItemRowOrigin(item), + ...agentJournalLinkageFields(item), role: 'system', blocks: [{ type: 'text', text: body.question }] } diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx index 823f78af6ed..7d245cc76fd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx @@ -58,6 +58,7 @@ function slot(id: string): NativeChatTranscriptSlot { folded: false, turnFolds: false, turnDiff: undefined, + subagentLabel: undefined, estimatedHeight: 48 } } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 57703fb7d1f..c72ffcdf803 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2697,6 +2697,10 @@ "workedFor": "Worked for {{value0}}", "workingFor": "Working for {{value0}}" }, + "subagents": { + "unnamed": "Subagent", + "writtenBy": "Written by subagent {{value0}}" + }, "tool": { "countN": "{{value0}} tool calls", "countOne": "1 tool call", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ffdcd6031c2..caed5202f08 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17837,7 +17837,9 @@ "startedN": "Kicked off {{value0}} subagents", "ranOne": "Ran 1 subagent", "ranN": "Ran {{value0}} subagents", - "tokens": "{{value0}} tokens" + "tokens": "{{value0}} tokens", + "unnamed": "Subagent", + "writtenBy": "Written by subagent {{value0}}" }, "conversationCommand": { "pendingWork": "Wait for pending work and messages to finish before using this command.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index adbe527574a..05d26267342 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15104,6 +15104,10 @@ "label": "Contexto: {{used}} de {{window}} tokens, {{percent}} % usados", "title": "Contexto", "estimated": "Estimado a partir de la última respuesta." + }, + "subagents": { + "unnamed": "Subagente", + "writtenBy": "Escrito por el subagente {{value0}}" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 047564c4e61..4f591ca8bf2 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17769,7 +17769,9 @@ "startedN": "Lancement des sous-agents {{value0}}", "ranOne": "A exécuté 1 sous-agent", "ranN": "Ran les sous-agents {{value0}}", - "tokens": "Jetons {{value0}}" + "tokens": "Jetons {{value0}}", + "unnamed": "Sous-agent", + "writtenBy": "Écrit par le sous-agent {{value0}}" }, "conversationCommand": { "pendingWork": "Attendez la fin du travail et des messages en attente avant d'utiliser cette commande.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 97ff2a6ffda..3e4a5cd42ee 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17705,7 +17705,9 @@ "startedN": "{{value0}} サブ Agent をキックオフしました", "ranOne": "1 人のサブ Agent を実行しました", "ranN": "{{value0}} サブ Agent を実行しました", - "tokens": "{{value0}} トークン" + "tokens": "{{value0}} トークン", + "unnamed": "サブ Agent", + "writtenBy": "サブ Agent {{value0}} による出力" }, "conversationCommand": { "pendingWork": "このコマンドを使用する前に、保留中の作業とメッセージが完了するまで待ってください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 511ee010e70..75f96c732ab 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17705,7 +17705,9 @@ "startedN": "{{value0}} 하위 에이전트가 시작되었습니다.", "ranOne": "하위 에이전트 1개 실행", "ranN": "{{value0}} 하위 에이전트를 실행했습니다.", - "tokens": "{{value0}} 토큰" + "tokens": "{{value0}} 토큰", + "unnamed": "하위 에이전트", + "writtenBy": "하위 에이전트 {{value0}}의 출력" }, "conversationCommand": { "pendingWork": "이 명령을 사용하기 전에 보류 중인 작업과 메시지가 완료될 때까지 기다리십시오.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e4fa1521899..5d3eadd54d0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17670,7 +17670,9 @@ "startedN": "启动 {{value0}} 子代理", "ranOne": "Ran 1 子代理", "ranN": "Ran {{value0}} 子代理", - "tokens": "{{value0}} 代币" + "tokens": "{{value0}} 代币", + "unnamed": "子代理", + "writtenBy": "由子代理 {{value0}} 输出" }, "conversationCommand": { "pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。", diff --git a/src/shared/agent-session-journal-producer.ts b/src/shared/agent-session-journal-producer.ts index ff51c6eaf57..2a5410eb275 100644 --- a/src/shared/agent-session-journal-producer.ts +++ b/src/shared/agent-session-journal-producer.ts @@ -25,7 +25,15 @@ import type { export function isRootAgentJournalItem( item: Pick | undefined ): boolean { - return item?.agentId == null + return agentJournalItemSubagentId(item) === null +} + +/** The subagent that produced a row, or null when the session's own agent did. + * Same reading of absence as {@link isRootAgentJournalItem}. */ +export function agentJournalItemSubagentId( + item: Pick | undefined +): string | null { + return item?.agentId ?? null } /** Whether a write names its producer at all. One that does not revises a row diff --git a/src/shared/native-chat-subagent-attribution.ts b/src/shared/native-chat-subagent-attribution.ts new file mode 100644 index 00000000000..19619ec6c87 --- /dev/null +++ b/src/shared/native-chat-subagent-attribution.ts @@ -0,0 +1,42 @@ +// Whose words a transcript row carries, when they are not the session's own agent's. +// +// A session's subagents write into its journal, and the transcript keeps their rows +// where they happened. Each such row names the subagent that wrote it, using the +// label that subagent's spawn roster already gives it, so a child's words are never +// read as its parent's. Shared so desktop and mobile name the same agent. + +import { agentJournalItemSubagentId } from './agent-session-journal-producer' +import { isSubagentGroupBlock, type NativeChatMessage } from './native-chat-types' + +/** English copy: the desktop's i18n fallbacks, and mobile's text as-is. */ +export const NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY = { + unnamed: 'Subagent', + writtenBy: 'Written by subagent {{value0}}' +} as const + +/** Each subagent's roster label, by the id its rows carry. */ +export function nativeChatSubagentLabels( + messages: readonly NativeChatMessage[] +): ReadonlyMap { + const labels = new Map() + for (const message of messages) { + for (const block of message.blocks) { + if (isSubagentGroupBlock(block)) { + for (const agent of block.agents) { + labels.set(agent.id, agent.label) + } + } + } + } + return labels +} + +/** The roster's name for the subagent that wrote a row. Undefined for the + * session's own rows, and for a subagent no loaded roster names. */ +export function nativeChatSubagentLabel( + labels: ReadonlyMap | undefined, + message: NativeChatMessage +): string | undefined { + const agentId = agentJournalItemSubagentId(message) + return agentId === null ? undefined : labels?.get(agentId) +} diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts index e08a722c242..f3c72a45a6b 100644 --- a/src/shared/native-chat-tool-fold.ts +++ b/src/shared/native-chat-tool-fold.ts @@ -8,6 +8,7 @@ import { type NativeChatToolCallBlock, type NativeChatToolResultBlock } from './native-chat-types' +import { agentJournalItemSubagentId } from './agent-session-journal-producer' import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' import { isNoiseMessage } from './native-chat-noise' @@ -78,14 +79,23 @@ function dropUnattributableToolResults(message: NativeChatMessage): NativeChatMe return blocks.length > 0 ? { ...message, blocks } : null } -/** Fold consecutive tool-only messages into their preceding assistant turn. */ +/** Fold consecutive tool-only messages into their preceding assistant turn. + * Only into a run the same agent wrote: a subagent's calls interleave with its + * parent's in one journal, and absorbing one into the other would present one + * agent's work as the other's. */ export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { const output: NativeChatMessage[] = [] let mutableAssistantIndex = -1 let clonedAssistantIndex = -1 for (const message of messages) { - if (isHarnessSidecarToolMessage(message) && mutableAssistantIndex >= 0) { - const index = mutableAssistantIndex + const foldTarget = + mutableAssistantIndex >= 0 && + agentJournalItemSubagentId(output[mutableAssistantIndex]) === + agentJournalItemSubagentId(message) + ? mutableAssistantIndex + : -1 + if (isHarnessSidecarToolMessage(message) && foldTarget >= 0) { + const index = foldTarget const assistant = output[index] if (assistant?.role === 'assistant') { if (clonedAssistantIndex !== index) { @@ -100,8 +110,8 @@ export function foldToolMessages(messages: readonly NativeChatMessage[]): Native continue } } - if (isToolOnlyMessage(message) && mutableAssistantIndex >= 0) { - const index = mutableAssistantIndex + if (isToolOnlyMessage(message) && foldTarget >= 0) { + const index = foldTarget const assistant = output[index] if (assistant?.role !== 'assistant') { output.push(message) diff --git a/src/shared/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index cd7e4de024d..d6bba89e552 100644 --- a/src/shared/native-chat-turn-fold.test.ts +++ b/src/shared/native-chat-turn-fold.test.ts @@ -147,3 +147,33 @@ describe('nativeChatTurnFold', () => { expect(foldedRows.size).toBe(0) }) }) + +describe("a subagent's words are never the turn's answer", () => { + // The reported shape: the parent replies, then a subagent it launched keeps + // narrating into the same journal after the parent's last word. + const rows: NativeChatTurnFoldRow[] = [ + row({ role: 'user' }), + row(), + row({ agentId: 'task-1' }), + row({ agentId: 'task-1', rendersProse: false }) + ] + + it("names the session's own last prose, not a subagent's later prose", () => { + expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(1) + }) + + it("folds the subagent's prose behind a settled turn and keeps the parent's answer", () => { + const { foldedRows } = nativeChatTurnFold({ + rows, + settledTurnKeys: SETTLED, + expandedTurnKeys: NONE + }) + expect(foldedRows.has(1)).toBe(false) + expect(foldedRows.has(2)).toBe(true) + }) + + it('reports no answer for a turn in which only subagents spoke', () => { + const onlyChildren = [row({ role: 'user' }), row({ agentId: 'task-1' })] + expect(nativeChatTurnAnswerRows(onlyChildren).has('turn-1')).toBe(false) + }) +}) diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 5ceb0ef8989..52407a9d524 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -9,6 +9,7 @@ // Shared because desktop and mobile both draw this disclosure, and a fold that // hides a different row on each surface is the same bug twice. +import { isRootAgentJournalItem } from './agent-session-journal-producer' import type { NativeChatRole } from './native-chat-types' /** What the fold needs to know about one transcript row. Deliberately not a @@ -24,6 +25,8 @@ export type NativeChatTurnFoldRow = { * spawn roster or a background task. That row is the durable report of how * the work ended — often the only one — so it never folds. */ outlivesTurn: boolean + /** The subagent that produced the row. Absent ⇒ the session's own agent. */ + agentId?: string } export type NativeChatTurnFold = { @@ -39,14 +42,21 @@ export const NATIVE_CHAT_EMPTY_TURN_FOLD: NativeChatTurnFold = { foldableTurnKeys: new Set() } -/** The index of each turn's answer: its last assistant row that renders prose. - * A turn with no such row has no answer, and folds whole. */ +/** The index of each turn's answer: the last prose the session's own agent + * wrote. A subagent that is still narrating after its parent's last word would + * otherwise stand as the turn's only visible reply. A turn with no such row has + * no answer, and folds whole. */ export function nativeChatTurnAnswerRows( rows: readonly NativeChatTurnFoldRow[] ): ReadonlyMap { const answers = new Map() for (const [index, row] of rows.entries()) { - if (row.turnKey !== undefined && row.role === 'assistant' && row.rendersProse) { + if ( + row.turnKey !== undefined && + row.role === 'assistant' && + row.rendersProse && + isRootAgentJournalItem(row) + ) { answers.set(row.turnKey, index) } } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index f33158d9a0f..392ebb5b8f6 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -13,7 +13,8 @@ import type { import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageSendMode, - AgentJournalPosition + AgentJournalPosition, + AgentJournalProducerLinkage } from './agent-session-journal-types' import type { AgentType } from './agent-status-types' import type { NativeChatToolMetadata } from './native-chat-tool-identity' @@ -190,7 +191,9 @@ export type NativeChatBlock = | NativeChatSubagentGroupBlock | NativeChatBackgroundTaskBlock -export type NativeChatMessage = { +/** A transcript row. Structured rows carry the journal row's producer linkage, so + * "who said this" survives the projection; terminal-backed rows carry none. */ +export type NativeChatMessage = AgentJournalProducerLinkage & { /** Stable across re-reads/appends so the assembler and the renderer list can * dedup and key by it. */ id: string diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index f26f409afd4..691872c3587 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -665,6 +665,18 @@ describe("producer linkage — a subagent's output never speaks for the parent", expect(prose).toContain('delegating') }) + it("keeps the child's output as the child's: the transcript message names its producer", () => { + // Rendering the child's rows is not enough; unless the message still says who + // wrote it, the transcript can only present it as the parent speaking. + const messages = projectStructuredItemsToNativeChat(items) + const byId = new Map(messages.map((message) => [message.id, message])) + expect(byId.get('child-prose')).toMatchObject({ agentId: 'task-1', producerKind: 'agent' }) + expect(byId.get('child-grep')).toMatchObject({ agentId: 'task-1' }) + // The session's own rows name no producer: absence is the claim that they are its own. + expect(byId.get('root-prose')).not.toHaveProperty('agentId') + expect(byId.get('root-task')).not.toHaveProperty('agentId') + }) + it("falls back to nothing rather than a child's line when the parent said nothing", () => { const summary = projectStructuredAgentSessionStatusSummary([ userAsk, diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index f444af0a060..f35af81b932 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -10,7 +10,7 @@ import { type AgentJournalSubmission, type AgentJournalTurnOutcome } from './agent-session-journal-types' -import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { agentJournalLinkageFields, isRootAgentJournalItem } from './agent-session-journal-producer' import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' import { agentJournalItemRowOrigin } from './agent-session-journal-position' import { @@ -133,8 +133,8 @@ function isAgentJournalMessageSendMode(value: string): value is AgentJournalMess const projectedItems = new WeakMap() /** Deliberately NOT scoped by producer: the transcript shows every agent's - * output. The line this module draws is that the transcript renders every item, - * while every "what is this agent doing right now" scan renders only the + * output, and each message keeps its row's linkage so the transcript can say + * whose it is. Every "what is this agent doing right now" scan renders only the * session's own agent's. */ export function projectStructuredItemsToNativeChat( items: readonly AgentJournalRenderItem[] @@ -162,6 +162,7 @@ export function projectStructuredItemToNativeChat( const message: NativeChatMessage | null = projected ? { ...agentJournalItemRowOrigin(item), + ...agentJournalLinkageFields(item), role: projected.role, blocks: projected.blocks, // A send mode this build cannot name renders as an ordinary message. diff --git a/src/shared/worker-transcript-background-task-text.test.ts b/src/shared/worker-transcript-background-task-text.test.ts index 1e9d727406b..a612dbe0d8c 100644 --- a/src/shared/worker-transcript-background-task-text.test.ts +++ b/src/shared/worker-transcript-background-task-text.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from './native-chat-types' -import { formatWorkerTranscriptMessage } from './worker-transcript-text' +import { formatWorkerTranscriptMessages } from './worker-transcript-text' + +const formatWorkerTranscriptMessage = (message: NativeChatMessage): string => + formatWorkerTranscriptMessages([message]).join('') function message(blocks: NativeChatMessage['blocks']): NativeChatMessage { return { id: 'm-1', role: 'system', blocks, timestamp: null, source: 'hook' } diff --git a/src/shared/worker-transcript-subagent-attribution.test.ts b/src/shared/worker-transcript-subagent-attribution.test.ts new file mode 100644 index 00000000000..f67a12d6702 --- /dev/null +++ b/src/shared/worker-transcript-subagent-attribution.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from './native-chat-types' +import { formatWorkerTranscriptMessages } from './worker-transcript-text' + +function message( + id: string, + text: string, + overrides: Partial = {} +): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +const roster = message('roster', 'Kicked off 1 subagent', { + role: 'system', + blocks: [ + { type: 'text', text: 'Kicked off 1 subagent' }, + { + type: 'subagent-group', + groupId: 'group-1', + agents: [{ id: 'task-1', label: 'review the PR', state: 'working' }] + } + ] +}) + +describe("a worker's transcript never gives a subagent's words to the worker", () => { + it("tags a subagent's line with the subagent the roster names", () => { + const [own, , child] = formatWorkerTranscriptMessages([ + message('own', 'Delegating the review.'), + roster, + message('child', 'The PR is CLEAN.', { agentId: 'task-1' }) + ]) + expect(own).toBe('[assistant] Delegating the review.') + expect(child).toBe('[assistant, subagent review the PR] The PR is CLEAN.') + }) + + it('still tags a subagent that no roster on the page names', () => { + const [child] = formatWorkerTranscriptMessages([ + message('child', 'Still looking.', { agentId: 'task-9' }) + ]) + expect(child).toBe('[assistant, subagent] Still looking.') + }) +}) diff --git a/src/shared/worker-transcript-text-scaling.test.ts b/src/shared/worker-transcript-text-scaling.test.ts index 92283cb2431..d00778e4d1a 100644 --- a/src/shared/worker-transcript-text-scaling.test.ts +++ b/src/shared/worker-transcript-text-scaling.test.ts @@ -1,7 +1,10 @@ import { expect, it, vi } from 'vitest' -import { formatWorkerTranscriptMessage } from './worker-transcript-text' +import { formatWorkerTranscriptMessages } from './worker-transcript-text' import type { NativeChatMessage } from './native-chat-types' +const formatWorkerTranscriptMessage = (message: NativeChatMessage): string => + formatWorkerTranscriptMessages([message]).join('') + it('does not shift the remaining twin list for each matching roster', () => { const blocks: NativeChatMessage['blocks'] = Array.from({ length: 1000 }, (_, index) => ({ type: 'subagent-group', diff --git a/src/shared/worker-transcript-text.ts b/src/shared/worker-transcript-text.ts index 45a98a87835..fd42193edd8 100644 --- a/src/shared/worker-transcript-text.ts +++ b/src/shared/worker-transcript-text.ts @@ -12,8 +12,36 @@ import { subagentGroupFallbackText } from './native-chat-subagent-summary' import type { NativeChatMessage } from './native-chat-types' +import { agentJournalItemSubagentId } from './agent-session-journal-producer' +import { + nativeChatSubagentLabel, + nativeChatSubagentLabels +} from './native-chat-subagent-attribution' -export function formatWorkerTranscriptMessage(message: NativeChatMessage): string { +/** Every message in one read, each tagged with who it speaks as. A subagent is + * named by the roster on the same page. */ +export function formatWorkerTranscriptMessages(messages: readonly NativeChatMessage[]): string[] { + const subagentLabels = nativeChatSubagentLabels(messages) + return messages.map((message) => formatWorkerTranscriptMessage(message, subagentLabels)) +} + +/** Who a line speaks as. A subagent's lines are tagged as its own, so a peer reading + * a worker's transcript never takes a subagent's words for the worker's. */ +function workerTranscriptSpeaker( + message: NativeChatMessage, + subagentLabels: ReadonlyMap | undefined +): string { + if (agentJournalItemSubagentId(message) === null) { + return message.role + } + const label = nativeChatSubagentLabel(subagentLabels, message) + return label === undefined ? `${message.role}, subagent` : `${message.role}, subagent ${label}` +} + +function formatWorkerTranscriptMessage( + message: NativeChatMessage, + subagentLabels: ReadonlyMap +): string { // Every roster block is written beside a plain-text twin carrying the same // sentence, for clients that cannot draw the block. Text surfaces are those // clients, so they print the twin and drop the block. The renderer reaches the @@ -49,7 +77,7 @@ export function formatWorkerTranscriptMessage(message: NativeChatMessage): strin // than reading fields off a shape that has none. return '[unsupported block]' }) - return `[${message.role}] ${blocks.filter((line) => line !== null).join('\n')}`.trimEnd() + return `[${workerTranscriptSpeaker(message, subagentLabels)}] ${blocks.filter((line) => line !== null).join('\n')}`.trimEnd() } /** For each roster block, the sentence it must print itself — absent when a twin