@assistant-ui/react 0.15.12 → 0.15.14

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.
Files changed (32) hide show
  1. package/dist/augmentations.d.ts +3 -25
  2. package/dist/augmentations.d.ts.map +1 -1
  3. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  4. package/dist/client/InMemoryThreadList.js +41 -32
  5. package/dist/client/InMemoryThreadList.js.map +1 -1
  6. package/dist/index.d.ts +3 -5
  7. package/dist/index.js +3 -5
  8. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js +48 -24
  9. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js.map +1 -1
  10. package/package.json +5 -5
  11. package/src/augmentations.ts +4 -2
  12. package/src/client/InMemoryThreadList.ts +10 -4
  13. package/src/index.ts +3 -2
  14. package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.test.ts +26 -1
  15. package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.ts +57 -31
  16. package/src/tests/augmentations.test.ts +39 -0
  17. package/src/tests/in-memory-thread-list.test.tsx +87 -3
  18. package/dist/client/ExternalThread.d.ts +0 -48
  19. package/dist/client/ExternalThread.d.ts.map +0 -1
  20. package/dist/client/ExternalThread.js +0 -1348
  21. package/dist/client/ExternalThread.js.map +0 -1
  22. package/dist/client/SingleThreadList.d.ts +0 -9
  23. package/dist/client/SingleThreadList.d.ts.map +0 -1
  24. package/dist/client/SingleThreadList.js +0 -171
  25. package/dist/client/SingleThreadList.js.map +0 -1
  26. package/src/client/ExternalThread.ts +0 -1147
  27. package/src/client/SingleThreadList.ts +0 -118
  28. package/src/tests/external-thread-attachments.test.tsx +0 -370
  29. package/src/tests/external-thread-branches.test.tsx +0 -160
  30. package/src/tests/external-thread-feedback.test.tsx +0 -204
  31. package/src/tests/external-thread-parity.test.tsx +0 -494
  32. package/src/tests/external-thread-speech.test.tsx +0 -328
@@ -7,8 +7,9 @@ import {
7
7
  attachTransformScopes,
8
8
  useClientResource,
9
9
  } from "@assistant-ui/store";
10
+ import { generateId } from "@assistant-ui/core";
10
11
 
11
- import { ModelContext, Suggestions } from "@assistant-ui/core/store";
12
+ import { ModelContext } from "@assistant-ui/core/store";
12
13
  import { Tools, DataRenderers } from "@assistant-ui/core/react";
13
14
 
14
15
  const RESOLVED_PROMISE = Promise.resolve();
@@ -137,7 +138,7 @@ const useInMemoryThreadList = (
137
138
  };
138
139
 
