feat(native-chat): show what the main agent asked a subagent and its reply in the subagent's view

A subagent's view now opens with the prompt the main agent gave it, folded
past a few lines, and ends with its final reply once it settles. Claude's
prompt and reply come from the Agent call its run names; Codex's prompt
comes from the spawn call naming the child, and its reply is already its
last own message.
This commit is contained in:
Kelvin Amoaba
2026-11-13 19:38:59 +00:00
parent a9a439a98c
commit 978cfacc4e
7 changed files with 299 additions and 6 deletions
@@ -389,6 +389,7 @@ export function NativeChatMessageList({
// Shares the composer's maximum width; rail clearance and the 5px inner inset narrow constrained panes.
className={NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS}
>
{scope.lead}
<NativeChatTranscriptItems
slots={slots}
timedRows={timedRows}
@@ -0,0 +1,33 @@
import { defineFixture } from '../../../component-fixtures/component-fixture'
import { nativeChatAppearanceStyle } from '../appearance/native-chat-appearance-style'
import { NativeChatSubagentPrompt } from './NativeChatSubagentPrompt'
const short = 'List the files in `src/` and read `src/math.ts`. Report what it exports.'
const long = [
'Audit the auth flow for token refresh bugs.',
'',
'1. Read `src/auth/session.ts` and `src/auth/refresh.ts`.',
'2. Find every place a request retries after a **401**.',
'3. Check whether two refreshes can run at once.',
'4. Note any timer that refreshes before expiry.',
'5. Look for tests that cover a refresh race.',
'6. Do not change any files.',
'7. Reply with a short list of findings, most serious first.',
'8. Include file paths and line numbers for each finding.'
].join('\n')
function Prompt({ prompt }: { prompt: string }) {
return (
<div
className="native-chat-appearance bg-chat-canvas p-4"
style={nativeChatAppearanceStyle({})}
>
<NativeChatSubagentPrompt agentId="a1" prompt={prompt} />
</div>
)
}
export const brief = defineFixture({ width: 640, render: () => <Prompt prompt={short} /> })
export const folded = defineFixture({ width: 640, render: () => <Prompt prompt={long} /> })
@@ -0,0 +1,35 @@
import { useTranslate } from '@/i18n/use-translate'
import { NativeChatMarkdown } from '../markdown/NativeChatMarkdown'
import { NativeChatCodeBlock } from '../markdown/code/NativeChatCodeBlock'
import { NativeChatUserMessageFold } from '../messages/rows/NativeChatUserMessageFold'
/** Opens a subagent's view with the task the main agent gave it, folded like a long prompt. */
export function NativeChatSubagentPrompt({
agentId,
prompt
}: {
agentId: string
prompt: string
}): React.JSX.Element {
const t = useTranslate()
return (
<section className="rounded-xl border border-chat-user-border bg-chat-user-surface px-3.5 py-2.5">
<h3 className="mb-1 font-sans text-xs text-muted-foreground">
{t('components.native-chat.subagents.promptFromMain', 'Prompt from main agent')}
</h3>
<NativeChatUserMessageFold
messageId={`subagent-prompt:${agentId}`}
markdown={prompt}
className="-mx-3.5 px-3.5"
>
<NativeChatMarkdown
rowKey={`subagent-prompt:${agentId}`}
content={prompt}
variant="document"
className="text-sm text-chat-foreground-strong"
renderCodeBlock={NativeChatCodeBlock}
/>
</NativeChatUserMessageFold>
</section>
)
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../../shared/agent-session/journal/agent-session-journal-types'
import type { NativeChatMessage } from '../../../../../shared/native-chat/native-chat-types'
import { nativeChatSubagentHandoff } from './native-chat-subagent-handoff'
function row(id: string, blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' }
}
describe('nativeChatSubagentHandoff', () => {
it("reads a Claude subagent's prompt and reply from the Agent call its run names", () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handoff reads only each item's body blocks.
const journalItems = [
{
itemId: 'spawn',
revision: 1,
sequence: 2,
observedAt: 1,
body: {
kind: 'message',
role: 'assistant',
blocks: [
{
type: 'subagent',
id: 'agent-a',
label: 'List src',
state: 'completed',
groupId: 'g',
run: 'toolu_2'
}
]
}
}
] as unknown as AgentJournalRenderItem[]
const conversation = [
row('call', [
{ type: 'tool-call', name: 'Agent', callId: 'toolu_1', input: { prompt: 'Other task' } },
{ type: 'tool-call', name: 'Agent', callId: 'toolu_2', input: { prompt: 'List src/' } },
{ type: 'tool-result', callId: 'toolu_1', output: 'Other reply' },
{
type: 'tool-result',
callId: 'toolu_2',
output:
' <tools-used-before-summary>Bash</tools-used-before-summary>\n \n[Subagent hand-back] The report follows:\n src holds:\n \n - `math.ts`'
}
])
]
expect(
nativeChatSubagentHandoff({
agentId: 'agent-a',
journalItems,
conversation,
subagentRows: new Map()
})
).toEqual({ prompt: 'List src/', reply: 'src holds:\n\n- `math.ts`' })
})
it("reads a Codex subagent's prompt from the spawn call naming it, leaving its reply to its own rows", () => {
const conversation = [
row('spawn', [
{
type: 'tool-call',
name: 'spawn_agent',
callId: 'call_1',
input: { agents: ['thread-child'], prompt: 'Map token refresh' }
},
{ type: 'tool-result', callId: 'call_1', output: 'Spawned' }
])
]
expect(
nativeChatSubagentHandoff({
agentId: 'thread-child',
journalItems: [],
conversation,
subagentRows: new Map()
})
).toEqual({ prompt: 'Map token refresh', reply: null })
})
})
@@ -0,0 +1,97 @@
// What the main agent asked a subagent to do and, for Claude, the reply its spawn call returned.
import type { AgentJournalRenderItem } from '../../../../../shared/agent-session/journal/agent-session-journal-types'
import { codexCollabRowAgentIds } from '../../../../../shared/codex-collab-agent-tools'
import {
isSubagentBlock,
isToolCallBlock,
type NativeChatMessage
} from '../../../../../shared/native-chat/native-chat-types'
import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection'
import { pairNativeChatToolResults } from '../../../../../shared/native-chat/tools/native-chat-tool-pairing'
export type NativeChatSubagentHandoff = {
prompt: string | null
/** The spawn call's result: Claude's `Agent` call returns the subagent's whole final reply. */
reply: string | null
}
const NO_HANDOFF: NativeChatSubagentHandoff = { prompt: null, reply: null }
/** Claude names the spawning `Agent` call as the run of the subagent's newest revision. */
function spawnRunOf(
journalItems: readonly AgentJournalRenderItem[] | undefined,
agentId: string
): string | undefined {
for (let index = (journalItems?.length ?? 0) - 1; index >= 0; index -= 1) {
const body = journalItems![index]!.body
if (body.kind !== 'message') {
continue
}
const block = body.blocks.find(
(candidate) => isSubagentBlock(candidate) && candidate.id === agentId
)
if (block && isSubagentBlock(block) && block.run !== undefined) {
return block.run
}
}
return undefined
}
/** Claude frames the report it hands back: a column-zero `[Subagent hand-back]` line, then every report line indented two spaces. */
function handBackReport(output: string): string {
const lines = output.split('\n')
const frame = lines.findLastIndex((line) => line.startsWith('[Subagent hand-back]'))
return frame === -1
? output.trim()
: lines
.slice(frame + 1)
.map((line) => line.replace(/^ {2}/, ''))
.join('\n')
.trim()
}
function promptOf(input: unknown): string | null {
const prompt =
typeof input === 'object' && input !== null && 'prompt' in input ? input.prompt : null
return typeof prompt === 'string' && prompt.trim() ? prompt : null
}
/** The spawn call is Claude's call named by the run, or the Codex `spawn_agent` call naming the child. */
export function nativeChatSubagentHandoff({
agentId,
journalItems,
conversation,
subagentRows
}: {
agentId: string
journalItems: readonly AgentJournalRenderItem[] | undefined
conversation: readonly NativeChatMessage[]
subagentRows: ReadonlyMap<string, readonly NativeChatSubagentRow[]>
}): NativeChatSubagentHandoff {
const run = spawnRunOf(journalItems, agentId)
const spawners = [
conversation,
...Array.from(subagentRows.values(), (rows) => rows.map(({ message }) => message))
]
for (const messages of spawners) {
for (const message of messages) {
for (const block of message.blocks) {
if (!isToolCallBlock(block)) {
continue
}
if (run !== undefined && block.callId === run) {
const result = pairNativeChatToolResults(message.blocks).resultByCall.get(block)
return {
prompt: promptOf(block.input),
reply: (result && !result.isError && handBackReport(result.output)) || null
}
}
if (block.name === 'spawn_agent' && codexCollabRowAgentIds(block.input).includes(agentId)) {
return { prompt: promptOf(block.input), reply: null }
}
}
}
}
return NO_HANDOFF
}
@@ -24,12 +24,43 @@ import {
nativeChatSubagentRowsInOrder
} from './native-chat-subagent-scope'
import { NativeChatSubagentViewBar } from './NativeChatSubagentViewBar'
import { NativeChatSubagentPrompt } from './NativeChatSubagentPrompt'
import { nativeChatSubagentHandoff } from './native-chat-subagent-handoff'
const NO_MESSAGES: readonly NativeChatMessage[] = []
const NO_SUBAGENT_ROWS: readonly NativeChatSubagentRow[] = []
const NO_TURN_KEYS: readonly (string | undefined)[] = []
const NO_ENTRIES: ReadonlyMap<string, NativeChatSubagentEntry> = new Map()
function words(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
/** Codex's last own message is its reply; a clipped copy of it counts as the same. */
function nativeChatRowsEndWith(rows: readonly NativeChatMessage[], reply: string): boolean {
const last = rows.findLast((row) => row.role === 'assistant')
const said = words(
last?.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('\n') ?? ''
)
const replied = words(reply)
return said.length > 0 && (said.startsWith(replied) || replied.startsWith(said))
}
/** A settled subagent's reply, drawn as its last message. */
function subagentReplyMessage(
agentId: string,
reply: string,
settledAt: number | undefined
): NativeChatMessage {
return {
id: `subagent-reply:${agentId}`,
role: 'assistant',
blocks: [{ type: 'text', text: reply }],
timestamp: settledAt ?? null,
source: 'transcript'
}
}
/** What a transcript draws: the conversation in its turns, or one subagent's own rows with none. */
export function useNativeChatSubagentScope({
view,
@@ -59,13 +90,25 @@ export function useNativeChatSubagentScope({
: nativeChatSubagentEntries(conversation, subagentRows, roster),
[agentId, conversation, roster, subagentRows]
)
const messages = useMemo(
const entry = agentId === null ? undefined : entries.get(agentId)
const settled = entry !== undefined && normalizeSubagentState(entry.state) !== 'working'
const handoff = useMemo(
() =>
agentId === null
? conversation
: (subagentRows.get(agentId) ?? NO_SUBAGENT_ROWS).map(({ message }) => message),
[agentId, conversation, subagentRows]
? null
: nativeChatSubagentHandoff({ agentId, journalItems, conversation, subagentRows }),
[agentId, conversation, journalItems, subagentRows]
)
const reply = settled ? (handoff?.reply ?? entry.lastMessage ?? null) : null
const messages = useMemo(() => {
if (agentId === null) {
return conversation
}
const own = (subagentRows.get(agentId) ?? NO_SUBAGENT_ROWS).map(({ message }) => message)
return reply === null || nativeChatRowsEndWith(own, reply)
? own
: [...own, subagentReplyMessage(agentId, reply, entry?.settledAt)]
}, [agentId, conversation, entry?.settledAt, reply, subagentRows])
const heads = useMemo(
() => nativeChatSubagentHeads(messages, subagentRows, entries, agentId),
[agentId, entries, messages, subagentRows]
@@ -101,11 +144,15 @@ export function useNativeChatSubagentScope({
}) === 'page',
[agentId, conversation, journalItems, roster, subagentRows]
)
const entry = agentId === null ? undefined : entries.get(agentId)
const open = view?.open
return {
agentId,
open,
/** Leads a subagent's transcript: what the main agent asked of it. */
lead:
agentId !== null && handoff?.prompt ? (
<NativeChatSubagentPrompt agentId={agentId} prompt={handoff.prompt} />
) : null,
/** Drawn above a subagent's own transcript. */
bar:
agentId !== null && open ? (
+2 -1
View File
@@ -21080,7 +21080,8 @@
"ran_other": "Ran {{count}} subagents",
"open": "Open subagent",
"backToMain": "Back to main agent",
"mainAgent": "Main agent"
"mainAgent": "Main agent",
"promptFromMain": "Prompt from main agent"
},
"rewind": {
"unsupported": "This chat can't go back to an earlier message.",