@assistant-ui/react-ag-ui 0.0.58 → 0.0.59

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.
@@ -0,0 +1,199 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { useState } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { act, cleanup, renderHook } from "@testing-library/react";
6
+ import type { HttpAgent } from "@ag-ui/client";
7
+ import type { ThreadMessage } from "@assistant-ui/core";
8
+ import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
9
+ import type { UseAgUiThreadListAdapter } from "./runtime/types";
10
+ import { useAgUiRuntime } from "./useAgUiRuntime";
11
+
12
+ type ThreadLoad = Awaited<
13
+ ReturnType<NonNullable<UseAgUiThreadListAdapter["onSwitchToThread"]>>
14
+ >;
15
+
16
+ function deferred<T>() {
17
+ let resolve!: (value: T) => void;
18
+ const promise = new Promise<T>((res) => {
19
+ resolve = res;
20
+ });
21
+ return { promise, resolve };
22
+ }
23
+
24
+ function message(id: string): ThreadMessage {
25
+ return {
26
+ id,
27
+ role: "user",
28
+ content: [{ type: "text", text: id }],
29
+ attachments: [],
30
+ createdAt: new Date(0),
31
+ metadata: { custom: {} },
32
+ };
33
+ }
34
+
35
+ function renderRuntime(
36
+ load: (id: string) => Promise<ThreadLoad>,
37
+ create: () => Promise<void> = async () => {},
38
+ ) {
39
+ const agent = {
40
+ runAgent: vi.fn(),
41
+ abortRun: vi.fn(),
42
+ } as unknown as HttpAgent;
43
+ return renderHook(() => {
44
+ const [threadId, setThreadId] = useState("initial");
45
+ return useAgUiRuntime({
46
+ agent,
47
+ adapters: {
48
+ threadList: {
49
+ threadId,
50
+ onSwitchToThread: (id) => {
51
+ setThreadId(id);
52
+ return load(id);
53
+ },
54
+ onSwitchToNewThread: () => {
55
+ setThreadId("thread-new");
56
+ return create();
57
+ },
58
+ },
59
+ },
60
+ });
61
+ });
62
+ }
63
+
64
+ afterEach(() => {
65
+ cleanup();
66
+ vi.restoreAllMocks();
67
+ });
68
+
69
+ describe("useAgUiRuntime thread switching", () => {
70
+ it.each([undefined, { owner: "thread-a" }])(
71
+ "ignores an older load and its resume request with state %j",
72
+ async (state) => {
73
+ const resume = vi
74
+ .spyOn(AgUiThreadRuntimeCore.prototype, "resumeInFlightRun")
75
+ .mockResolvedValue();
76
+ const first = deferred<ThreadLoad>();
77
+ const second = deferred<ThreadLoad>();
78
+ const { result } = renderRuntime((id) =>
79
+ id === "thread-a" ? first.promise : second.promise,
80
+ );
81
+
82
+ let switchA!: Promise<void>;
83
+ let switchB!: Promise<void>;
84
+ act(() => {
85
+ switchA = result.current.threads.switchToThread("thread-a");
86
+ switchB = result.current.threads.switchToThread("thread-b");
87
+ });
88
+ await act(async () => {
89
+ second.resolve({
90
+ messages: [message("thread-b")],
91
+ state: { owner: "thread-b" },
92
+ });
93
+ await switchB;
94
+ });
95
+ await act(async () => {
96
+ first.resolve({
97
+ messages: [message("thread-a")],
98
+ ...(state !== undefined && { state }),
99
+ unstable_resume: true,
100
+ });
101
+ await switchA;
102
+ });
103
+
104
+ expect(result.current.threads.getState().mainThreadId).toBe("thread-b");
105
+ expect(
106
+ result.current.thread.getState().messages.map((m) => m.id),
107
+ ).toEqual(["thread-b"]);
108
+ expect(result.current.thread.getState().state).toEqual({
109
+ owner: "thread-b",
110
+ });
111
+ expect(resume).not.toHaveBeenCalled();
112
+ },
113
+ );
114
+
115
+ it("ignores a load superseded by creating a new thread", async () => {
116
+ const resume = vi
117
+ .spyOn(AgUiThreadRuntimeCore.prototype, "resumeInFlightRun")
118
+ .mockResolvedValue();
119
+ const load = deferred<ThreadLoad>();
120
+ const { result } = renderRuntime(() => load.promise);
121
+
122
+ let switchA!: Promise<void>;
123
+ act(() => {
124
+ switchA = result.current.threads.switchToThread("thread-a");
125
+ });
126
+ await act(async () => {
127
+ await result.current.threads.switchToNewThread();
128
+ });
129
+ const newThreadState = result.current.thread.getState().state;
130
+ await act(async () => {
131
+ load.resolve({
132
+ messages: [message("thread-a")],
133
+ state: { owner: "thread-a" },
134
+ unstable_resume: true,
135
+ });
136
+ await switchA;
137
+ });
138
+
139
+ expect(result.current.threads.getState().mainThreadId).toBe("thread-new");
140
+ expect(result.current.thread.getState().messages).toEqual([]);
141
+ expect(result.current.thread.getState().state).toEqual(newThreadState);
142
+ expect(resume).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it("does not clear a newer thread when an older creation finishes", async () => {
146
+ const creation = deferred<void>();
147
+ const { result } = renderRuntime(
148
+ async () => ({
149
+ messages: [message("thread-b")],
150
+ state: { owner: "thread-b" },
151
+ }),
152
+ () => creation.promise,
153
+ );
154
+
155
+ let switchNew!: Promise<void>;
156
+ act(() => {
157
+ switchNew = result.current.threads.switchToNewThread();
158
+ });
159
+ await act(async () => {
160
+ await result.current.threads.switchToThread("thread-b");
161
+ });
162
+ await act(async () => {
163
+ creation.resolve();
164
+ await switchNew;
165
+ });
166
+
167
+ expect(result.current.threads.getState().mainThreadId).toBe("thread-b");
168
+ expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
169
+ "thread-b",
170
+ ]);
171
+ expect(result.current.thread.getState().state).toEqual({
172
+ owner: "thread-b",
173
+ });
174
+ });
175
+
176
+ it("applies the current load and resumes it", async () => {
177
+ const resume = vi
178
+ .spyOn(AgUiThreadRuntimeCore.prototype, "resumeInFlightRun")
179
+ .mockResolvedValue();
180
+ const messages = [message("thread-a")];
181
+ const { result } = renderRuntime(async () => ({
182
+ messages,
183
+ state: { owner: "thread-a" },
184
+ unstable_resume: true,
185
+ }));
186
+
187
+ await act(async () => {
188
+ await result.current.threads.switchToThread("thread-a");
189
+ });
190
+
191
+ expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
192
+ "thread-a",
193
+ ]);
194
+ expect(result.current.thread.getState().state).toEqual({
195
+ owner: "thread-a",
196
+ });
197
+ expect(resume).toHaveBeenCalledExactlyOnceWith(messages);
198
+ });
199
+ });
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  useCallback,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useMemo,
7
8
  useRef,
