@assistant-ui/react-ag-ui 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-ag-ui",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "AG-UI protocol adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "ag-ui",
@@ -0,0 +1,226 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { AppendMessage } from "@assistant-ui/core";
3
+ import { HttpAgent } from "@ag-ui/client";
4
+ import { AgUiThreadController } from "./AgUiThreadController";
5
+ import { makeLogger } from "./logger";
6
+
7
+ const tick = () => new Promise((r) => setTimeout(r, 0));
8
+ const settle = async () => {
9
+ for (let i = 0; i < 8; i++) await tick();
10
+ };
11
+ const userMessage = {
12
+ role: "user",
13
+ content: [{ type: "text", text: "hello" }],
14
+ attachments: [],
15
+ metadata: { custom: {} },
16
+ createdAt: new Date(0),
17
+ parentId: null,
18
+ sourceId: null,
19
+ runConfig: {},
20
+ startRun: true,
21
+ } as unknown as AppendMessage;
22
+
23
+ type Row = { id: string; status: string | undefined };
24
+
25
+ // Follows the ThreadHistoryAdapter contract: both writes upsert by message
26
+ // id, and only append moves the stored head.
27
+ function createHistory(writes: string[]) {
28
+ const items = new Map<string, any>();
29
+ let headId: string | null = null;
30
+ const store = (item: any, moveHead: boolean) => {
31
+ if (moveHead) headId = item.message.id;
32
+ items.set(item.message.id, structuredClone(item));
33
+ };
34
+ return {
35
+ rows: () =>
36
+ new Map<string, Row>(
37
+ [...items.values()].map((item) => [
38
+ item.message.id,
39
+ { id: item.message.id, status: item.message.status?.type },
40
+ ]),
41
+ ),
42
+ load: async () => ({ headId, messages: [...items.values()] }),
43
+ append: async (item: any) => {
44
+ writes.push(`append ${item.message.id}`);
45
+ store(item, true);
46
+ },
47
+ update: async (item: any) => {
48
+ writes.push(`update ${item.message.id}`);
49
+ store(item, false);
50
+ },
51
+ };
52
+ }
53
+
54
+ async function reload(history: ReturnType<typeof createHistory>) {
55
+ const core = new AgUiThreadController({
56
+ agent: new HttpAgent({ url: "https://agent.test" }),
57
+ logger: makeLogger(),
58
+ showThinking: false,
59
+ history: history as any,
60
+ });
61
+ await core.__internal_load();
62
+ return core.getMessages().map((m: any) => `${m.id}:${m.status?.type ?? "-"}`);
63
+ }
64
+
65
+ async function run(events: (requestMessages: any[]) => object[][]) {
66
+ const writes: string[] = [];
67
+ const history = createHistory(writes);
68
+ let write!: (e: object) => void;
69
+ let close!: () => void;
70
+ let requestMessages: any[] = [];
71
+ const agent = new HttpAgent({
72
+ url: "https://agent.test",
73
+ fetch: async (_url: unknown, init: RequestInit) => {
74
+ const encoder = new TextEncoder();
75
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
76
+ const body = new ReadableStream<Uint8Array>({
77
+ start: (c) => void (controller = c),
78
+ });
79
+ requestMessages = JSON.parse(init.body as string).messages ?? [];
80
+ write = (e) =>
81
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`));
82
+ close = () => controller.close();
83
+ return new Response(body, {
84
+ headers: { "Content-Type": "text/event-stream" },
85
+ });
86
+ },
87
+ });
88
+ const core = new AgUiThreadController({
89
+ agent,
90
+ logger: makeLogger(),
91
+ showThinking: false,
92
+ history: history as any,
93
+ });
94
+ const sending = core.append(userMessage).catch(() => {});
95
+ for (let i = 0; i < 50 && !write; i++) await tick();
96
+ for (const batch of events(requestMessages)) {
97
+ for (const e of batch) write(e);
98
+ await settle();
99
+ }
100
+ close();
101
+ await sending;
102
+ await settle();
103
+ const shown = core
104
+ .getMessages()
105
+ .map((m: any) => `${m.id}:${m.status?.type ?? "-"}`);
106
+ return {
107
+ rows: history.rows(),
108
+ writes,
109
+ shown,
110
+ reloaded: await reload(history),
111
+ };
112
+ }
113
+
114
+ const start = [
115
+ { type: "RUN_STARTED", threadId: "t", runId: "r" },
116
+ { type: "TEXT_MESSAGE_START", messageId: "answer", role: "assistant" },
117
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a1" },
118
+ ];
119
+ const end = { type: "TEXT_MESSAGE_END", messageId: "answer" };
120
+ const moveSnapshot = (req: any[]) => ({
121
+ type: "MESSAGES_SNAPSHOT",
122
+ messages: [
123
+ ...req,
124
+ { id: "answer", role: "assistant", content: "a1" },
125
+ { id: "other", role: "assistant", content: "o" },
126
+ ],
127
+ });
128
+
129
+ describe("ag-ui: a snapshot's new head and the history adapter", () => {
130
+ it("stores the new head after one head-move snapshot and RUN_FINISHED", async () => {
131
+ const { rows, writes, shown, reloaded } = await run((req) => [
132
+ start,
133
+ [moveSnapshot(req)],
134
+ [end, { type: "RUN_FINISHED", threadId: "t", runId: "r" }],
135
+ ]);
136
+ expect(shown).toContain("other:complete");
137
+ expect(rows.get("other")?.status, writes.join(", ")).toBe("complete");
138
+ expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
139
+ });
140
+
141
+ it("stores the new head after one head-move snapshot and RUN_ERROR", async () => {
142
+ const { rows, writes, shown, reloaded } = await run((req) => [
143
+ start,
144
+ [moveSnapshot(req)],
145
+ [{ type: "RUN_ERROR", message: "boom" }],
146
+ ]);
147
+ // Which message takes the error depends on whether the run moved onto
148
+ // "other"; either way history holds what the thread shows.
149
+ const other = shown.find((entry) => entry.startsWith("other:"));
150
+ expect(["other:incomplete", "other:complete"]).toContain(other);
151
+ expect(`other:${rows.get("other")?.status}`, writes.join(", ")).toBe(other);
152
+ expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
153
+ });
154
+
155
+ it("stores a snapshot-only answer that a second snapshot repeats", async () => {
156
+ const snapshot = (req: any[]) => ({
157
+ type: "MESSAGES_SNAPSHOT",
158
+ messages: [...req, { id: "only", role: "assistant", content: "o" }],
159
+ });
160
+ const { rows, writes, shown, reloaded } = await run((req) => [
161
+ [{ type: "RUN_STARTED", threadId: "t", runId: "r" }, snapshot(req)],
162
+ [snapshot(req)],
163
+ [{ type: "RUN_FINISHED", threadId: "t", runId: "r" }],
164
+ ]);
165
+ expect(rows.get("only")?.status, writes.join(", ")).toBe("complete");
166
+ expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
167
+ });
168
+
169
+ it("stores an answer that arrives only in a snapshot", async () => {
170
+ const { rows, writes, shown, reloaded } = await run((req) => [
171
+ [
172
+ { type: "RUN_STARTED", threadId: "t", runId: "r" },
173
+ {
174
+ type: "MESSAGES_SNAPSHOT",
175
+ messages: [...req, { id: "only", role: "assistant", content: "o" }],
176
+ },
177
+ ],
178
+ [{ type: "RUN_FINISHED", threadId: "t", runId: "r" }],
179
+ ]);
180
+ expect(shown).toContain("only:complete");
181
+ expect(rows.get("only")?.status, writes.join(", ")).toBe("complete");
182
+ expect(writes.filter((w) => w.endsWith(" only"))).toHaveLength(1);
183
+ expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
184
+ });
185
+
186
+ it("keeps the streamed answer as the head after a snapshot adds an earlier step", async () => {
187
+ const { writes, shown, reloaded } = await run((req) => [
188
+ start,
189
+ [
190
+ {
191
+ type: "MESSAGES_SNAPSHOT",
192
+ messages: [
193
+ ...req,
194
+ { id: "step", role: "assistant", content: "s" },
195
+ { id: "answer", role: "assistant", content: "a1" },
196
+ ],
197
+ },
198
+ ],
199
+ [end, { type: "RUN_FINISHED", threadId: "t", runId: "r" }],
200
+ ]);
201
+ expect(shown.slice(1)).toEqual(["step:complete", "answer:complete"]);
202
+ expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
203
+ });
204
+
205
+ it("control: a second snapshot after the head move stores the new head", async () => {
206
+ const { rows, writes, shown, reloaded } = await run((req) => [
207
+ start,
208
+ [moveSnapshot(req)],
209
+ [moveSnapshot(req)],
210
+ [end, { type: "RUN_FINISHED", threadId: "t", runId: "r" }],
211
+ ]);
212
+ expect(rows.get("other")?.status, JSON.stringify({ writes, shown })).toBe(
213
+ "complete",
214
+ );
215
+ expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
216
+ });
217
+
218
+ it("control: the streamed answer without a snapshot is stored", async () => {
219
+ const { rows, shown, reloaded } = await run(() => [
220
+ start,
221
+ [end, { type: "RUN_FINISHED", threadId: "t", runId: "r" }],
222
+ ]);
223
+ expect(rows.get("answer")?.status).toBe("complete");
224
+ expect(reloaded).toEqual(shown);
225
+ });
226
+ });
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { AppendMessage } from "@assistant-ui/core";
3
+ import { HttpAgent } from "@ag-ui/client";
4
+ import { AgUiThreadController } from "./AgUiThreadController";
5
+ import { makeLogger } from "./logger";
6
+
7
+ const tick = () => new Promise((r) => setTimeout(r, 0));
8
+ const settle = async () => {
9
+ for (let i = 0; i < 8; i++) await tick();
10
+ };
11
+ const userMessage = {
12
+ role: "user",
13
+ content: [{ type: "text", text: "hello" }],
14
+ attachments: [],
15
+ metadata: { custom: {} },
16
+ createdAt: new Date(0),
17
+ parentId: null,
18
+ sourceId: null,
19
+ runConfig: {},
20
+ startRun: true,
21
+ } as unknown as AppendMessage;
22
+
23
+ type End = "close" | "error" | "stop" | "finish";
24
+
25
+ async function run(end: End, withSnapshot = true, history?: unknown) {
26
+ let write!: (e: object) => void;
27
+ let close!: () => void;
28
+ let requestMessages: any[] = [];
29
+ const agent = new HttpAgent({
30
+ url: "https://agent.test",
31
+ fetch: async (_url: unknown, init: RequestInit) => {
32
+ const encoder = new TextEncoder();
33
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
34
+ let closed = false;
35
+ const body = new ReadableStream<Uint8Array>({
36
+ start: (c) => void (controller = c),
37
+ });
38
+ requestMessages = JSON.parse(init.body as string).messages ?? [];
39
+ write = (e) => {
40
+ if (!closed)
41
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`));
42
+ };
43
+ close = () => {
44
+ if (closed) return;
45
+ closed = true;
46
+ controller.close();
47
+ };
48
+ init.signal?.addEventListener("abort", () => {
49
+ if (closed) return;
50
+ closed = true;
51
+ try {
52
+ controller.error(new DOMException("aborted", "AbortError"));
53
+ } catch {}
54
+ });
55
+ return new Response(body, {
56
+ headers: { "Content-Type": "text/event-stream" },
57
+ });
58
+ },
59
+ });
60
+ const core = new AgUiThreadController({
61
+ agent,
62
+ logger: makeLogger(),
63
+ showThinking: false,
64
+ ...(history ? { history: history as any } : {}),
65
+ });
66
+ const sending = core.append(userMessage).catch(() => {});
67
+ for (let i = 0; i < 50 && !write; i++) await tick();
68
+ const events: object[] = [
69
+ { type: "RUN_STARTED", threadId: "t", runId: "r" },
70
+ { type: "TEXT_MESSAGE_START", messageId: "answer", role: "assistant" },
71
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a1" },
72
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
73
+ { type: "TOOL_CALL_START", toolCallId: "task", toolCallName: "task" },
74
+ { type: "TOOL_CALL_END", toolCallId: "task" },
75
+ {
76
+ type: "SUBAGENT_STARTED",
77
+ subagentRunId: "sub",
78
+ name: "worker",
79
+ parentToolCallId: "task",
80
+ },
81
+ {
82
+ type: "TOOL_CALL_START",
83
+ toolCallId: "ncall",
84
+ toolCallName: "search",
85
+ subagentRunId: "sub",
86
+ },
87
+ { type: "TOOL_CALL_END", toolCallId: "ncall", subagentRunId: "sub" },
88
+ { type: "SUBAGENT_FINISHED", subagentRunId: "sub" },
89
+ ];
90
+ for (const e of events) write(e);
91
+ await settle();
92
+ if (withSnapshot) {
93
+ write({
94
+ type: "MESSAGES_SNAPSHOT",
95
+ messages: [
96
+ ...requestMessages,
97
+ {
98
+ id: "answer",
99
+ role: "assistant",
100
+ content: "a1",
101
+ toolCalls: [
102
+ {
103
+ id: "task",
104
+ type: "function",
105
+ function: { name: "task", arguments: "{}" },
106
+ },
107
+ ],
108
+ },
109
+ {
110
+ id: "s-call",
111
+ role: "assistant",
112
+ content: "",
113
+ subagentRunId: "sub",
114
+ toolCalls: [
115
+ {
116
+ id: "ncall",
117
+ type: "function",
118
+ function: { name: "search", arguments: "{}" },
119
+ },
120
+ ],
121
+ },
122
+ ],
123
+ });
124
+ await settle();
125
+ }
126
+ const before = nested(core);
127
+ if (end === "close") close();
128
+ else if (end === "error")
129
+ write({ type: "RUN_ERROR", message: "server failed" });
130
+ else if (end === "finish")
131
+ write({ type: "RUN_FINISHED", threadId: "t", runId: "r" });
132
+ else await core.cancel();
133
+ await settle();
134
+ close();
135
+ await sending;
136
+ await settle();
137
+ return { before, after: nested(core) };
138
+ }
139
+
140
+ function nested(core: AgUiThreadController) {
141
+ const answer = core.getMessages().find((m: any) => m.id === "answer") as any;
142
+ const call = answer?.content.find(
143
+ (p: any) => p.type === "tool-call" && p.toolCallId === "task",
144
+ );
145
+ const sub = call?.messages?.find((m: any) => m.id === "sub");
146
+ return {
147
+ answer: answer?.status?.type,
148
+ sub: sub?.status?.type,
149
+ subReason: sub?.status?.reason,
150
+ ncallOpen: sub?.content.some(
151
+ (p: any) => p.toolCallId === "ncall" && p.result === undefined,
152
+ ),
153
+ };
154
+ }
155
+
156
+ describe("ag-ui: a nested message waiting on its own call after the run ends", () => {
157
+ for (const end of ["close", "error", "stop"] as const) {
158
+ it(`settles the nested message when the run ends by ${end}`, async () => {
159
+ const { before, after } = await run(end);
160
+ // the snapshot imported the sub-agent's message waiting on ncall
161
+ expect(before.sub, JSON.stringify(before)).toBe("requires-action");
162
+ expect(after.answer, JSON.stringify(after)).toBe("incomplete");
163
+ expect(after.sub, JSON.stringify(after)).not.toBe("requires-action");
164
+ });
165
+ }
166
+
167
+ it("control: after RUN_FINISHED the nested call stays open and is not settled as an error", async () => {
168
+ const { after } = await run("finish");
169
+ expect(after.answer, JSON.stringify(after)).toBe("requires-action");
170
+ expect(after.ncallOpen, JSON.stringify(after)).toBe(true);
171
+ expect(after.subReason, JSON.stringify(after)).not.toBe("error");
172
+ });
173
+
174
+ it("control: without the snapshot the nested message is the stream's (complete) and settles", async () => {
175
+ const { before, after } = await run("close", false);
176
+ expect(before.sub, JSON.stringify(before)).toBe("complete");
177
+ expect(after.sub, JSON.stringify(after)).not.toBe("requires-action");
178
+ });
179
+
180
+ it("appends the answer again when its first append fails under a queued update", async () => {
181
+ const writes: string[] = [];
182
+ let headId: string | null = null;
183
+ const rows = new Set<string>();
184
+ let failed = false;
185
+ const history = {
186
+ load: async () => ({ headId: null, messages: [] }),
187
+ append: async (item: any) => {
188
+ const id = item.message.id;
189
+ writes.push(`append ${id}`);
190
+ if (id === "answer" && !failed) {
191
+ failed = true;
192
+ await tick();
193
+ throw new Error("write failed");
194
+ }
195
+ rows.add(id);
196
+ headId = id;
197
+ },
198
+ // Follows the ThreadHistoryAdapter contract: update does not move the head.
199
+ update: async (item: any) => {
200
+ writes.push(`update ${item.message.id}`);
201
+ rows.add(item.message.id);
202
+ },
203
+ };
204
+ const { after } = await run("error", true, history);
205
+ expect(after.sub, JSON.stringify(after)).not.toBe("requires-action");
206
+ expect(writes, writes.join(", ")).not.toContain("update answer");
207
+ expect(rows.has("answer"), writes.join(", ")).toBe(true);
208
+ expect(headId, writes.join(", ")).toBe("answer");
209
+ });
210
+ });