diff --git a/libs/ag-ui/fixtures/runtime-transcripts/subagent-lifecycle.json b/libs/ag-ui/fixtures/runtime-transcripts/subagent-lifecycle.json new file mode 100644 index 000000000..cdc6ce876 --- /dev/null +++ b/libs/ag-ui/fixtures/runtime-transcripts/subagent-lifecycle.json @@ -0,0 +1,19 @@ +[ + { "type": "RUN_STARTED", "threadId": "thread-subagent-lifecycle", "runId": "run-subagent-1" }, + { "type": "TEXT_MESSAGE_START", "messageId": "m-parent-1", "role": "assistant" }, + { "type": "TEXT_MESSAGE_CONTENT", "messageId": "m-parent-1", "delta": "Let me check that for you." }, + { "type": "TEXT_MESSAGE_END", "messageId": "m-parent-1" }, + { "type": "TOOL_CALL_START", "toolCallId": "call-9", "toolCallName": "research_availability", "parentMessageId": "m-parent-1" }, + { "type": "SUBAGENT_STARTED", "subagentRunId": "sa-1", "name": "researcher", "parentToolCallId": "call-9" }, + { "type": "TEXT_MESSAGE_START", "messageId": "sa-1-m1", "role": "assistant", "subagentRunId": "sa-1" }, + { "type": "TEXT_MESSAGE_CONTENT", "messageId": "sa-1-m1", "delta": "Checking ", "subagentRunId": "sa-1" }, + { "type": "TEXT_MESSAGE_CONTENT", "messageId": "sa-1-m1", "delta": "availability", "subagentRunId": "sa-1" }, + { "type": "TEXT_MESSAGE_END", "messageId": "sa-1-m1", "subagentRunId": "sa-1" }, + { "type": "SUBAGENT_FINISHED", "subagentRunId": "sa-1", "outcome": { "type": "success" } }, + { "type": "TOOL_CALL_END", "toolCallId": "call-9" }, + { "type": "TOOL_CALL_RESULT", "messageId": "m-result-1", "toolCallId": "call-9", "content": "Availability confirmed for Tuesday.", "role": "tool" }, + { "type": "TEXT_MESSAGE_START", "messageId": "m-parent-2", "role": "assistant" }, + { "type": "TEXT_MESSAGE_CONTENT", "messageId": "m-parent-2", "delta": "You're all set for Tuesday." }, + { "type": "TEXT_MESSAGE_END", "messageId": "m-parent-2" }, + { "type": "RUN_FINISHED", "threadId": "thread-subagent-lifecycle", "runId": "run-subagent-1", "outcome": { "type": "success" } } +] diff --git a/libs/ag-ui/src/lib/reducer.runtime-interrupts.spec.ts b/libs/ag-ui/src/lib/reducer.runtime-interrupts.spec.ts index a8e0c37a2..f903b7a1e 100644 --- a/libs/ag-ui/src/lib/reducer.runtime-interrupts.spec.ts +++ b/libs/ag-ui/src/lib/reducer.runtime-interrupts.spec.ts @@ -39,6 +39,12 @@ function readSseFixture(name: string): BaseEvent[] { .map((line) => JSON.parse(line.slice('data:'.length)) as BaseEvent); } +/** Parse a plain JSON-array transcript (synthetic, not an SSE capture). */ +function readJsonFixture(name: string): BaseEvent[] { + const raw = readFileSync(join(FIXTURES_DIR, name), 'utf8'); + return JSON.parse(raw) as BaseEvent[]; +} + function makeStore(generation = 'run-generation-1'): ReducerStore { let sequence = 0; return { @@ -280,3 +286,62 @@ describe('toAgent end-to-end — Strands interrupt transcript through the adapte expect(agent.error()).toBeUndefined(); }); }); + +describe('toAgent end-to-end — synthetic subagent-lifecycle transcript', () => { + // Source: subagent-lifecycle.json — a synthetic (not vendor-captured) wire + // sequence pinning the SUBAGENT_STARTED/FINISHED contract: a parent tool + // call (call-9) spawns a subagentRunId-attributed child (sa-1) whose + // TEXT_MESSAGE_* events must route into subagents(), never the parent + // transcript, while the parent's own TOOL_CALL_END/RESULT for call-9 stay + // on the parent side because they carry no subagentRunId. + class StubAgent { + state: Record = {}; + private readonly subscribers: Array<{ + onEvent?: (p: { event: BaseEvent; input: { runId?: string } }) => void; + }> = []; + subscribe(sub: { onEvent?: (p: { event: BaseEvent; input: { runId?: string } }) => void }) { + this.subscribers.push(sub); + return { unsubscribe: () => undefined }; + } + emit(event: BaseEvent, callbackRunId?: string): void { + for (const sub of this.subscribers) sub.onEvent?.({ event, input: { runId: callbackRunId } }); + } + runAgent = vi.fn(async () => ({ result: undefined, newMessages: [] })); + abortRun = vi.fn(); + addMessage = vi.fn(); + setMessages = vi.fn(); + } + + it('routes the child transcript into subagents() and keeps the parent transcript to its own two messages', async () => { + const stub = new StubAgent(); + const agent = toAgent(stub as unknown as AbstractAgent); + let finishRun!: () => void; + stub.runAgent.mockImplementationOnce(() => new Promise((resolve) => { + finishRun = () => resolve({ result: undefined, newMessages: [] }); + })); + + const submitted = agent.submit({ message: 'Can you check availability and confirm?' }); + for (const event of readJsonFixture('subagent-lifecycle.json')) { + stub.emit(event, 'run-subagent-1'); + } + finishRun(); + await submitted; + + const parentAssistantMessages = agent.messages().filter((m) => m.role === 'assistant'); + expect(parentAssistantMessages).toHaveLength(2); + expect(parentAssistantMessages.map((m) => m.id)).toEqual(['m-parent-1', 'm-parent-2']); + + const subagents = agent.subagents!(); + expect(subagents.size).toBe(1); + const sa = subagents.get('sa-1'); + expect(sa).toBeDefined(); + expect(sa!.toolCallId).toBe('call-9'); + expect(sa!.name).toBe('researcher'); + expect(sa!.status()).toBe('complete'); + expect(sa!.messages()).toHaveLength(1); + expect(sa!.messages()[0]).toMatchObject({ id: 'sa-1-m1', content: 'Checking availability' }); + + // The child's messageId must never leak into the parent transcript. + expect(agent.messages().some((m) => m.id === 'sa-1-m1')).toBe(false); + }); +}); diff --git a/libs/ag-ui/src/lib/reducer.subagent.spec.ts b/libs/ag-ui/src/lib/reducer.subagent.spec.ts new file mode 100644 index 000000000..2ba8f6235 --- /dev/null +++ b/libs/ag-ui/src/lib/reducer.subagent.spec.ts @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { signal } from '@angular/core'; +import { Subject } from 'rxjs'; +import { + AgentError, + type AgentStatus, + type Message, + type ToolCall, + type AgentEvent, +} from '@threadplane/chat'; +import type { BaseEvent } from '@ag-ui/core'; +import { reduceEvent, type ReducerStore, type CustomStreamEvent, type ActivityEntry } from './reducer'; + +interface TestDeliveryRun { + generation: string; + baselineMessageIds: Set; + ownedMessageIds: Set; + snapshotReplacementIds: Set; + currentAssistantMessageId?: string; + eligibleBaselineAssistantId?: string; + protocolRunId?: string; + outcome?: 'success' | 'error' | 'aborted' | 'interrupted' | 'paused'; +} + +type TestStore = ReducerStore & { + deliveryRun: TestDeliveryRun | null; + allocateDeliveryGeneration: (scope: string) => string; +}; + +function makeStore(generation = 'run-generation-1'): TestStore { + let activitySequence = 0; + return { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(undefined), + toolCalls: signal([]), + state: signal>({}), + interrupt: signal(undefined), + events$: new Subject(), + customEvents: signal([]), + activities: signal>(new Map()), + deliveryRun: { + generation, + baselineMessageIds: new Set(), + ownedMessageIds: new Set(), + snapshotReplacementIds: new Set(), + }, + allocateDeliveryGeneration: (scope: string) => `${generation}:${scope}:${++activitySequence}`, + } as TestStore; +} + +const ev = (e: Record) => e as unknown as BaseEvent; + +describe('reduceEvent SUBAGENT_* lifecycle', () => { + it('SUBAGENT_STARTED creates a running subagent activity entry', () => { + const store = makeStore(); + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-1', name: 'researcher', parentToolCallId: 'call-9' }), store); + const entry = store.activities().get('sa-1'); + expect(entry?.activityType).toBe('subagent'); + expect(entry?.content()['status']).toBe('running'); + expect(entry?.content()['name']).toBe('researcher'); + expect(entry?.content()['toolCallId']).toBe('call-9'); + }); + + it('SUBAGENT_STARTED without parentToolCallId keys the card by subagentRunId', () => { + const store = makeStore(); + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-2', name: 'forecaster' }), store); + expect(store.activities().get('sa-2')?.content()['toolCallId']).toBe('sa-2'); + }); + + it('attributed TEXT_MESSAGE events feed the child entry and never the transcript', () => { + const store = makeStore(); + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-1', name: 'researcher' }), store); + reduceEvent(ev({ type: 'TEXT_MESSAGE_START', messageId: 'm-1', role: 'assistant', subagentRunId: 'sa-1' }), store); + reduceEvent(ev({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm-1', delta: 'Checking ', subagentRunId: 'sa-1' }), store); + reduceEvent(ev({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm-1', delta: 'flights', subagentRunId: 'sa-1' }), store); + const msgs = store.activities().get('sa-1')?.content()['messages'] as Array>; + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatchObject({ id: 'm-1', role: 'assistant', content: 'Checking flights' }); + expect(store.messages().some((m) => m.id === 'm-1')).toBe(false); // structural rule + }); + + it('attributed TOOL_CALL events feed the child, not the parent toolCalls signal', () => { + const store = makeStore(); + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-1', name: 'researcher' }), store); + reduceEvent(ev({ type: 'TOOL_CALL_START', toolCallId: 't-1', toolCallName: 'web_search', subagentRunId: 'sa-1' }), store); + reduceEvent(ev({ type: 'TOOL_CALL_ARGS', toolCallId: 't-1', delta: '{"q":"x"}', subagentRunId: 'sa-1' }), store); + reduceEvent(ev({ type: 'TOOL_CALL_END', toolCallId: 't-1', subagentRunId: 'sa-1' }), store); + const calls = store.activities().get('sa-1')?.content()['toolCalls'] as Array>; + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ id: 't-1', name: 'web_search', status: 'complete', args: { q: 'x' } }); + expect(store.toolCalls()).toHaveLength(0); + }); + + it('an attributed event before SUBAGENT_STARTED creates the entry instead of dropping (buffer-not-drop)', () => { + const store = makeStore(); + reduceEvent(ev({ type: 'TEXT_MESSAGE_START', messageId: 'm-1', role: 'assistant', subagentRunId: 'sa-late' }), store); + reduceEvent(ev({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm-1', delta: 'early', subagentRunId: 'sa-late' }), store); + const beforeGeneration = store.activities().get('sa-late')!.generation; + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-late', name: 'researcher', parentToolCallId: 'call-9' }), store); + const entry = store.activities().get('sa-late')!; + const content = entry.content(); + expect(content['name']).toBe('researcher'); + expect(content['toolCallId']).toBe('call-9'); + const msgs = content['messages'] as Array>; + expect(msgs[0]).toMatchObject({ content: 'early' }); + // The placeholder identity from the buffer-not-drop entry must not leak + // into a wrapper cached before STARTED arrived — identity changes force a + // fresh generation so to-agent.ts's (id, generation)-keyed cache rebuilds. + expect(entry.generation).not.toBe(beforeGeneration); + }); + + it('resume cycle: a re-announce after a fresh RUN_STARTED does not duplicate or lose identity', () => { + const store = makeStore(); + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-1', name: 'researcher' }), store); + reduceEvent(ev({ type: 'SUBAGENT_FINISHED', subagentRunId: 'sa-1', outcome: { type: 'suspended', interruptIds: ['i-1'] } }), store); + reduceEvent(ev({ type: 'RUN_STARTED' }), store); + expect(store.activities().size).toBe(0); // new run clears activities + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-1', name: 'researcher' }), store); + expect(store.activities().size).toBe(1); + const entry = store.activities().get('sa-1')!; + expect(entry.content()['status']).toBe('running'); + expect(entry.content()['name']).toBe('researcher'); + }); + + it('SUBAGENT_FINISHED success completes; suspended stays running; re-announce after suspend does not duplicate', () => { + const store = makeStore(); + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-1', name: 'researcher' }), store); + reduceEvent(ev({ type: 'SUBAGENT_FINISHED', subagentRunId: 'sa-1', outcome: { type: 'suspended', interruptIds: ['i-1'] } }), store); + expect(store.activities().get('sa-1')?.content()['status']).toBe('running'); + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-1', name: 'researcher' }), store); + expect(store.activities().size).toBe(1); + reduceEvent(ev({ type: 'SUBAGENT_FINISHED', subagentRunId: 'sa-1', outcome: { type: 'success' }, result: 'booked' }), store); + expect(store.activities().get('sa-1')?.content()['status']).toBe('complete'); + }); + + it('SUBAGENT_ERROR marks the entry error and records the message', () => { + const store = makeStore(); + reduceEvent(ev({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-1', name: 'researcher' }), store); + reduceEvent(ev({ type: 'SUBAGENT_ERROR', subagentRunId: 'sa-1', message: 'rate limited', code: '429' }), store); + const content = store.activities().get('sa-1')!.content(); + expect(content['status']).toBe('error'); + expect((content['state'] as Record)['error']).toBe('rate limited'); + }); + + it('unattributed events behave exactly as before (regression)', () => { + const store = makeStore(); + reduceEvent(ev({ type: 'TEXT_MESSAGE_START', messageId: 'm-1', role: 'assistant' }), store); + reduceEvent(ev({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm-1', delta: 'hello' }), store); + expect(store.messages().find((m) => m.id === 'm-1')?.content).toBe('hello'); + expect(store.activities().size).toBe(0); + }); +}); diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts index b551bcc07..b9ff7f66b 100644 --- a/libs/ag-ui/src/lib/reducer.ts +++ b/libs/ag-ui/src/lib/reducer.ts @@ -135,6 +135,16 @@ function resolveReasoningDurationMs(messageId: string): number | undefined { * for testability — no side effects beyond the supplied store. */ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { + // A subagentRunId on a content event means the child produced it: route it + // into that subagent's activity entry and never into the parent transcript — + // the same structural rule @threadplane/langgraph applies to namespaced + // events. Scope: text + tool events (what our emitters produce). Reasoning/ + // step attribution is deliberately not routed yet (YAGNI). + const subagentRunId = (event as { subagentRunId?: string }).subagentRunId; + if (subagentRunId && SUBAGENT_ROUTED_TYPES.has(event.type as string)) { + routeSubagentContentEvent(subagentRunId, event, store); + return; + } switch (event.type) { case 'RUN_STARTED': { const run = store.deliveryRun; @@ -470,6 +480,78 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { } return; } + case 'SUBAGENT_STARTED': { + const e = event as unknown as { + subagentRunId: string; name: string; description?: string; parentToolCallId?: string; + }; + const existing = store.activities().get(e.subagentRunId); + const existingContent = existing?.content(); + const nextToolCallId = e.parentToolCallId + ?? (existingContent?.['toolCallId'] as string | undefined) + ?? e.subagentRunId; + // Identity is "placeholder" when this is the first sighting, or when a + // buffer-not-drop entry (created by an attributed content event that + // arrived before STARTED) still carries its placeholder name/toolCallId. + // to-agent.ts's wrapper cache keys on (id, generation) and snapshots + // name/toolCallId at creation — a content-only update on the SAME + // generation would leave an already-cached wrapper permanently stale, + // so identity changes must land on a freshly allocated generation. + const identityChanged = !existing + || existingContent?.['name'] !== e.name + || existingContent?.['toolCallId'] !== nextToolCallId; + const mergedContent: Record = { + ...(existingContent ?? { messages: [], toolCalls: [] }), + name: e.name, + ...(e.description !== undefined ? { description: e.description } : {}), + toolCallId: nextToolCallId, + status: 'running', + }; + if (identityChanged) { + const entry: ActivityEntry = { + messageId: e.subagentRunId, + activityType: 'subagent', + generation: store.allocateDeliveryGeneration(`activity:${e.subagentRunId}`), + content: signal>(mergedContent), + }; + const map = new Map(store.activities()); + map.set(e.subagentRunId, entry); + store.activities.set(map); // membership/identity change → new ref + } else { + // Re-announce (e.g. after a suspend) with unchanged identity: content- + // only update, no map churn — mirrors ACTIVITY_DELTA's idiom. + existing.content.update((c) => ({ ...c, ...mergedContent })); + } + return; + } + case 'SUBAGENT_FINISHED': { + const e = event as unknown as { + subagentRunId: string; result?: unknown; outcome?: { type: 'success' | 'suspended' }; + }; + const entry = store.activities().get(e.subagentRunId); + if (!entry) return; + // Suspended keeps the card running: the run resumes with the same id, + // and the interrupt itself surfaces through the interrupt signal. + const status = e.outcome?.type === 'suspended' ? 'running' : 'complete'; + entry.content.update((c) => ({ + ...c, + status, + ...(e.result !== undefined + ? { state: { ...((c['state'] as Record) ?? {}), result: e.result } } + : {}), + })); // content-only change → inner signal, no map churn + return; + } + case 'SUBAGENT_ERROR': { + const e = event as unknown as { subagentRunId: string; message: string }; + const entry = store.activities().get(e.subagentRunId); + if (!entry) return; + entry.content.update((c) => ({ + ...c, + status: 'error', + state: { ...((c['state'] as Record) ?? {}), error: e.message }, + })); // content-only change → inner signal, no map churn + return; + } case 'ACTIVITY_SNAPSHOT': { const e = event as unknown as { messageId: string; activityType: string; @@ -521,6 +603,105 @@ function randomId(): string { return Math.random().toString(36).slice(2); } +/** Content events a subagentRunId can route away from the parent transcript. */ +const SUBAGENT_ROUTED_TYPES = new Set([ + 'TEXT_MESSAGE_START', 'TEXT_MESSAGE_CONTENT', 'TEXT_MESSAGE_END', + 'TOOL_CALL_START', 'TOOL_CALL_ARGS', 'TOOL_CALL_END', 'TOOL_CALL_RESULT', +]); + +/** Get-or-create the ActivityEntry for a subagent run, keyed by + * subagentRunId — mirrors ACTIVITY_SNAPSHOT's creation branch exactly + * (same generation allocation, same activities-map replace idiom) so + * the projection in to-agent.ts needs no special-casing. Buffer-not-drop: + * an attributed content event that arrives before SUBAGENT_STARTED still + * gets a card, which SUBAGENT_STARTED then fills in with identity. */ +function ensureSubagentEntry(subagentRunId: string, store: ReducerStore): ActivityEntry { + const existing = store.activities().get(subagentRunId); + if (existing && existing.activityType === 'subagent') return existing; + const entry: ActivityEntry = { + messageId: subagentRunId, + activityType: 'subagent', + generation: store.allocateDeliveryGeneration(`activity:${subagentRunId}`), + content: signal>({ + toolCallId: subagentRunId, + name: '', + status: 'running', + messages: [], + toolCalls: [], + }), + }; + const map = new Map(store.activities()); + map.set(subagentRunId, entry); + store.activities.set(map); + return entry; +} + +/** Route a subagentRunId-attributed content event into that subagent's + * ActivityEntry rather than the parent transcript/toolCalls signal. + * Text and tool-call handling mirrors the parent TEXT_MESSAGE and + * TOOL_CALL cases above, but written against the entry's content record + * instead of store.messages/store.toolCalls. */ +function routeSubagentContentEvent(subagentRunId: string, event: BaseEvent, store: ReducerStore): void { + const entry = ensureSubagentEntry(subagentRunId, store); // buffer-not-drop: creates on first sight + const e = event as unknown as Record; + + // TOOL_CALL_ARGS/END mutate the shared argsBuffers map — a side effect. + // Compute that up front so the content.update callback below stays a pure + // function of its input, same as every other content.update in this file. + let parsedArgs: Record | undefined; + if (event.type === 'TOOL_CALL_ARGS') { + // Same accumulated-buffer rule as the parent handler: deltas are JSON + // fragments; parse the accumulation, keep last-good args. + const buffers = (store.argsBuffers ??= new Map()); + const key = `subagent:${e['toolCallId']}`; + const buffer = (buffers.get(key) ?? '') + ((e['delta'] as string) ?? ''); + buffers.set(key, buffer); + parsedArgs = tryParseArgs(buffer); + } else if (event.type === 'TOOL_CALL_END') { + store.argsBuffers?.delete(`subagent:${e['toolCallId']}`); + } + + entry.content.update((c) => { + const messages = [...((c['messages'] as Array>) ?? [])]; + const toolCalls = [...((c['toolCalls'] as Array>) ?? [])]; + switch (event.type as string) { + case 'TEXT_MESSAGE_START': { + const id = e['messageId'] as string; + if (!messages.some((m) => m['id'] === id)) messages.push({ id, role: 'assistant', content: '' }); + return { ...c, messages }; + } + case 'TEXT_MESSAGE_CONTENT': { + const id = e['messageId'] as string; + const idx = messages.findIndex((m) => m['id'] === id); + if (idx < 0) messages.push({ id, role: 'assistant', content: (e['delta'] as string) ?? '' }); + else messages[idx] = { ...messages[idx], content: `${messages[idx]['content'] ?? ''}${(e['delta'] as string) ?? ''}` }; + return { ...c, messages }; + } + case 'TEXT_MESSAGE_END': + return c; + case 'TOOL_CALL_START': + toolCalls.push({ id: e['toolCallId'], name: e['toolCallName'], args: {}, status: 'running' }); + return { ...c, toolCalls }; + case 'TOOL_CALL_ARGS': + return parsedArgs === undefined + ? c + : { ...c, toolCalls: toolCalls.map((t) => (t['id'] === e['toolCallId'] ? { ...t, args: parsedArgs } : t)) }; + case 'TOOL_CALL_END': + return { ...c, toolCalls: toolCalls.map((t) => (t['id'] === e['toolCallId'] ? { ...t, status: 'complete' } : t)) }; + case 'TOOL_CALL_RESULT': { + // ag_ui_langgraph serialises tool results via normalize_tool_content() + // which always returns a string — parse it the same way the parent + // TOOL_CALL_RESULT handler does so downstream consumers get an object. + const raw = e['content']; + const result = typeof raw === 'string' ? safeParseJson(raw) : raw; + return { ...c, toolCalls: toolCalls.map((t) => (t['id'] === e['toolCallId'] ? { ...t, result } : t)) }; + } + default: + return c; + } + }); // content-only change → inner signal, no map churn +} + /** Loosely-typed RUN_FINISHED outcome. @ag-ui/core@0.0.59 ships the strict * RunFinishedInterruptOutcomeSchema / InterruptSchema for this shape, but * the reducer deliberately keeps this tolerant hand-rolled view: a strict diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index 27735185a..e35bd7058 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -1257,3 +1257,39 @@ describe('subagents transcript projection (F5-transcript)', () => { expect(sa?.messages()[0].delivery).toEqual(staticDelivery('m1')); }); }); + +describe('SUBAGENT_* lifecycle projection', () => { + it('SUBAGENT_STARTED + attributed TEXT_MESSAGE events project into agent.subagents()', () => { + const source = new StubAgent(); + const agent = toAgent(source as never); + source.emit({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-1', name: 'researcher' } as never); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'm-1', role: 'assistant', subagentRunId: 'sa-1' } as never); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm-1', delta: 'Checking ', subagentRunId: 'sa-1' } as never); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm-1', delta: 'flights', subagentRunId: 'sa-1' } as never); + const sa = agent.subagents!().get('sa-1'); + expect(sa?.name).toBe('researcher'); + expect(sa?.messages()).toEqual([ + { id: 'm-1', role: 'assistant', content: 'Checking flights', delivery: expect.objectContaining({ phase: 'streaming' }) }, + ]); + }); + + it('a wrapper read before SUBAGENT_STARTED reflects the real identity once STARTED arrives (no stale name/toolCallId)', () => { + const source = new StubAgent(); + const agent = toAgent(source as never); + // Attributed content arrives first (buffer-not-drop) and a consumer reads + // the projection — caching a wrapper off the placeholder identity — + // before SUBAGENT_STARTED ever shows up. + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'm-1', role: 'assistant', subagentRunId: 'sa-late' } as never); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm-1', delta: 'early', subagentRunId: 'sa-late' } as never); + const before = agent.subagents!().get('sa-late'); + expect(before?.name).toBe(''); + expect(before?.toolCallId).toBe('sa-late'); + + source.emit({ type: 'SUBAGENT_STARTED', subagentRunId: 'sa-late', name: 'researcher', parentToolCallId: 'call-9' } as never); + + const after = agent.subagents!().get('sa-late'); + expect(after?.name).toBe('researcher'); + expect(after?.toolCallId).toBe('call-9'); + expect(after?.messages()[0]).toMatchObject({ id: 'm-1', content: 'early' }); + }); +});