8
9
  useState,
@@ -62,6 +63,7 @@ export function useAgUiRuntime(
62
63
  const [_version, setVersion] = useState(0);
63
64
  const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);
64
65
  const coreRef = useRef<AgUiThreadRuntimeCore | null>(null);
66
+ const threadSwitchGenerationRef = useRef(0);
65
67
  const runtimeAdapters = useRuntimeAdapters();
66
68
 
67
69
  const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;
@@ -72,6 +74,7 @@ export function useAgUiRuntime(
72
74
  agent: options.agent,
73
75
  logger,
74
76
  showThinking: options.showThinking ?? true,
77
+ resumeTranscript: options.resumeTranscript,
75
78
  autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
76
79
  ...(options.onError && { onError: options.onError }),
77
80
  ...(options.onCancel && { onCancel: options.onCancel }),
@@ -86,6 +89,7 @@ export function useAgUiRuntime(
86
89
  agent: options.agent,
87
90
  logger,
88
91
  showThinking: options.showThinking ?? true,
92
+ resumeTranscript: options.resumeTranscript,
89
93
  autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
90
94
  ...(options.onError && { onError: options.onError }),
91
95
  ...(options.onCancel && { onCancel: options.onCancel }),
@@ -136,13 +140,16 @@ export function useAgUiRuntime(
136
140
  });
137
141
  },
138
142
  });
139
- } else if (!options.unstable_enableMessageQueue && queueRef.current) {
140
- queueRef.current.clear();
141
- queueRef.current = null;
142
143
  }
143
144
  const queueController = options.unstable_enableMessageQueue
144
145
  ? queueRef.current
145
146
  : null;
147
+ useLayoutEffect(() => {
148
+ if (options.unstable_enableMessageQueue || !queueRef.current) return;
149
+ const controller = queueRef.current;
150
+ queueRef.current = null;
151
+ controller.clear();
152
+ }, [options.unstable_enableMessageQueue]);
146
153
 
147
154
  // Feeds the store memo below: the runtime core skips an adapter whose
148
155
  // identity is unchanged, so queue items have to move the store reference or
@@ -182,17 +189,21 @@ export function useAgUiRuntime(
182
189
  ...rest,
183
190
  onSwitchToNewThread: onSwitchToNewThread
184
191
  ? async () => {
192
+ const generation = ++threadSwitchGenerationRef.current;
185
193
  await onSwitchToNewThread();
194
+ if (generation !== threadSwitchGenerationRef.current) return;
186
195
  core.applyExternalMessages([]);
187
196
  core.resetState();
188
197
  }
189
198
  : undefined,
190
199
  onSwitchToThread: onSwitchToThread
191
200
  ? async (threadId: string) => {
201
+ const generation = ++threadSwitchGenerationRef.current;
192
202
  // Clear before the thread id flips, or the old messages leak
193
203
  // into the new thread as a sibling branch.
194
204
  core.applyExternalMessages([]);
195
205
  const result = await onSwitchToThread(threadId);
206
+ if (generation !== threadSwitchGenerationRef.current) return;
196
207
  core.applyExternalMessages(result.messages);
197
208
  if (result.state !== undefined) {
198
209
  core.loadExternalState(result.state);