Skip to content

Commit 558bac9

Browse files
bloveclaude
andauthored
fix(ag-ui): graceful ACTIVITY_DELTA applyPatch + enforce assistant-role in subagent projection (#694)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6027a2e commit 558bac9

4 files changed

Lines changed: 48 additions & 2 deletions

File tree

libs/ag-ui/src/lib/reducer.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,4 +432,25 @@ describe('ACTIVITY events (F5 subagent activities)', () => {
432432
// merge: status updated, text preserved
433433
expect(store.activities().get('tc-1')?.content()).toEqual({ status: 'complete', text: 'hello' });
434434
});
435+
436+
it('ACTIVITY_DELTA with a malformed patch (non-existent path) does not throw and leaves content unchanged', () => {
437+
// Regression guard: an out-of-order ACTIVITY_DELTA (e.g. replace /messages/5/content
438+
// when there are 0 messages) must be dropped — not thrown — so the stream stays usable.
439+
const store = makeStore();
440+
reduceEvent({ type: 'ACTIVITY_SNAPSHOT', messageId: 'tc-1', activityType: 'subagent',
441+
content: { status: 'running', text: 'prior' } } as any, store);
442+
// Send a patch that targets a non-existent array index — applyPatch throws without the guard.
443+
expect(() =>
444+
reduceEvent({ type: 'ACTIVITY_DELTA', messageId: 'tc-1', activityType: 'subagent',
445+
patch: [{ op: 'replace', path: '/messages/5/content', value: 'x' }] } as any, store),
446+
).not.toThrow();
447+
// Prior content must be preserved unchanged.
448+
const content = store.activities().get('tc-1')?.content();
449+
expect(content?.['text']).toBe('prior');
450+
expect(content?.['status']).toBe('running');
451+
// Subsequent valid patches must still apply (store remains usable).
452+
reduceEvent({ type: 'ACTIVITY_DELTA', messageId: 'tc-1', activityType: 'subagent',
453+
patch: [{ op: 'replace', path: '/text', value: 'updated' }] } as any, store);
454+
expect(store.activities().get('tc-1')?.content()['text']).toBe('updated');
455+
});
435456
});

libs/ag-ui/src/lib/reducer.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,15 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void {
353353
};
354354
const entry = store.activities().get(e.messageId);
355355
if (!entry) return; // unknown activity — ignore
356-
entry.content.update((c) => applyPatch(c, e.patch)); // inner signal → live, no map churn
356+
entry.content.update((c) => {
357+
try {
358+
return applyPatch(c, e.patch);
359+
} catch (err) {
360+
// A malformed/out-of-order ACTIVITY_DELTA must not break the stream — drop it.
361+
if (typeof console !== 'undefined') console.warn('[ag-ui] dropping malformed ACTIVITY_DELTA patch', err);
362+
return c;
363+
}
364+
}); // inner signal → live, no map churn
357365
return;
358366
}
359367
default: {

libs/ag-ui/src/lib/to-agent.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,4 +677,21 @@ describe('subagents transcript projection (F5-transcript)', () => {
677677
expect(sa?.messages()).toEqual([{ id: 'sub-1', role: 'assistant', content: 'partial' }]);
678678
expect(sa?.toolCalls!()).toEqual([]);
679679
});
680+
681+
it('coerces role:"tool" to role:"assistant" in subagent message projection', () => {
682+
// Regression guard: a buggy/future emitter putting role:'tool' in messages[]
683+
// must not leak into the rendered subagent card — the subagent transcript is
684+
// assistant turns only; tool/system/user don't belong there.
685+
const source = new StubAgent();
686+
const agent = toAgent(source as never);
687+
source.emit(snapshotWithContent('tc-1', {
688+
status: 'running',
689+
messages: [{ id: 'm1', role: 'tool', content: 'leak', toolCallIds: [] }],
690+
}) as never);
691+
const sa = agent.subagents!().get('tc-1');
692+
expect(sa?.messages()[0].role).toBe('assistant');
693+
// Content and id must pass through unchanged.
694+
expect(sa?.messages()[0].content).toBe('leak');
695+
expect(sa?.messages()[0].id).toBe('m1');
696+
});
680697
});

libs/ag-ui/src/lib/to-agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag
251251
if (Array.isArray(raw)) {
252252
return (raw as Array<Record<string, unknown>>).map((m, i) => ({
253253
id: (m['id'] as string) ?? `${id}-${i}`,
254-
role: (m['role'] as Message['role']) ?? 'assistant',
254+
role: 'assistant' as Message['role'],
255255
content: typeof m['content'] === 'string' ? (m['content'] as string) : (m['content'] as Message['content']) ?? '',
256256
...(Array.isArray(m['toolCallIds']) ? { toolCallIds: m['toolCallIds'] as string[] } : {}),
257257
...(typeof m['reasoning'] === 'string' ? { reasoning: m['reasoning'] as string } : {}),

0 commit comments

Comments
 (0)