mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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}
|
||||
|
||||
+33
@@ -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>
|
||||
)
|
||||
}
|
||||
+79
@@ -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
|
||||
}
|
||||
+52
-5
@@ -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 ? (
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user