mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ function slot(id: string): NativeChatTranscriptSlot {
|
||||
folded: false,
|
||||
turnFolds: false,
|
||||
turnDiff: undefined,
|
||||
subagentLabel: undefined,
|
||||
estimatedHeight: 48
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -17705,7 +17705,9 @@
|
||||
"startedN": "{{value0}} サブ Agent をキックオフしました",
|
||||
"ranOne": "1 人のサブ Agent を実行しました",
|
||||
"ranN": "{{value0}} サブ Agent を実行しました",
|
||||
"tokens": "{{value0}} トークン"
|
||||
"tokens": "{{value0}} トークン",
|
||||
"unnamed": "サブ Agent",
|
||||
"writtenBy": "サブ Agent {{value0}} による出力"
|
||||
},
|
||||
"conversationCommand": {
|
||||
"pendingWork": "このコマンドを使用する前に、保留中の作業とメッセージが完了するまで待ってください。",
|
||||
|
||||
@@ -17705,7 +17705,9 @@
|
||||
"startedN": "{{value0}} 하위 에이전트가 시작되었습니다.",
|
||||
"ranOne": "하위 에이전트 1개 실행",
|
||||
"ranN": "{{value0}} 하위 에이전트를 실행했습니다.",
|
||||
"tokens": "{{value0}} 토큰"
|
||||
"tokens": "{{value0}} 토큰",
|
||||
"unnamed": "하위 에이전트",
|
||||
"writtenBy": "하위 에이전트 {{value0}}의 출력"
|
||||
},
|
||||
"conversationCommand": {
|
||||
"pendingWork": "이 명령을 사용하기 전에 보류 중인 작업과 메시지가 완료될 때까지 기다리십시오.",
|
||||
|
||||
@@ -17670,7 +17670,9 @@
|
||||
"startedN": "启动 {{value0}} 子代理",
|
||||
"ranOne": "Ran 1 子代理",
|
||||
"ranN": "Ran {{value0}} 子代理",
|
||||
"tokens": "{{value0}} 代币"
|
||||
"tokens": "{{value0}} 代币",
|
||||
"unnamed": "子代理",
|
||||
"writtenBy": "由子代理 {{value0}} 输出"
|
||||
},
|
||||
"conversationCommand": {
|
||||
"pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user