diff --git a/libs/ag-ui/src/lib/reducer.spec.ts b/libs/ag-ui/src/lib/reducer.spec.ts index cf1b5624..6684d74e 100644 --- a/libs/ag-ui/src/lib/reducer.spec.ts +++ b/libs/ag-ui/src/lib/reducer.spec.ts @@ -432,4 +432,25 @@ describe('ACTIVITY events (F5 subagent activities)', () => { // merge: status updated, text preserved expect(store.activities().get('tc-1')?.content()).toEqual({ status: 'complete', text: 'hello' }); }); + + it('ACTIVITY_DELTA with a malformed patch (non-existent path) does not throw and leaves content unchanged', () => { + // Regression guard: an out-of-order ACTIVITY_DELTA (e.g. replace /messages/5/content + // when there are 0 messages) must be dropped — not thrown — so the stream stays usable. + const store = makeStore(); + reduceEvent({ type: 'ACTIVITY_SNAPSHOT', messageId: 'tc-1', activityType: 'subagent', + content: { status: 'running', text: 'prior' } } as any, store); + // Send a patch that targets a non-existent array index — applyPatch throws without the guard. + expect(() => + reduceEvent({ type: 'ACTIVITY_DELTA', messageId: 'tc-1', activityType: 'subagent', + patch: [{ op: 'replace', path: '/messages/5/content', value: 'x' }] } as any, store), + ).not.toThrow(); + // Prior content must be preserved unchanged. + const content = store.activities().get('tc-1')?.content(); + expect(content?.['text']).toBe('prior'); + expect(content?.['status']).toBe('running'); + // Subsequent valid patches must still apply (store remains usable). + reduceEvent({ type: 'ACTIVITY_DELTA', messageId: 'tc-1', activityType: 'subagent', + patch: [{ op: 'replace', path: '/text', value: 'updated' }] } as any, store); + expect(store.activities().get('tc-1')?.content()['text']).toBe('updated'); + }); }); diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts index 6dc2cadc..38e0818f 100644 --- a/libs/ag-ui/src/lib/reducer.ts +++ b/libs/ag-ui/src/lib/reducer.ts @@ -353,7 +353,15 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { }; const entry = store.activities().get(e.messageId); if (!entry) return; // unknown activity — ignore - entry.content.update((c) => applyPatch(c, e.patch)); // inner signal → live, no map churn + entry.content.update((c) => { + try { + return applyPatch(c, e.patch); + } catch (err) { + // A malformed/out-of-order ACTIVITY_DELTA must not break the stream — drop it. + if (typeof console !== 'undefined') console.warn('[ag-ui] dropping malformed ACTIVITY_DELTA patch', err); + return c; + } + }); // inner signal → live, no map churn return; } default: { diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index 34d0482d..4df3211f 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -677,4 +677,21 @@ describe('subagents transcript projection (F5-transcript)', () => { expect(sa?.messages()).toEqual([{ id: 'sub-1', role: 'assistant', content: 'partial' }]); expect(sa?.toolCalls!()).toEqual([]); }); + + it('coerces role:"tool" to role:"assistant" in subagent message projection', () => { + // Regression guard: a buggy/future emitter putting role:'tool' in messages[] + // must not leak into the rendered subagent card — the subagent transcript is + // assistant turns only; tool/system/user don't belong there. + const source = new StubAgent(); + const agent = toAgent(source as never); + source.emit(snapshotWithContent('tc-1', { + status: 'running', + messages: [{ id: 'm1', role: 'tool', content: 'leak', toolCallIds: [] }], + }) as never); + const sa = agent.subagents!().get('tc-1'); + expect(sa?.messages()[0].role).toBe('assistant'); + // Content and id must pass through unchanged. + expect(sa?.messages()[0].content).toBe('leak'); + expect(sa?.messages()[0].id).toBe('m1'); + }); }); diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index 1917fae2..8c39d79d 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -251,7 +251,7 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag if (Array.isArray(raw)) { return (raw as Array>).map((m, i) => ({ id: (m['id'] as string) ?? `${id}-${i}`, - role: (m['role'] as Message['role']) ?? 'assistant', + role: 'assistant' as Message['role'], content: typeof m['content'] === 'string' ? (m['content'] as string) : (m['content'] as Message['content']) ?? '', ...(Array.isArray(m['toolCallIds']) ? { toolCallIds: m['toolCallIds'] as string[] } : {}), ...(typeof m['reasoning'] === 'string' ? { reasoning: m['reasoning'] as string } : {}),