139
140
  const handleSwitchToNewThread = () => {
140
- const newId = `thread-${Date.now()}`;
141
+ const newId = `thread-${generateId()}`;
141
142
  setThreads((prev) => [
142
143
  ...prev,
143
144
  { id: newId, title: "New Thread", status: "regular" },
@@ -190,7 +191,8 @@ const useInMemoryThreadList = (
190
191
  switchToNewThread: handleSwitchToNewThread,
191
192
  getLoadThreadsPromise: () => RESOLVED_PROMISE,
192
193
  reload: () => RESOLVED_PROMISE,
193
- reloadMainThread: () => RESOLVED_PROMISE,
194
+ reloadMainThread: () =>
195
+ mainThreadClient.methods.unstable_refetchThread?.() ?? RESOLVED_PROMISE,
194
196
  loadMore: () => RESOLVED_PROMISE,
195
197
  item: (selector) => {
196
198
  if (selector === "main") {
@@ -236,6 +238,10 @@ attachTransformScopes(useInMemoryThreadList, (scopes, parent) => {
236
238
  scopes.dataRenderers = DataRenderers();
237
239
  }
238
240
  if (!scopes.suggestions && parent.suggestions.source === null) {
239
- scopes.suggestions = Suggestions();
241
+ scopes.suggestions = Derived({
242
+ source: "thread",
243
+ query: {},
244
+ get: (aui) => aui.thread.suggestions(),
245
+ });
240
246
  }
241
247
  });
package/src/index.ts CHANGED
@@ -139,6 +139,7 @@ export type {
139
139
  ExternalStoreThreadData,
140
140
  ExternalStoreBranchChange,
141
141
  } from "@assistant-ui/core";
142
+ export { MessageNotSentError, isMessageNotSentError } from "@assistant-ui/core";
142
143
  export {
143
144
  createMessageQueue,
144
145
  type MessageQueueDriver,
@@ -425,12 +426,12 @@ export {
425
426
  ExternalThread,
426
427
  type ExternalThreadProps,
427
428
  type ExternalThreadMessage,
428
- } from "./client/ExternalThread";
429
+ } from "@assistant-ui/core/store";
429
430
  export {
430
431
  InMemoryThreadList,
431
432
  type InMemoryThreadListProps,
432
433
  } from "./client/InMemoryThreadList";
433
- export { SingleThreadList } from "./client/SingleThreadList";
434
+ export { SingleThreadList } from "@assistant-ui/core/store";
434
435
 
435
436
  export * as INTERNAL from "./internal";
436
437
 
@@ -272,8 +272,11 @@ describe("createReplayBoundaryStream", () => {
272
272
  it("clears replaying when the stream ends before the boundary", async () => {
273
273
  const { waitForRender, releaseNext } = createRenderWait();
274
274
  const setReplaying = vi.fn();
275
+ const body = createBody(["hi"]);
275
276
  const streamPromise = createReplayBoundaryStream(
276
- createResponse(["hi"], 10),
277
+ new Response(body, {
278
+ headers: { [REPLAY_CONTENT_LENGTH_HEADER]: "10" },
279
+ }),
277
280
  {
278
281
  setReplaying,
279
282
  waitForRender,
@@ -288,6 +291,7 @@ describe("createReplayBoundaryStream", () => {
288
291
 
289
292
  await expect(text).resolves.toBe("hi");
290
293
  expect(setReplaying).toHaveBeenLastCalledWith(false);
294
+ expect(body.locked).toBe(false);
291
295
  });
292
296
 
293
297
  it("clears replaying when the gated stream is cancelled", async () => {
@@ -313,6 +317,27 @@ describe("createReplayBoundaryStream", () => {
313
317
 
314
318
  expect(setReplaying).toHaveBeenLastCalledWith(false);
315
319
  expect(cancelled).toBe(true);
320
+ expect(body.locked).toBe(false);
321
+ });
322
+
323
+ it("releases the response body reader after a read failure", async () => {
324
+ const { waitForRender, releaseNext } = createRenderWait();
325
+ const error = new Error("stream failed");
326
+ const body = new ReadableStream<Uint8Array>({
327
+ start(controller) {
328
+ controller.error(error);
329
+ },
330
+ });
331
+ const streamPromise = createReplayBoundaryStream(
332
+ new Response(body, { headers: { [REPLAY_CONTENT_LENGTH_HEADER]: "10" } }),
333
+ { setReplaying: vi.fn(), waitForRender },
334
+ );
335
+
336
+ await releaseNext();
337
+ const stream = await streamPromise;
338
+
339
+ await expect(stream.getReader().read()).rejects.toBe(error);
340
+ expect(body.locked).toBe(false);
316
341
  });
317
342
 
318
343
  it("does not wait for replay completion after cancellation unblocks a read", async () => {
@@ -90,6 +90,26 @@ export const createReplayBoundaryStream = async (
90
90
  const reader = body.getReader();
91
91
  let bytesForwarded = 0;
92
92
  let replayFinished = false;
93
+ let readerCleanup: Promise<void> | undefined;
94
+
95
+ const releaseReader = () => {
96
+ if (readerCleanup) return readerCleanup;
97
+ reader.releaseLock();
98
+ readerCleanup = Promise.resolve();
99
+ return readerCleanup;
100
+ };
101
+
102
+ const cancelReader = (reason?: unknown) => {
103
+ if (readerCleanup) return readerCleanup;
104
+ readerCleanup = (async () => {
105
+ try {
106
+ await reader.cancel(reason);
107
+ } finally {
108
+ reader.releaseLock();
109
+ }
110
+ })();
111
+ return readerCleanup;
112
+ };
93
113
 
94
114
  const finishReplay = async () => {
95
115
  if (replayFinished) return;
@@ -103,44 +123,50 @@ export const createReplayBoundaryStream = async (
103
123
 
104
124
  return new ReadableStream<Uint8Array>({
105
125
  async pull(controller) {
106
- const { done, value } = await reader.read();
107
-
108
- if (done) {
109
- await finishReplay();
110
- controller.close();
111
- return;
112
- }
113
-
114
- if (replayFinished) {
115
- controller.enqueue(value);
116
- return;
117
- }
118
-
119
- const nextBytesForwarded = bytesForwarded + value.byteLength;
120
-
121
- if (nextBytesForwarded < replayContentLength) {
122
- bytesForwarded = nextBytesForwarded;
123
- controller.enqueue(value);
124
- return;
125
- }
126
-
127
- if (nextBytesForwarded === replayContentLength) {
128
- controller.enqueue(value);
126
+ try {
127
+ const { done, value } = await reader.read();
128
+
129
+ if (done) {
130
+ await releaseReader();
131
+ await finishReplay();
132
+ controller.close();
133
+ return;
134
+ }
135
+
136
+ if (replayFinished) {
137
+ controller.enqueue(value);
138
+ return;
139
+ }
140
+
141
+ const nextBytesForwarded = bytesForwarded + value.byteLength;
142
+
143
+ if (nextBytesForwarded < replayContentLength) {
144
+ bytesForwarded = nextBytesForwarded;
145
+ controller.enqueue(value);
146
+ return;
147
+ }
148
+
149
+ if (nextBytesForwarded === replayContentLength) {
150
+ controller.enqueue(value);
151
+ await finishReplay();
152
+ return;
153
+ }
154
+
155
+ const replayBytesInChunk = replayContentLength - bytesForwarded;
156
+
157
+ controller.enqueue(value.subarray(0, replayBytesInChunk));
129
158
  await finishReplay();
130
- return;
159
+ controller.enqueue(value.subarray(replayBytesInChunk));
160
+ } catch (error) {
161
+ await cancelReader(error).catch(() => {});
162
+ throw error;
131
163
  }
132
-
133
- const replayBytesInChunk = replayContentLength - bytesForwarded;
134
-
135
- controller.enqueue(value.subarray(0, replayBytesInChunk));
136
- await finishReplay();
137
- controller.enqueue(value.subarray(replayBytesInChunk));
138
164
  },
139
165
  async cancel(reason) {
140
166
  const wasFinished = replayFinished;
141
167
  replayFinished = true;
142
168
  if (!wasFinished) setReplaying(false);
143
- await reader.cancel(reason);
169
+ await cancelReader(reason);
144
170
  },
145
171
  });
146
172
  };
@@ -0,0 +1,39 @@
1
+ import { describe, expectTypeOf, it } from "vitest";
2
+ import type { UserCommands, UserExternalState } from "../augmentations";
3
+
4
+ declare module "../augmentations" {
5
+ namespace Assistant {
6
+ interface Commands {
7
+ reactCommand: { type: "react-command"; data: number };
8
+ }
9
+ interface ExternalState {
10
+ reactState: { fromReact: boolean };
11
+ }
12
+ }
13
+ }
14
+
15
+ declare module "@assistant-ui/core" {
16
+ namespace Assistant {
17
+ interface Commands {
18
+ coreCommand: { type: "core-command" };
19
+ }
20
+ interface ExternalState {
21
+ coreState: { fromCore: boolean };
22
+ }
23
+ }
24
+ }
25
+
26
+ describe("Assistant augmentations", () => {
27
+ it("react-side contributions reach UserCommands and UserExternalState", () => {
28
+ expectTypeOf<{
29
+ type: "react-command";
30
+ data: number;
31
+ }>().toExtend<UserCommands>();
32
+ expectTypeOf<{ fromReact: boolean }>().toExtend<UserExternalState>();
33
+ });
34
+
35
+ it("core-side contributions flow into the react-side unions", () => {
36
+ expectTypeOf<{ type: "core-command" }>().toExtend<UserCommands>();
37
+ expectTypeOf<{ fromCore: boolean }>().toExtend<UserExternalState>();
38
+ });
39
+ });
@@ -2,10 +2,10 @@
2
2
 
3
3
  import { render, waitFor } from "@testing-library/react";
4
4
  import type { FC } from "react";
5
- import { describe, it, expect } from "vitest";
5
+ import { describe, it, expect, vi } from "vitest";
6
6
  import { useAui, AuiProvider } from "@assistant-ui/store";
7
7
  import { InMemoryThreadList } from "../client/InMemoryThreadList";
8
- import { ExternalThread } from "../client/ExternalThread";
8
+ import { ExternalThread } from "../index";
9
9
 
10
10
  const renderThreads = () => {
11
11
  const captured: { aui?: ReturnType<typeof useAui> } = {};
@@ -29,7 +29,7 @@ const renderThreads = () => {
29
29
  return { aui: () => captured.aui! };
30
30
  };
31
31
 
32
- describe("InMemoryThreadList delete", () => {
32
+ describe("InMemoryThreadList", () => {
33
33
  it("falls back to a live thread when the switch target is deleted in the same tick", async () => {
34
34
  const { aui } = renderThreads();
35
35
 
@@ -52,4 +52,88 @@ describe("InMemoryThreadList delete", () => {
52
52
  expect(state.threadIds).not.toContain(newId);
53
53
  });
54
54
  });
55
+
56
+ it("creates unique IDs for threads created in the same millisecond", async () => {
57
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
58
+
59
+ try {
60
+ const { aui } = renderThreads();
61
+ aui().threads.switchToNewThread();
62
+ aui().threads.switchToNewThread();
63
+
64
+ await waitFor(() => {
65
+ const generatedIds = aui()
66
+ .threads.getState()
67
+ .threadIds.filter((id) => id !== "main");
68
+ expect(generatedIds).toHaveLength(2);
69
+ expect(new Set(generatedIds).size).toBe(2);
70
+ });
71
+ } finally {
72
+ now.mockRestore();
73
+ }
74
+ });
75
+ });
76
+
77
+ describe("InMemoryThreadList reloadMainThread", () => {
78
+ it("routes reloadMainThread to the main thread's refetch callback", async () => {
79
+ const refetched: string[] = [];
80
+ const captured: { aui?: ReturnType<typeof useAui> } = {};
81
+ const Capture: FC = () => {
82
+ captured.aui = useAui();
83
+ return null;
84
+ };
85
+ const App: FC = () => {
86
+ const aui = useAui({
87
+ threads: InMemoryThreadList({
88
+ thread: (threadId) =>
89
+ ExternalThread({
90
+ messages: [],
91
+ onRefetchThread: async () => {
92
+ refetched.push(threadId);
93
+ },
94
+ }),
95
+ }),
96
+ });
97
+ return (
98
+ <AuiProvider value={aui}>
99
+ <Capture />
100
+ </AuiProvider>
101
+ );
102
+ };
103
+ render(<App />);
104
+ const aui = () => captured.aui!;
105
+
106
+ await waitFor(() => expect(captured.aui).toBeDefined());
107
+ await aui().threads.reloadMainThread();
108
+ expect(refetched).toEqual(["main"]);
109
+
110
+ aui().threads.switchToNewThread();
111
+ await waitFor(() =>
112
+ expect(aui().threads.getState().mainThreadId).not.toBe("main"),
113
+ );
114
+ const newId = aui().threads.getState().mainThreadId;
115
+
116
+ await aui().threads.reloadMainThread();
117
+ expect(refetched).toEqual(["main", newId]);
118
+ });
119
+
120
+ it("resolves reloadMainThread when the thread has no refetch callback", async () => {
121
+ const { aui } = renderThreads();
122
+
123
+ await waitFor(() => expect(aui()).toBeDefined());
124
+ await expect(aui().threads.reloadMainThread()).resolves.toBeUndefined();
125
+ });
126
+ });
127
+
128
+ describe("InMemoryThreadList suggestions", () => {
129
+ it("derives the suggestions scope from the main thread", async () => {
130
+ const { aui } = renderThreads();
131
+
132
+ await waitFor(() => {
133
+ expect(aui().suggestions.getState()).toEqual({ suggestions: [] });
134
+ });
135
+ expect(aui().suggestions.getState()).toBe(
136
+ aui().thread.suggestions().getState(),
137
+ );
138
+ });
55
139
  });
@@ -1,48 +0,0 @@
1
- import { ClientOutput } from "@assistant-ui/store";
2
- import { AddToolResultOptions, AppendMessage, AttachmentAdapter, ExternalThreadBranchAdapter, ExternalThreadQueueAdapter, FeedbackAdapter, RespondToToolApprovalOptions, ResumeToolCallOptions, SpeechSynthesisAdapter, ThreadMessage } from "@assistant-ui/core";
3
- import { ReadonlyJSONValue } from "assistant-stream/utils";
4
- //#region src/client/ExternalThread.d.ts
5
- type ExternalThreadMessage = ThreadMessage & {
6
- id: string;
7
- };
8
- type ExternalThreadProps = {
9
- messages: readonly ExternalThreadMessage[];
10
- isRunning?: boolean;
11
- isLoading?: boolean | undefined;
12
- state?: ReadonlyJSONValue | undefined;
13
- extras?: unknown;
14
- /**
15
- * Whether sending new messages is currently disabled. When `true`, the
16
- * thread composer's input remains usable but `send()` is a no-op and
17
- * `composer.canSend` is `false`. Edit composers (saving message edits)
18
- * intentionally ignore this flag.
19
- */
20
- isSendDisabled?: boolean;
21
- /**
22
- * Callback for new messages (non-queue runtimes).
23
- * @note Unused when `queue` is provided — new messages are routed through `queue.enqueue` instead.
24
- */
25
- onNew?: (message: AppendMessage) => void;
26
- onEdit?: (message: AppendMessage) => void;
27
- onReload?: (parentId: string | null) => void;
28
- onStartRun?: () => void;
29
- onCancel?: () => void;
30
- onResume?: (() => void) | undefined;
31
- onAddToolResult?: ((options: AddToolResultOptions) => void) | undefined;
32
- /** Callback for resuming a tool call that is waiting for human input. */
33
- onResumeToolCall?: ((options: ResumeToolCallOptions) => void) | undefined;
34
- onLoadExternalState?: ((state: unknown) => void) | undefined;
35
- attachmentAdapter?: AttachmentAdapter | undefined;
36
- feedbackAdapter?: FeedbackAdapter | undefined;
37
- speechAdapter?: SpeechSynthesisAdapter | undefined;
38
- /** Queue adapter for runtimes that support message queuing and steering. */
39
- queue?: ExternalThreadQueueAdapter;
40
- /** Branch adapter for runtimes that track sibling variants of messages. */
41
- branches?: ExternalThreadBranchAdapter;
42
- /** Callback for tool approval decisions. Absent: responding to an approval throws a capability error. */
43
- onRespondToToolApproval?: (options: RespondToToolApprovalOptions) => void;
44
- };
45
- declare const ExternalThread: import("@assistant-ui/tap").Resource<ClientOutput<"thread">, [ExternalThreadProps]>;
46
- //#endregion
47
- export { ExternalThread, ExternalThreadMessage, ExternalThreadProps };
48
- //# sourceMappingURL=ExternalThread.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ExternalThread.d.ts","names":[],"sources":["../../src/client/ExternalThread.ts"],"mappings":";;;;KAkDY,wBAAwB;EAClC;;KAgBU;EACV,mBAAmB;EACnB;EACA;EACA,QAAQ;EACR;;;;;;;EAOA;;;;;EAKA,SAAS,SAAS;EAClB,UAAU,SAAS;EACnB,YAAY;EACZ;EACA;EACA;EACA,oBAAoB,SAAS;;EAE7B,qBAAqB,SAAS;EAC9B,wBAAwB;EACxB,oBAAoB;EACpB,kBAAkB;EAClB,gBAAgB;;EAEhB,QAAQ;;EAER,WAAW;;EAEX,2BAA2B,SAAS;;cA2+BzB,4CAAc,SAAA,yBAAA"}