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
This commit is contained in:
Brennan Benson
2026-09-28 15:28:01 -07:00
committed by GitHub
parent c5330d0d52
commit e8e144bf3c
38 changed files with 688 additions and 42 deletions
@@ -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)
})
})
+34 -2
View File
@@ -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 (
<>
<View style={[styles.row, isUser && styles.rowUser]}>
<View
style={[styles.content, isUser && styles.userBubble, isReasoning && styles.reasoning]}
style={[
styles.content,
isUser && styles.userBubble,
isReasoning && styles.reasoning,
subagentName !== null && styles.subagent
]}
>
{subagentName !== null ? (
<NativeText
style={styles.subagentCaption}
accessibilityLabel={
subagentLabel === undefined
? subagentName
: NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.writtenBy.replaceAll(
'{{value0}}',
subagentLabel
)
}
numberOfLines={1}
>
{subagentName}
</NativeText>
) : null}
{prose.map((block, index) => (
<Prose
key={index}
+7 -1
View File
@@ -13,6 +13,10 @@ import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-ha
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
nativeChatSubagentLabel,
nativeChatSubagentLabels
} from '../../../src/shared/native-chat-subagent-attribution'
import type {
NativeChatLiveTurnIndicator,
NativeChatSettledTurns
@@ -209,6 +213,7 @@ export function MobileNativeChatView({
}),
[messages, folded, streaming, pending, imagePreviewsByMessageId]
)
const subagentLabels = useMemo(() => 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)
@@ -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
},
@@ -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}`
}
@@ -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)
})
})
@@ -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))
}
}
@@ -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),
@@ -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
@@ -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(
<MessageRow
message={{
id: 'message',
role: 'assistant',
timestamp: 0,
source: 'transcript',
blocks: [{ type: 'text', text: 'The PR is CLEAN.' }],
...(agentId === undefined ? {} : { agentId })
}}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
subagentLabel={subagentLabel}
/>
)
}
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()
})
})
@@ -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<HTMLDivElement | null>(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 (
<div
@@ -201,9 +224,22 @@ export const MessageRow = memo(function MessageRow({
'group relative max-w-full select-text text-sm leading-relaxed text-foreground',
// Reasoning is the agent thinking aloud — quieter, italic, like an aside.
isReasoning && 'border-l-2 border-border/60 pl-3 italic text-muted-foreground',
subagentName !== null && !isReasoning && 'border-l-2 border-border/60 pl-3',
isSystem && 'text-xs text-muted-foreground'
)}
>
{subagentName !== null ? (
<div
role="note"
aria-label={subagentCaption ?? undefined}
className="mb-1 flex min-h-5 items-center gap-1.5 not-italic text-muted-foreground"
>
<Bot aria-hidden className="size-3.5 shrink-0" />
<code aria-hidden className="min-w-0 truncate font-mono text-[11px]">
{subagentName}
</code>
</div>
) : null}
<NativeChatImageAttachments
blocks={prose}
runtimeContext={runtimeContext}
@@ -64,6 +64,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
deliveryNotice={context.deliveryNotices?.get(message.id)}
structuredActivityUi={context.showTurnStatus}
folded={slot.folded}
subagentLabel={slot.subagentLabel}
runtimeContext={context.runtimeContext}
/>
)}
@@ -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) {
@@ -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<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
): 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')])
})
})
@@ -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)
})
})
@@ -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<string, string>
}
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<number>()
const agentsWithFrontier = new Set<string | null>()
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'
})
})
}
@@ -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' })
})
})
@@ -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 }]
}
@@ -58,6 +58,7 @@ function slot(id: string): NativeChatTranscriptSlot {
folded: false,
turnFolds: false,
turnDiff: undefined,
subagentLabel: undefined,
estimatedHeight: 48
}
}
+4
View File
@@ -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",
+3 -1
View File
@@ -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.",
+4
View File
@@ -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": {
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -17705,7 +17705,9 @@
"startedN": "{{value0}} サブ Agent をキックオフしました",
"ranOne": "1 人のサブ Agent を実行しました",
"ranN": "{{value0}} サブ Agent を実行しました",
"tokens": "{{value0}} トークン"
"tokens": "{{value0}} トークン",
"unnamed": "サブ Agent",
"writtenBy": "サブ Agent {{value0}} による出力"
},
"conversationCommand": {
"pendingWork": "このコマンドを使用する前に、保留中の作業とメッセージが完了するまで待ってください。",
+3 -1
View File
@@ -17705,7 +17705,9 @@
"startedN": "{{value0}} 하위 에이전트가 시작되었습니다.",
"ranOne": "하위 에이전트 1개 실행",
"ranN": "{{value0}} 하위 에이전트를 실행했습니다.",
"tokens": "{{value0}} 토큰"
"tokens": "{{value0}} 토큰",
"unnamed": "하위 에이전트",
"writtenBy": "하위 에이전트 {{value0}}의 출력"
},
"conversationCommand": {
"pendingWork": "이 명령을 사용하기 전에 보류 중인 작업과 메시지가 완료될 때까지 기다리십시오.",
+3 -1
View File
@@ -17670,7 +17670,9 @@
"startedN": "启动 {{value0}} 子代理",
"ranOne": "Ran 1 子代理",
"ranN": "Ran {{value0}} 子代理",
"tokens": "{{value0}} 代币"
"tokens": "{{value0}} 代币",
"unnamed": "子代理",
"writtenBy": "由子代理 {{value0}} 输出"
},
"conversationCommand": {
"pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。",
+9 -1
View File
@@ -25,7 +25,15 @@ import type {
export function isRootAgentJournalItem(
item: Pick<AgentJournalRenderItem, 'agentId'> | 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<AgentJournalRenderItem, 'agentId'> | undefined
): string | null {
return item?.agentId ?? null
}
/** Whether a write names its producer at all. One that does not revises a row
@@ -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<string, string> {
const labels = new Map<string, string>()
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<string, string> | undefined,
message: NativeChatMessage
): string | undefined {
const agentId = agentJournalItemSubagentId(message)
return agentId === null ? undefined : labels?.get(agentId)
}
+15 -5
View File
@@ -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)
+30
View File
@@ -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)
})
})
+13 -3
View File
@@ -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<string, number> {
const answers = new Map<string, number>()
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)
}
}
+5 -2
View File
@@ -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
@@ -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,
@@ -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<AgentJournalRenderItem, NativeChatMessage | null>()
/** Deliberately NOT scoped by producer: the transcript shows every agent's
* output. The line this module draws is that the transcript renders every item,
* while every "what is this agent doing right now" scan renders only the
* 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.
@@ -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' }
@@ -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> = {}
): 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.')
})
})
@@ -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',
+30 -2
View File
@@ -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<string, string> | 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, string>
): 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