Skip to content

Commit 3f588c9

Browse files
bloveclaude
andcommitted
feat(ag-ui): bridge state.citations into Message.citations
Create bridgeCitationsState() normalizer and wire it into reduceEvent() after STATE_SNAPSHOT and STATE_DELTA apply their patches, so that state.citations[messageId] arrays flow through as Message.citations on every affected assistant message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d3c69f3 commit 3f588c9

3 files changed

Lines changed: 99 additions & 1 deletion

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// libs/ag-ui/src/lib/bridge-citations-state.spec.ts
2+
// SPDX-License-Identifier: MIT
3+
import { bridgeCitationsState } from './bridge-citations-state';
4+
import type { Message } from '@ngaf/chat';
5+
6+
describe('bridgeCitationsState', () => {
7+
const baseMsg = (id: string): Message => ({ id, role: 'assistant', content: 'x' });
8+
9+
it('returns messages unchanged when state has no citations', () => {
10+
const msgs = [baseMsg('m1'), baseMsg('m2')];
11+
const result = bridgeCitationsState({ state: {} }, msgs);
12+
expect(result).toEqual(msgs);
13+
});
14+
15+
it('merges citations into matching messages by id', () => {
16+
const result = bridgeCitationsState(
17+
{ state: { citations: { m1: [{ id: 'a', title: 'A', url: 'https://a' }] } } },
18+
[baseMsg('m1'), baseMsg('m2')],
19+
);
20+
expect(result[0].citations).toEqual([{ id: 'a', index: 1, title: 'A', url: 'https://a' }]);
21+
expect(result[1].citations).toBeUndefined();
22+
});
23+
24+
it('idempotent — same input produces same output', () => {
25+
const state = { state: { citations: { m1: [{ id: 'a', title: 'A' }] } } };
26+
const msgs = [baseMsg('m1')];
27+
const a = bridgeCitationsState(state, msgs);
28+
const b = bridgeCitationsState(state, a);
29+
expect(b[0].citations).toEqual(a[0].citations);
30+
});
31+
32+
it('coerces key spellings (href/source, name, excerpt)', () => {
33+
const result = bridgeCitationsState(
34+
{ state: { citations: { m1: [{ name: 'N', href: 'https://h', excerpt: 'E' }] } } },
35+
[baseMsg('m1')],
36+
);
37+
expect(result[0].citations).toEqual([
38+
{ id: 'c1', index: 1, title: 'N', url: 'https://h', snippet: 'E' },
39+
]);
40+
});
41+
42+
it('handles string entries', () => {
43+
const result = bridgeCitationsState(
44+
{ state: { citations: { m1: ['https://x'] } } },
45+
[baseMsg('m1')],
46+
);
47+
expect(result[0].citations).toEqual([{ id: 'c1', index: 1, url: 'https://x' }]);
48+
});
49+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// libs/ag-ui/src/lib/bridge-citations-state.ts
2+
// SPDX-License-Identifier: MIT
3+
import type { Citation, Message } from '@ngaf/chat';
4+
5+
interface ThreadStateLike {
6+
state?: Record<string, unknown>;
7+
}
8+
9+
export function bridgeCitationsState(thread: ThreadStateLike, messages: Message[]): Message[] {
10+
const citationsByMsg = (thread.state as { citations?: unknown })?.citations;
11+
if (!citationsByMsg || typeof citationsByMsg !== 'object') return messages;
12+
const map = citationsByMsg as Record<string, unknown>;
13+
return messages.map(msg => {
14+
const raw = map[msg.id];
15+
if (!Array.isArray(raw) || raw.length === 0) return msg;
16+
return { ...msg, citations: raw.map((entry, i) => normalizeCitation(entry, i + 1)) };
17+
});
18+
}
19+
20+
function normalizeCitation(entry: unknown, fallbackIndex: number): Citation {
21+
if (typeof entry === 'string') {
22+
return { id: `c${fallbackIndex}`, index: fallbackIndex, url: entry };
23+
}
24+
const e = (entry ?? {}) as Record<string, unknown>;
25+
const str = (key: string): string | undefined =>
26+
typeof e[key] === 'string' ? (e[key] as string) : undefined;
27+
const firstStr = (...keys: string[]): string | undefined => {
28+
for (const k of keys) {
29+
const v = str(k);
30+
if (v !== undefined) return v;
31+
}
32+
return undefined;
33+
};
34+
return {
35+
id: str('id') ?? str('refId') ?? `c${fallbackIndex}`,
36+
index: typeof e['index'] === 'number' ? (e['index'] as number) : fallbackIndex,
37+
title: firstStr('title', 'name'),
38+
url: firstStr('url', 'href', 'source'),
39+
snippet: firstStr('snippet', 'content', 'excerpt'),
40+
extra:
41+
typeof e['extra'] === 'object' && e['extra'] !== null
42+
? (e['extra'] as Record<string, unknown>)
43+
: undefined,
44+
};
45+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
} from '@ngaf/chat';
1111
import type { BaseEvent } from '@ag-ui/client';
1212
import { applyPatch, type Operation } from 'fast-json-patch';
13+
import { bridgeCitationsState } from './bridge-citations-state';
1314

1415
export interface ReducerStore {
1516
messages: WritableSignal<Message[]>;
@@ -157,13 +158,16 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void {
157158
}
158159
case 'STATE_SNAPSHOT': {
159160
const e = event as unknown as { snapshot: Record<string, unknown> };
160-
store.state.set(e.snapshot ?? {});
161+
const snapshot = e.snapshot ?? {};
162+
store.state.set(snapshot);
163+
store.messages.update(msgs => bridgeCitationsState({ state: snapshot }, msgs));
161164
return;
162165
}
163166
case 'STATE_DELTA': {
164167
const e = event as unknown as { delta: Operation[] };
165168
const next = applyPatch(deepClone(store.state()), e.delta).newDocument;
166169
store.state.set(next);
170+
store.messages.update(msgs => bridgeCitationsState({ state: next }, msgs));
167171
return;
168172
}
169173
case 'MESSAGES_SNAPSHOT': {

0 commit comments

Comments
 (0)