@assistant-ui/react-a2a 0.2.31 → 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.
@@ -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 = vi.fn(
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
  });
@@ -1,6 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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
- headersRef.current = options.headers;
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 core = useMemo(
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
  };