diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 7165eca9d04..83c5cedc9f3 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -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} + + + ) +} + +export const brief = defineFixture({ width: 640, render: () => }) + +export const folded = defineFixture({ width: 640, render: () => }) diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentPrompt.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentPrompt.tsx new file mode 100644 index 00000000000..3c47d9ed1f3 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentPrompt.tsx @@ -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 ( +
+

+ {t('components.native-chat.subagents.promptFromMain', 'Prompt from main agent')} +

+ + + +
+ ) +} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.test.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.test.ts new file mode 100644 index 00000000000..4e43a62a703 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.test.ts @@ -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: + ' Bash\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 }) + }) +}) diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.ts new file mode 100644 index 00000000000..a95e06cc03a --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.ts @@ -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 +}): 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 +} diff --git a/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-scope.tsx b/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-scope.tsx index 51ea32548a4..2014c3b730b 100644 --- a/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-scope.tsx +++ b/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-scope.tsx @@ -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 = 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 ? ( + + ) : null, /** Drawn above a subagent's own transcript. */ bar: agentId !== null && open ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d7372d4a7a2..b4a4adeffc0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.",