@assistant-ui/react-a2a 0.2.30 → 0.2.32
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/dist/A2AClient.d.ts.map +1 -1
- package/dist/A2AClient.js +200 -27
- package/dist/A2AClient.js.map +1 -1
- package/dist/A2AThreadRuntimeCore.d.ts +7 -12
- package/dist/A2AThreadRuntimeCore.d.ts.map +1 -1
- package/dist/A2AThreadRuntimeCore.js +65 -147
- package/dist/A2AThreadRuntimeCore.js.map +1 -1
- package/dist/conversions.d.ts +6 -2
- package/dist/conversions.d.ts.map +1 -1
- package/dist/conversions.js +28 -9
- package/dist/conversions.js.map +1 -1
- package/dist/useA2ARuntime.d.ts.map +1 -1
- package/dist/useA2ARuntime.js +17 -7
- package/dist/useA2ARuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/A2AClient.test.ts +714 -0
- package/src/A2AClient.ts +307 -43
- package/src/A2AThreadRuntimeCore.test.ts +248 -0
- package/src/A2AThreadRuntimeCore.ts +104 -212
- package/src/conversions.test.ts +49 -0
- package/src/conversions.ts +56 -7
- package/src/useA2ARuntime.test.tsx +150 -26
- package/src/useA2ARuntime.ts +33 -12
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { startTransition, Suspense, type PropsWithChildren } from "react";
|
|
4
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
6
|
import type { A2AClient } from "./A2AClient";
|
|
6
7
|
import type { A2AStreamEvent } from "./types";
|
|
@@ -57,11 +58,100 @@ const createMockClient = (waitForAbort = false) => {
|
|
|
57
58
|
};
|
|
58
59
|
};
|
|
59
60
|
|
|
61
|
+
const createFetchMock = () =>
|
|
62
|
+
vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
|
|
63
|
+
const url =
|
|
64
|
+
typeof input === "string"
|
|
65
|
+
? input
|
|
66
|
+
: input instanceof URL
|
|
67
|
+
? input.href
|
|
68
|
+
: input.url;
|
|
69
|
+
if (url.endsWith("/.well-known/agent-card.json")) {
|
|
70
|
+
return new Response(
|
|
71
|
+
JSON.stringify({ capabilities: { streaming: true } }),
|
|
72
|
+
{
|
|
73
|
+
status: 200,
|
|
74
|
+
headers: { "Content-Type": "application/json" },
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return new Response(
|
|
79
|
+
'data: {"message":{"message_id":"response","role":"ROLE_AGENT","parts":[{"text":"Done"}]}}\n\n',
|
|
80
|
+
{
|
|
81
|
+
status: 200,
|
|
82
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
60
87
|
afterEach(() => {
|
|
61
88
|
vi.unstubAllGlobals();
|
|
62
89
|
});
|
|
63
90
|
|
|
64
91
|
describe("useA2ARuntime", () => {
|
|
92
|
+
const createHistory = () => ({
|
|
93
|
+
load: vi.fn().mockResolvedValue({
|
|
94
|
+
headId: "restored",
|
|
95
|
+
messages: [
|
|
96
|
+
{
|
|
97
|
+
parentId: null,
|
|
98
|
+
message: {
|
|
99
|
+
id: "restored",
|
|
100
|
+
role: "user" as const,
|
|
101
|
+
content: [{ type: "text" as const, text: "hello" }],
|
|
102
|
+
createdAt: new Date(0),
|
|
103
|
+
metadata: { custom: {} },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("loads a history adapter that arrives on a later render", async () => {
|
|
112
|
+
const { client } = createMockClient();
|
|
113
|
+
const history = createHistory();
|
|
114
|
+
const { result, rerender } = renderHook(
|
|
115
|
+
({ history }: { history?: ReturnType<typeof createHistory> }) =>
|
|
116
|
+
useA2ARuntime({ client, adapters: history ? { history } : {} }),
|
|
117
|
+
{ initialProps: {} },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await waitFor(() =>
|
|
121
|
+
expect(result.current.thread.getState().isLoading).toBe(false),
|
|
122
|
+
);
|
|
123
|
+
expect(history.load).not.toHaveBeenCalled();
|
|
124
|
+
|
|
125
|
+
rerender({ history });
|
|
126
|
+
|
|
127
|
+
await waitFor(() =>
|
|
128
|
+
expect(
|
|
129
|
+
result.current.thread.getState().messages.map((m) => m.id),
|
|
130
|
+
).toEqual(["restored"]),
|
|
131
|
+
);
|
|
132
|
+
expect(history.load).toHaveBeenCalledOnce();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("loads history through a swapped client's core", async () => {
|
|
136
|
+
const first = createMockClient();
|
|
137
|
+
const second = createMockClient();
|
|
138
|
+
const history = createHistory();
|
|
139
|
+
const { result, rerender } = renderHook(
|
|
140
|
+
({ client }) => useA2ARuntime({ client, adapters: { history } }),
|
|
141
|
+
{ initialProps: { client: first.client } },
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await waitFor(() => expect(history.load).toHaveBeenCalledOnce());
|
|
145
|
+
|
|
146
|
+
rerender({ client: second.client });
|
|
147
|
+
|
|
148
|
+
await waitFor(() => expect(second.getAgentCard).toHaveBeenCalledOnce());
|
|
149
|
+
await waitFor(() => expect(history.load).toHaveBeenCalledTimes(2));
|
|
150
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
151
|
+
"restored",
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
65
155
|
it("switches provided clients and aborts the previous client run", async () => {
|
|
66
156
|
const first = createMockClient(true);
|
|
67
157
|
const second = createMockClient();
|
|
@@ -97,32 +187,7 @@ describe("useA2ARuntime", () => {
|
|
|
97
187
|
});
|
|
98
188
|
|
|
99
189
|
it("uses current headers without recreating the managed client", async () => {
|
|
100
|
-
const fetchMock =
|
|
101
|
-
async (input: string | URL | Request, _init?: RequestInit) => {
|
|
102
|
-
const url =
|
|
103
|
-
typeof input === "string"
|
|
104
|
-
? input
|
|
105
|
-
: input instanceof URL
|
|
106
|
-
? input.href
|
|
107
|
-
: input.url;
|
|
108
|
-
if (url.endsWith("/.well-known/agent-card.json")) {
|
|
109
|
-
return new Response(
|
|
110
|
-
JSON.stringify({ capabilities: { streaming: true } }),
|
|
111
|
-
{
|
|
112
|
-
status: 200,
|
|
113
|
-
headers: { "Content-Type": "application/json" },
|
|
114
|
-
},
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
return new Response(
|
|
118
|
-
'data: {"message":{"message_id":"response","role":"ROLE_AGENT","parts":[{"text":"Done"}]}}\n\n',
|
|
119
|
-
{
|
|
120
|
-
status: 200,
|
|
121
|
-
headers: { "Content-Type": "text/event-stream" },
|
|
122
|
-
},
|
|
123
|
-
);
|
|
124
|
-
},
|
|
125
|
-
);
|
|
190
|
+
const fetchMock = createFetchMock();
|
|
126
191
|
vi.stubGlobal("fetch", fetchMock);
|
|
127
192
|
|
|
128
193
|
const { result, rerender } = renderHook(
|
|
@@ -153,4 +218,63 @@ describe("useA2ARuntime", () => {
|
|
|
153
218
|
Authorization: "Bearer second",
|
|
154
219
|
});
|
|
155
220
|
});
|
|
221
|
+
|
|
222
|
+
it("keeps managed headers scoped to committed renders", async () => {
|
|
223
|
+
const fetchMock = createFetchMock();
|
|
224
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
225
|
+
|
|
226
|
+
const interruptedRender = vi.fn();
|
|
227
|
+
const renderedToken = vi.fn();
|
|
228
|
+
const pending = new Promise<never>(() => {});
|
|
229
|
+
let blocked = false;
|
|
230
|
+
const Blocker = ({ blocked }: { blocked: boolean }) => {
|
|
231
|
+
if (blocked) {
|
|
232
|
+
interruptedRender();
|
|
233
|
+
throw pending;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
};
|
|
237
|
+
const Wrapper = ({ children }: PropsWithChildren) => (
|
|
238
|
+
<Suspense fallback={null}>
|
|
239
|
+
{children}
|
|
240
|
+
<Blocker blocked={blocked} />
|
|
241
|
+
</Suspense>
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const { result, rerender } = renderHook(
|
|
245
|
+
({ token }) => {
|
|
246
|
+
renderedToken(token);
|
|
247
|
+
return useA2ARuntime({
|
|
248
|
+
baseUrl: "https://agent.test",
|
|
249
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
initialProps: { token: "workspace-a" },
|
|
254
|
+
wrapper: Wrapper,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
|
258
|
+
|
|
259
|
+
act(() => {
|
|
260
|
+
blocked = true;
|
|
261
|
+
startTransition(() => {
|
|
262
|
+
rerender({ token: "workspace-b" });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
expect(interruptedRender).toHaveBeenCalled();
|
|
266
|
+
expect(renderedToken).toHaveBeenCalledWith("workspace-b");
|
|
267
|
+
|
|
268
|
+
act(() => {
|
|
269
|
+
result.current.thread.append({
|
|
270
|
+
role: "user",
|
|
271
|
+
content: [{ type: "text", text: "hello" }],
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
|
276
|
+
expect(fetchMock.mock.calls[1]![1]?.headers).toMatchObject({
|
|
277
|
+
Authorization: "Bearer workspace-a",
|
|
278
|
+
});
|
|
279
|
+
});
|
|
156
280
|
});
|
package/src/useA2ARuntime.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useInsertionEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
4
11
|
import {
|
|
5
12
|
useExternalStoreRuntime,
|
|
6
13
|
useExternalStoreSharedOptions,
|
|
@@ -42,7 +49,9 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
42
49
|
const threadListAdapter = options.adapters?.threadList;
|
|
43
50
|
|
|
44
51
|
const headersRef = useRef(options.headers);
|
|
45
|
-
|
|
52
|
+
useInsertionEffect(() => {
|
|
53
|
+
headersRef.current = options.headers;
|
|
54
|
+
});
|
|
46
55
|
const resolveHeaders = useCallback(() => {
|
|
47
56
|
const headers = headersRef.current;
|
|
48
57
|
return typeof headers === "function" ? headers() : (headers ?? {});
|
|
@@ -72,16 +81,7 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
72
81
|
});
|
|
73
82
|
}, [managedClientOptionsKey, options.client, resolveHeaders]);
|
|
74
83
|
|
|
75
|
-
const
|
|
76
|
-
() =>
|
|
77
|
-
new A2AThreadRuntimeCore({
|
|
78
|
-
client,
|
|
79
|
-
notifyUpdate,
|
|
80
|
-
}),
|
|
81
|
-
[client, notifyUpdate],
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
core.updateOptions({
|
|
84
|
+
const coreOptions = {
|
|
85
85
|
client,
|
|
86
86
|
contextId: options.contextId,
|
|
87
87
|
configuration: options.configuration,
|
|
@@ -91,6 +91,22 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
91
91
|
onArtifactComplete: options.onArtifactComplete,
|
|
92
92
|
}),
|
|
93
93
|
...(historyAdapter && { history: historyAdapter }),
|
|
94
|
+
};
|
|
95
|
+
const coreOptionsRef = useRef(coreOptions);
|
|
96
|
+
coreOptionsRef.current = coreOptions;
|
|
97
|
+
|
|
98
|
+
const core = useMemo(
|
|
99
|
+
() =>
|
|
100
|
+
new A2AThreadRuntimeCore({
|
|
101
|
+
...coreOptionsRef.current,
|
|
102
|
+
client,
|
|
103
|
+
notifyUpdate,
|
|
104
|
+
}),
|
|
105
|
+
[client, notifyUpdate],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
core.updateOptions(coreOptions);
|
|
94
110
|
});
|
|
95
111
|
|
|
96
112
|
// Thread list
|
|
@@ -104,13 +120,18 @@ export function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {
|
|
|
104
120
|
onSwitchToNewThread: onSwitchToNewThread
|
|
105
121
|
? async () => {
|
|
106
122
|
await onSwitchToNewThread();
|
|
123
|
+
// Apply first so the abort inside resetContext finds an already
|
|
124
|
+
// cleared repository and cannot persist the old thread's partial
|
|
125
|
+
// assistant message.
|
|
107
126
|
core.applyExternalMessages([]);
|
|
127
|
+
core.resetContext();
|
|
108
128
|
}
|
|
109
129
|
: undefined,
|
|
110
130
|
onSwitchToThread: onSwitchToThread
|
|
111
131
|
? async (threadId: string) => {
|
|
112
132
|
const result = await onSwitchToThread(threadId);
|
|
113
133
|
core.applyExternalMessages(result.messages);
|
|
134
|
+
core.resetContext();
|
|
114
135
|
}
|
|
115
136
|
: undefined,
|
|
116
137
|
};
|