@assistant-ui/ai-sdk 0.0.10 → 0.0.12
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/converters/convertMessage.d.ts +8 -6
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +106 -28
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/toolApprovalAnswers.d.ts +3 -0
- package/dist/converters/toolApprovalAnswers.d.ts.map +1 -0
- package/dist/converters/toolApprovalAnswers.js +17 -0
- package/dist/converters/toolApprovalAnswers.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.native.d.ts +1 -1
- package/dist/index.native.d.ts.map +1 -1
- package/dist/index.native.js +1 -1
- package/dist/model-context/injectInteractableContext.d.ts +3 -0
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
- package/dist/model-context/injectInteractableContext.js +3 -0
- package/dist/model-context/injectInteractableContext.js.map +1 -1
- package/dist/model-context/injectQuoteContext.d.ts +1 -0
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
- package/dist/model-context/injectQuoteContext.js +1 -0
- package/dist/model-context/injectQuoteContext.js.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKThreads.d.ts +5 -0
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +37 -4
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/DynamicChatTransport.d.ts +49 -0
- package/dist/runtime/DynamicChatTransport.d.ts.map +1 -0
- package/dist/runtime/DynamicChatTransport.js +147 -0
- package/dist/runtime/DynamicChatTransport.js.map +1 -0
- package/dist/runtime/getResumableAdapter.d.ts +5 -0
- package/dist/runtime/getResumableAdapter.d.ts.map +1 -0
- package/dist/runtime/getResumableAdapter.js +12 -0
- package/dist/runtime/getResumableAdapter.js.map +1 -0
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/toolHistoryCodec.d.ts +20 -0
- package/dist/runtime/toolHistoryCodec.d.ts.map +1 -0
- package/dist/runtime/toolHistoryCodec.js +107 -0
- package/dist/runtime/toolHistoryCodec.js.map +1 -0
- package/dist/runtime/useAISDKRuntime.d.ts +4 -1
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +154 -155
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts +14 -2
- package/dist/runtime/useChatRuntime.d.ts.map +1 -1
- package/dist/runtime/useChatRuntime.js +12 -3
- package/dist/runtime/useChatRuntime.js.map +1 -1
- package/dist/runtime/useChatThread.d.ts +3 -2
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +56 -36
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useDynamicChatTransport.d.ts +4 -0
- package/dist/runtime/useDynamicChatTransport.d.ts.map +1 -0
- package/dist/runtime/useDynamicChatTransport.js +64 -0
- package/dist/runtime/useDynamicChatTransport.js.map +1 -0
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +11 -105
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.js +1 -1
- package/dist/runtime/useStreamingTiming.js +2 -2
- package/dist/runtime/useStreamingTiming.js.map +1 -1
- package/dist/tools/generativeTools.d.ts +2 -1
- package/dist/tools/generativeTools.d.ts.map +1 -1
- package/dist/tools/generativeTools.js +17 -6
- package/dist/tools/generativeTools.js.map +1 -1
- package/dist/usage.js +1 -1
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
- package/dist/utils/sliceMessagesUntil.js +1 -2
- package/dist/utils/sliceMessagesUntil.js.map +1 -1
- package/package.json +12 -10
- package/src/converters/convertMessage.test.ts +350 -2
- package/src/converters/convertMessage.tool-args-status.test.tsx +185 -0
- package/src/converters/convertMessage.ts +154 -23
- package/src/converters/toCreateMessage.test.ts +27 -0
- package/src/converters/toolApprovalAnswers.ts +27 -0
- package/src/index.native.ts +1 -1
- package/src/index.ts +1 -1
- package/src/model-context/injectInteractableContext.ts +3 -0
- package/src/model-context/injectQuoteContext.ts +1 -0
- package/src/runtime/AISDKChat.integration.test.tsx +57 -2
- package/src/runtime/AISDKThreads.cloud.test.ts +3 -0
- package/src/runtime/AISDKThreads.test.ts +181 -0
- package/src/runtime/AISDKThreads.ts +31 -4
- package/src/runtime/DynamicChatTransport.test.ts +203 -0
- package/src/runtime/DynamicChatTransport.ts +273 -0
- package/src/runtime/__tests__/controlled-transport.ts +3 -0
- package/src/runtime/getResumableAdapter.ts +16 -0
- package/src/runtime/toolHistoryCodec.test.ts +161 -0
- package/src/runtime/toolHistoryCodec.ts +207 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +12 -0
- package/src/runtime/useAISDKRuntime.reload.test.tsx +219 -0
- package/src/runtime/useAISDKRuntime.test.ts +538 -5
- package/src/runtime/useAISDKRuntime.ts +161 -51
- package/src/runtime/useChatRuntime.integration.test.tsx +319 -4
- package/src/runtime/useChatRuntime.local-storage.test.tsx +123 -0
- package/src/runtime/useChatRuntime.test.ts +107 -1
- package/src/runtime/useChatRuntime.ts +26 -4
- package/src/runtime/useChatThread.binding.test.tsx +143 -0
- package/src/runtime/useChatThread.ts +98 -81
- package/src/runtime/useDynamicChatTransport.ts +26 -0
- package/src/runtime/useExternalHistory.test.ts +205 -0
- package/src/runtime/useExternalHistory.ts +14 -206
- package/src/runtime/useStreamingTiming.ts +2 -2
- package/src/tools/generativeTools.test.ts +190 -2
- package/src/tools/generativeTools.ts +28 -8
- package/src/utils/sliceMessagesUntil.test.ts +2 -6
- package/src/utils/sliceMessagesUntil.ts +1 -5
- package/dist/converters/modelContentEnvelope.d.ts +0 -14
- package/dist/converters/modelContentEnvelope.d.ts.map +0 -1
- package/dist/converters/modelContentEnvelope.js +0 -22
- package/dist/converters/modelContentEnvelope.js.map +0 -1
- package/dist/converters/toolOutputConversion.d.ts +0 -26
- package/dist/converters/toolOutputConversion.d.ts.map +0 -1
- package/dist/converters/toolOutputConversion.js +0 -31
- package/dist/converters/toolOutputConversion.js.map +0 -1
- package/dist/tools/frontendTools.d.ts +0 -30
- package/dist/tools/frontendTools.d.ts.map +0 -1
- package/dist/tools/frontendTools.js +0 -33
- package/dist/tools/frontendTools.js.map +0 -1
- package/src/converters/modelContentEnvelope.ts +0 -41
- package/src/converters/toolOutputConversion.ts +0 -26
- package/src/tools/frontendTools.test.ts +0 -205
- package/src/tools/frontendTools.ts +0 -83
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { Suspense, version } from "react";
|
|
4
|
+
import { render } from "@testing-library/react";
|
|
5
|
+
import type { AssistantRuntime } from "@assistant-ui/core";
|
|
6
|
+
import type { ChatTransport, UIMessage } from "ai";
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { AssistantChatTransport } from "../transport/AssistantChatTransport";
|
|
9
|
+
import { DynamicChatTransport } from "./DynamicChatTransport";
|
|
10
|
+
|
|
11
|
+
const mocks = vi.hoisted(() => ({
|
|
12
|
+
system: "",
|
|
13
|
+
useAISDKRuntime: vi.fn(),
|
|
14
|
+
useChat: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("@ai-sdk/react", async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal<typeof import("@ai-sdk/react")>()),
|
|
19
|
+
Chat: class MockChat {
|
|
20
|
+
constructor(config: unknown) {
|
|
21
|
+
Object.assign(this, config);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
useChat: mocks.useChat,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("./useAISDKRuntime", async (importOriginal) => ({
|
|
28
|
+
...(await importOriginal<typeof import("./useAISDKRuntime")>()),
|
|
29
|
+
useAISDKRuntime: mocks.useAISDKRuntime,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import { useChatThread } from "./useChatThread";
|
|
33
|
+
|
|
34
|
+
const onReact18 = version.startsWith("18.");
|
|
35
|
+
|
|
36
|
+
const createRuntime = (system: string) =>
|
|
37
|
+
({
|
|
38
|
+
thread: {
|
|
39
|
+
getModelContext: () => ({ system }),
|
|
40
|
+
getState: () => ({ isLoading: false }),
|
|
41
|
+
subscribe: () => () => {},
|
|
42
|
+
},
|
|
43
|
+
}) as unknown as AssistantRuntime;
|
|
44
|
+
|
|
45
|
+
describe("useChatThread transport binding", () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mocks.system = "";
|
|
48
|
+
mocks.useAISDKRuntime.mockReset();
|
|
49
|
+
mocks.useChat.mockReset();
|
|
50
|
+
mocks.useAISDKRuntime.mockImplementation(() => createRuntime(mocks.system));
|
|
51
|
+
mocks.useChat.mockImplementation(({ chat }) => ({
|
|
52
|
+
...chat,
|
|
53
|
+
messages: [],
|
|
54
|
+
status: "ready",
|
|
55
|
+
error: undefined,
|
|
56
|
+
stop: vi.fn(async () => {}),
|
|
57
|
+
}));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.skipIf(onReact18)(
|
|
61
|
+
"keeps fallback wiring on committed values after an abandoned render",
|
|
62
|
+
async () => {
|
|
63
|
+
const bodies: Array<{ id: string; system: string }> = [];
|
|
64
|
+
const sourceTransport = new AssistantChatTransport<UIMessage>({
|
|
65
|
+
fetch: vi.fn(async (_input, init) => {
|
|
66
|
+
bodies.push(JSON.parse(String(init?.body)));
|
|
67
|
+
return new Response(
|
|
68
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
69
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
70
|
+
);
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
const transport = new DynamicChatTransport(sourceTransport);
|
|
74
|
+
const pending = new Promise<never>(() => {});
|
|
75
|
+
let committedProxy: ChatTransport<UIMessage> | undefined;
|
|
76
|
+
|
|
77
|
+
const App = ({
|
|
78
|
+
system,
|
|
79
|
+
remoteId,
|
|
80
|
+
suspend = false,
|
|
81
|
+
}: {
|
|
82
|
+
system: string;
|
|
83
|
+
remoteId: string;
|
|
84
|
+
suspend?: boolean;
|
|
85
|
+
}) => {
|
|
86
|
+
mocks.system = system;
|
|
87
|
+
useChatThread(
|
|
88
|
+
{ transport },
|
|
89
|
+
{
|
|
90
|
+
id: "thread-id",
|
|
91
|
+
isMainThread: true,
|
|
92
|
+
getThreadListItem: () => ({
|
|
93
|
+
initialize: async () => ({
|
|
94
|
+
remoteId,
|
|
95
|
+
externalId: undefined,
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
const proxy = mocks.useChat.mock.lastCall?.[0].chat.transport as
|
|
101
|
+
| ChatTransport<UIMessage>
|
|
102
|
+
| undefined;
|
|
103
|
+
if (!suspend) committedProxy = proxy;
|
|
104
|
+
if (suspend) throw pending;
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const view = render(
|
|
109
|
+
<Suspense fallback={null}>
|
|
110
|
+
<App system="committed-system" remoteId="committed-remote" />
|
|
111
|
+
</Suspense>,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
transport.registerThread("thread-id", {});
|
|
115
|
+
view.rerender(
|
|
116
|
+
<Suspense fallback={null}>
|
|
117
|
+
<App system="discarded-system" remoteId="discarded-remote" suspend />
|
|
118
|
+
</Suspense>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
await committedProxy!.sendMessages({
|
|
122
|
+
trigger: "submit-message",
|
|
123
|
+
chatId: "thread-id",
|
|
124
|
+
messageId: undefined,
|
|
125
|
+
messages: [
|
|
126
|
+
{
|
|
127
|
+
id: "message-id",
|
|
128
|
+
role: "user",
|
|
129
|
+
parts: [{ type: "text", text: "hello" }],
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
abortSignal: undefined,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(bodies).toEqual([
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
id: "committed-remote",
|
|
138
|
+
system: "committed-system",
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
});
|
|
@@ -12,24 +12,28 @@ import {
|
|
|
12
12
|
type AISDKRuntimeAdapter,
|
|
13
13
|
type CustomToCreateMessageFunction,
|
|
14
14
|
} from "./useAISDKRuntime";
|
|
15
|
-
import type { ChatInit
|
|
15
|
+
import type { ChatInit } from "ai";
|
|
16
16
|
import {
|
|
17
17
|
AssistantChatTransport,
|
|
18
18
|
type InitializableThreadListItem,
|
|
19
19
|
} from "../transport/AssistantChatTransport";
|
|
20
|
-
import type {
|
|
21
|
-
AssistantChatResumableOptions,
|
|
22
|
-
ResumableClientStorage,
|
|
23
|
-
} from "../transport/resumable";
|
|
24
20
|
import {
|
|
25
21
|
useCallback,
|
|
26
22
|
useEffect,
|
|
23
|
+
useEffectEvent,
|
|
24
|
+
useInsertionEffect,
|
|
27
25
|
useMemo,
|
|
28
26
|
useRef,
|
|
29
27
|
useState,
|
|
30
28
|
useSyncExternalStore,
|
|
31
29
|
} from "react";
|
|
32
30
|
import { useResourceCleanup } from "./useResourceCleanup";
|
|
31
|
+
import {
|
|
32
|
+
DynamicChatTransport,
|
|
33
|
+
getResumedStreamIds,
|
|
34
|
+
} from "./DynamicChatTransport";
|
|
35
|
+
import { getResumableAdapter } from "./getResumableAdapter";
|
|
36
|
+
import { useDynamicChatTransport } from "./useDynamicChatTransport";
|
|
33
37
|
|
|
34
38
|
export type ChatThreadOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
35
39
|
ChatInit<UI_MESSAGE> &
|
|
@@ -49,6 +53,7 @@ export type ChatThreadOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
|
49
53
|
onResumeError?: ((error: unknown) => void) | undefined;
|
|
50
54
|
joinStrategy?: AISDKRuntimeAdapter["joinStrategy"];
|
|
51
55
|
messageRepository?: AISDKRuntimeAdapter<UI_MESSAGE>["messageRepository"];
|
|
56
|
+
/** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */
|
|
52
57
|
unstable_onBranchChange?: AISDKRuntimeAdapter["unstable_onBranchChange"];
|
|
53
58
|
};
|
|
54
59
|
|
|
@@ -77,58 +82,13 @@ export type ChatThreadEnvironment<UI_MESSAGE extends UIMessage = UIMessage> = {
|
|
|
77
82
|
messageRepositoryInstance?: MessageRepository | undefined;
|
|
78
83
|
};
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const transportRef = useRef<ChatTransport<UI_MESSAGE>>(transport);
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
transportRef.current = transport;
|
|
86
|
-
});
|
|
87
|
-
const dynamicTransport = useMemo(
|
|
88
|
-
() =>
|
|
89
|
-
new Proxy(transportRef.current, {
|
|
90
|
-
get(_, prop) {
|
|
91
|
-
const res =
|
|
92
|
-
transportRef.current[prop as keyof ChatTransport<UI_MESSAGE>];
|
|
93
|
-
return typeof res === "function"
|
|
94
|
-
? res.bind(transportRef.current)
|
|
95
|
-
: res;
|
|
96
|
-
},
|
|
97
|
-
}),
|
|
98
|
-
[],
|
|
99
|
-
);
|
|
100
|
-
return dynamicTransport;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const getResumableAdapter = <UI_MESSAGE extends UIMessage>(
|
|
104
|
-
transport: ChatTransport<UI_MESSAGE>,
|
|
105
|
-
): AssistantChatResumableOptions | undefined => {
|
|
106
|
-
if (transport instanceof AssistantChatTransport) {
|
|
107
|
-
return transport.getResumableAdapter();
|
|
108
|
-
}
|
|
109
|
-
const candidate = (transport as { getResumableAdapter?: () => unknown })
|
|
110
|
-
.getResumableAdapter;
|
|
111
|
-
if (typeof candidate !== "function") return undefined;
|
|
112
|
-
return candidate.call(transport) as AssistantChatResumableOptions | undefined;
|
|
85
|
+
type ChatThreadTransportBinding = {
|
|
86
|
+
runtime: AssistantRuntime;
|
|
87
|
+
getThreadListItem: () => InitializableThreadListItem | undefined;
|
|
113
88
|
};
|
|
114
89
|
|
|
115
90
|
const getNoPendingStreamId = () => null;
|
|
116
91
|
|
|
117
|
-
const resumedStreamIdsByStorage = new WeakMap<
|
|
118
|
-
ResumableClientStorage,
|
|
119
|
-
Set<string>
|
|
120
|
-
>();
|
|
121
|
-
|
|
122
|
-
const getResumedStreamIds = (storage: ResumableClientStorage | undefined) => {
|
|
123
|
-
if (!storage) return new Set<string>();
|
|
124
|
-
let resumedStreamIds = resumedStreamIdsByStorage.get(storage);
|
|
125
|
-
if (!resumedStreamIds) {
|
|
126
|
-
resumedStreamIds = new Set();
|
|
127
|
-
resumedStreamIdsByStorage.set(storage, resumedStreamIds);
|
|
128
|
-
}
|
|
129
|
-
return resumedStreamIds;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
92
|
/**
|
|
133
93
|
* Splits the combined options into the assistant-ui side and the `ChatInit`
|
|
134
94
|
* remainder the AI SDK consumes, so external `Chat` construction forwards the
|
|
@@ -246,21 +206,47 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
246
206
|
messageRepositoryInstance,
|
|
247
207
|
} = env;
|
|
248
208
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
209
|
+
const defaultTransport = useMemo(() => new AssistantChatTransport(), []);
|
|
210
|
+
const configuredTransport = transportOptions ?? defaultTransport;
|
|
211
|
+
const transport = useDynamicChatTransport(
|
|
212
|
+
configuredTransport,
|
|
213
|
+
externalChat === undefined,
|
|
214
|
+
);
|
|
215
|
+
const transportContextOwner = useMemo(() => ({}), []);
|
|
216
|
+
const getThreadListItemRef = useRef(getThreadListItem);
|
|
217
|
+
useInsertionEffect(() => {
|
|
218
|
+
getThreadListItemRef.current = getThreadListItem;
|
|
219
|
+
}, [getThreadListItem]);
|
|
220
|
+
const getCurrentThreadListItem = useCallback(
|
|
221
|
+
() => getThreadListItemRef.current(),
|
|
222
|
+
[],
|
|
223
|
+
);
|
|
224
|
+
const resumableStorage = useMemo(
|
|
254
225
|
() =>
|
|
255
|
-
|
|
256
|
-
?
|
|
257
|
-
:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
226
|
+
transport instanceof DynamicChatTransport
|
|
227
|
+
? transport.getCurrentResumableStorage()
|
|
228
|
+
: getResumableAdapter(transport)?.storage,
|
|
229
|
+
[transport],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const transportBindingRef = useRef<ChatThreadTransportBinding | null>(null);
|
|
233
|
+
// This is null when useMemo runs, so the proxy pins its mount render's binding. The fallback
|
|
234
|
+
// serves only pre-commit sends; after insertion effects, transportBindingRef is authoritative.
|
|
235
|
+
let initialTransportBinding: ChatThreadTransportBinding | null = null;
|
|
236
|
+
const chatTransport = useMemo(
|
|
237
|
+
() =>
|
|
238
|
+
transport instanceof DynamicChatTransport
|
|
239
|
+
? transport.createThreadProxy(transportContextOwner, () => {
|
|
240
|
+
const binding =
|
|
241
|
+
transportBindingRef.current ?? initialTransportBinding;
|
|
242
|
+
if (!binding) {
|
|
243
|
+
throw new Error("Chat transport used before runtime setup");
|
|
244
|
+
}
|
|
245
|
+
return binding;
|
|
246
|
+
})
|
|
247
|
+
: transport,
|
|
248
|
+
[initialTransportBinding, transport, transportContextOwner],
|
|
262
249
|
);
|
|
263
|
-
const transport = useDynamicChatTransport(sourceTransport);
|
|
264
250
|
|
|
265
251
|
const latestChatOptionsRef = useRef(chatOptions);
|
|
266
252
|
useEffect(() => {
|
|
@@ -271,7 +257,10 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
271
257
|
const [ownedChat] = useState(
|
|
272
258
|
() =>
|
|
273
259
|
externalChat ??
|
|
274
|
-
createChat(
|
|
260
|
+
createChat(
|
|
261
|
+
{ ...chatOptions, id, transport: chatTransport },
|
|
262
|
+
latestChatOptionsRef,
|
|
263
|
+
),
|
|
275
264
|
);
|
|
276
265
|
|
|
277
266
|
const chat = useChat({
|
|
@@ -309,15 +298,42 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
309
298
|
unstable_hostApprovalOwner: externalChat ?? ownedChat,
|
|
310
299
|
...(unstable_onBranchChange && { unstable_onBranchChange }),
|
|
311
300
|
});
|
|
301
|
+
initialTransportBinding = {
|
|
302
|
+
runtime,
|
|
303
|
+
getThreadListItem: getCurrentThreadListItem,
|
|
304
|
+
};
|
|
305
|
+
useInsertionEffect(() => {
|
|
306
|
+
transportBindingRef.current = {
|
|
307
|
+
runtime,
|
|
308
|
+
getThreadListItem: getCurrentThreadListItem,
|
|
309
|
+
};
|
|
310
|
+
}, [runtime, getCurrentThreadListItem]);
|
|
311
|
+
|
|
312
|
+
const registerTransportContext = useEffectEvent(
|
|
313
|
+
(dynamicTransport: DynamicChatTransport<UI_MESSAGE>, chatId: string) => {
|
|
314
|
+
dynamicTransport.setThreadContext(
|
|
315
|
+
chatId,
|
|
316
|
+
transportContextOwner,
|
|
317
|
+
runtime,
|
|
318
|
+
getCurrentThreadListItem,
|
|
319
|
+
);
|
|
320
|
+
return () =>
|
|
321
|
+
dynamicTransport.unregisterThread(chatId, transportContextOwner);
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
useInsertionEffect(() => {
|
|
325
|
+
if (!(transport instanceof DynamicChatTransport)) return undefined;
|
|
326
|
+
return registerTransportContext(transport, id);
|
|
327
|
+
}, [id, runtime, transport, transportContextOwner]);
|
|
312
328
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
329
|
+
if (
|
|
330
|
+
!(transport instanceof DynamicChatTransport) &&
|
|
331
|
+
configuredTransport instanceof AssistantChatTransport
|
|
332
|
+
) {
|
|
333
|
+
configuredTransport.setRuntime(runtime);
|
|
334
|
+
configuredTransport.__internal_setGetThreadListItem(
|
|
335
|
+
getCurrentThreadListItem,
|
|
336
|
+
);
|
|
321
337
|
}
|
|
322
338
|
|
|
323
339
|
const subscribeToRuntime = useCallback(
|
|
@@ -334,10 +350,6 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
334
350
|
getHistoryLoadingSnapshot,
|
|
335
351
|
);
|
|
336
352
|
|
|
337
|
-
const resumableStorage = useMemo(
|
|
338
|
-
() => getResumableAdapter(sourceTransport)?.storage,
|
|
339
|
-
[sourceTransport],
|
|
340
|
-
);
|
|
341
353
|
const subscribeToResumableStorage = useCallback(
|
|
342
354
|
(callback: () => void) =>
|
|
343
355
|
isMainThread
|
|
@@ -357,7 +369,7 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
357
369
|
const isChatRunning =
|
|
358
370
|
chat.status === "submitted" || chat.status === "streaming";
|
|
359
371
|
|
|
360
|
-
const
|
|
372
|
+
const staticResumedStreamIds = useMemo(
|
|
361
373
|
() => getResumedStreamIds(resumableStorage),
|
|
362
374
|
[resumableStorage],
|
|
363
375
|
);
|
|
@@ -366,6 +378,10 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
366
378
|
onResumeErrorRef.current = onResumeError;
|
|
367
379
|
});
|
|
368
380
|
useEffect(() => {
|
|
381
|
+
const resumedStreamIds =
|
|
382
|
+
transport instanceof DynamicChatTransport
|
|
383
|
+
? transport.getResumedStreamIds()
|
|
384
|
+
: staticResumedStreamIds;
|
|
369
385
|
if (!pendingStreamId || resumedStreamIds.has(pendingStreamId)) {
|
|
370
386
|
return;
|
|
371
387
|
}
|
|
@@ -411,7 +427,8 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
411
427
|
pendingStreamId,
|
|
412
428
|
ownedChat,
|
|
413
429
|
resumableStorage,
|
|
414
|
-
|
|
430
|
+
staticResumedStreamIds,
|
|
431
|
+
transport,
|
|
415
432
|
]);
|
|
416
433
|
|
|
417
434
|
return runtime;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { UIMessage } from "@ai-sdk/react";
|
|
2
|
+
import type { ChatTransport } from "ai";
|
|
3
|
+
import { useEffect, useInsertionEffect, useState } from "react";
|
|
4
|
+
import { DynamicChatTransport } from "./DynamicChatTransport";
|
|
5
|
+
|
|
6
|
+
export const useDynamicChatTransport = <UI_MESSAGE extends UIMessage>(
|
|
7
|
+
transport: ChatTransport<UI_MESSAGE>,
|
|
8
|
+
enabled = true,
|
|
9
|
+
): ChatTransport<UI_MESSAGE> => {
|
|
10
|
+
const [dynamicTransport] = useState(() =>
|
|
11
|
+
transport instanceof DynamicChatTransport
|
|
12
|
+
? transport
|
|
13
|
+
: new DynamicChatTransport(transport),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
useInsertionEffect(() => {
|
|
17
|
+
if (!enabled || dynamicTransport === transport) return;
|
|
18
|
+
dynamicTransport.setTransport(transport);
|
|
19
|
+
}, [dynamicTransport, enabled, transport]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!enabled || dynamicTransport === transport) return;
|
|
22
|
+
dynamicTransport.flushTransportChange();
|
|
23
|
+
}, [dynamicTransport, enabled, transport]);
|
|
24
|
+
|
|
25
|
+
return enabled ? dynamicTransport : transport;
|
|
26
|
+
};
|
|
@@ -206,6 +206,53 @@ describe("useExternalHistory withFormat contract", () => {
|
|
|
206
206
|
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
+
it("re-enters loading when remoteId arrives before history settles", async () => {
|
|
210
|
+
mocks.hasThreadListItem = true;
|
|
211
|
+
let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
|
|
212
|
+
const load = vi.fn(
|
|
213
|
+
() =>
|
|
214
|
+
new Promise<MessageFormatRepository<unknown>>((resolve) => {
|
|
215
|
+
resolveLoad = resolve;
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
const adapter: ThreadHistoryAdapter = {
|
|
219
|
+
load: vi.fn(),
|
|
220
|
+
append: vi.fn(),
|
|
221
|
+
withFormat: vi.fn().mockReturnValue({
|
|
222
|
+
load,
|
|
223
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const { result } = renderHook(() =>
|
|
228
|
+
useExternalHistory(
|
|
229
|
+
runtimeRef,
|
|
230
|
+
adapter,
|
|
231
|
+
toThreadMessages,
|
|
232
|
+
storageFormat,
|
|
233
|
+
onSetMessages,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await act(async () => {});
|
|
238
|
+
expect(result.current.isLoading).toBe(false);
|
|
239
|
+
expect(load).not.toHaveBeenCalled();
|
|
240
|
+
|
|
241
|
+
mocks.remoteId = "remote-thread";
|
|
242
|
+
await act(async () => {
|
|
243
|
+
for (const listener of mocks.listeners) listener();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
247
|
+
expect(result.current.isLoading).toBe(true);
|
|
248
|
+
|
|
249
|
+
await act(async () => {
|
|
250
|
+
resolveLoad({ headId: null, messages: [] });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
254
|
+
});
|
|
255
|
+
|
|
209
256
|
it("does not load history when remoteId appears during an active run", async () => {
|
|
210
257
|
mocks.hasThreadListItem = true;
|
|
211
258
|
const load = vi.fn().mockResolvedValue({ headId: null, messages: [] });
|
|
@@ -288,6 +335,70 @@ describe("useExternalHistory withFormat contract", () => {
|
|
|
288
335
|
|
|
289
336
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
290
337
|
});
|
|
338
|
+
|
|
339
|
+
it("does not replace live messages when history resolves after a send starts", async () => {
|
|
340
|
+
mocks.hasThreadListItem = true;
|
|
341
|
+
mocks.remoteId = "remote-thread";
|
|
342
|
+
let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
|
|
343
|
+
const load = vi.fn(
|
|
344
|
+
() =>
|
|
345
|
+
new Promise<MessageFormatRepository<unknown>>((resolve) => {
|
|
346
|
+
resolveLoad = resolve;
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
const threadState = {
|
|
350
|
+
isRunning: false,
|
|
351
|
+
messages: [] as ThreadMessage[],
|
|
352
|
+
};
|
|
353
|
+
const threadImport = vi.fn();
|
|
354
|
+
const setMessages = vi.fn();
|
|
355
|
+
const thread = {
|
|
356
|
+
subscribe: () => () => {},
|
|
357
|
+
getState: () => threadState,
|
|
358
|
+
import: threadImport,
|
|
359
|
+
export: () => ({ headId: null, messages: [] }),
|
|
360
|
+
} as unknown as AssistantRuntime["thread"];
|
|
361
|
+
const historyRuntimeRef = {
|
|
362
|
+
current: { thread } as AssistantRuntime,
|
|
363
|
+
};
|
|
364
|
+
const adapter: ThreadHistoryAdapter = {
|
|
365
|
+
load: vi.fn(),
|
|
366
|
+
append: vi.fn(),
|
|
367
|
+
withFormat: vi.fn().mockReturnValue({
|
|
368
|
+
load,
|
|
369
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
370
|
+
}),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const { result } = renderHook(() =>
|
|
374
|
+
useExternalHistory(
|
|
375
|
+
historyRuntimeRef,
|
|
376
|
+
adapter,
|
|
377
|
+
(messages) => messages.map((message) => message as ThreadMessage),
|
|
378
|
+
storageFormat,
|
|
379
|
+
setMessages,
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
384
|
+
const liveMessages = [
|
|
385
|
+
{ id: "live-user" },
|
|
386
|
+
{ id: "live-assistant" },
|
|
387
|
+
] as ThreadMessage[];
|
|
388
|
+
await act(async () => {
|
|
389
|
+
threadState.isRunning = true;
|
|
390
|
+
threadState.messages = liveMessages;
|
|
391
|
+
resolveLoad({
|
|
392
|
+
headId: "stored",
|
|
393
|
+
messages: [{ parentId: null, message: { id: "stored" } }],
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
398
|
+
expect(threadState.messages).toBe(liveMessages);
|
|
399
|
+
expect(threadImport).not.toHaveBeenCalled();
|
|
400
|
+
expect(setMessages).not.toHaveBeenCalled();
|
|
401
|
+
});
|
|
291
402
|
});
|
|
292
403
|
|
|
293
404
|
describe("toExportedMessageRepository", () => {
|
|
@@ -1383,6 +1494,100 @@ describe("useExternalHistory persistence", () => {
|
|
|
1383
1494
|
);
|
|
1384
1495
|
});
|
|
1385
1496
|
|
|
1497
|
+
it("stores and restores questionnaire answers with the approval response", async () => {
|
|
1498
|
+
const answers = {
|
|
1499
|
+
scope: { optionIds: ["src", "tests"] },
|
|
1500
|
+
note: { text: "keep it short" },
|
|
1501
|
+
};
|
|
1502
|
+
const saved = new Map<string, RespondToToolApprovalOptions>();
|
|
1503
|
+
const writer = createPersistenceHarness(false, {
|
|
1504
|
+
toolApprovalResponses: saved,
|
|
1505
|
+
});
|
|
1506
|
+
const innerMessage: InnerMessage = {
|
|
1507
|
+
id: "inner-a",
|
|
1508
|
+
role: "assistant",
|
|
1509
|
+
parts: [
|
|
1510
|
+
{
|
|
1511
|
+
type: "tool-ask",
|
|
1512
|
+
toolCallId: "call-1",
|
|
1513
|
+
state: "approval-requested",
|
|
1514
|
+
approval: { id: "approval-1" },
|
|
1515
|
+
},
|
|
1516
|
+
],
|
|
1517
|
+
};
|
|
1518
|
+
const message = Object.assign(
|
|
1519
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
1520
|
+
innerMessage,
|
|
1521
|
+
]),
|
|
1522
|
+
{
|
|
1523
|
+
content: [
|
|
1524
|
+
{
|
|
1525
|
+
type: "tool-call" as const,
|
|
1526
|
+
toolCallId: "call-1",
|
|
1527
|
+
toolName: "ask",
|
|
1528
|
+
args: {},
|
|
1529
|
+
argsText: "{}",
|
|
1530
|
+
result: undefined,
|
|
1531
|
+
isError: false,
|
|
1532
|
+
approval: { id: "approval-1" },
|
|
1533
|
+
},
|
|
1534
|
+
],
|
|
1535
|
+
},
|
|
1536
|
+
);
|
|
1537
|
+
await waitFor(() => expect(writer.load).toHaveBeenCalledTimes(1));
|
|
1538
|
+
saved.set("approval-1", {
|
|
1539
|
+
approvalId: "approval-1",
|
|
1540
|
+
approved: true,
|
|
1541
|
+
answers,
|
|
1542
|
+
});
|
|
1543
|
+
await writer.runCycle([message]);
|
|
1544
|
+
await waitFor(() =>
|
|
1545
|
+
expect(writer.append).toHaveBeenCalledWith({
|
|
1546
|
+
parentId: null,
|
|
1547
|
+
message: {
|
|
1548
|
+
...innerMessage,
|
|
1549
|
+
metadata: {
|
|
1550
|
+
__aui_toolApprovalResponses: {
|
|
1551
|
+
"approval-1": { approved: true, answers },
|
|
1552
|
+
},
|
|
1553
|
+
},
|
|
1554
|
+
},
|
|
1555
|
+
}),
|
|
1556
|
+
);
|
|
1557
|
+
writer.unmount();
|
|
1558
|
+
|
|
1559
|
+
const restored = new Map<string, RespondToToolApprovalOptions>();
|
|
1560
|
+
const reader = createPersistenceHarness(true, {
|
|
1561
|
+
loadMessages: {
|
|
1562
|
+
messages: [
|
|
1563
|
+
{
|
|
1564
|
+
parentId: null,
|
|
1565
|
+
message: {
|
|
1566
|
+
...innerMessage,
|
|
1567
|
+
metadata: {
|
|
1568
|
+
__aui_toolApprovalResponses: {
|
|
1569
|
+
"approval-1": {
|
|
1570
|
+
approved: true,
|
|
1571
|
+
answers: { ...answers, junk: { optionIds: [1] } },
|
|
1572
|
+
},
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
},
|
|
1576
|
+
},
|
|
1577
|
+
],
|
|
1578
|
+
},
|
|
1579
|
+
toolApprovalResponses: restored,
|
|
1580
|
+
});
|
|
1581
|
+
await waitFor(() => expect(reader.load).toHaveBeenCalledTimes(1));
|
|
1582
|
+
await waitFor(() =>
|
|
1583
|
+
expect(restored.get("approval-1")).toEqual({
|
|
1584
|
+
approvalId: "approval-1",
|
|
1585
|
+
approved: true,
|
|
1586
|
+
answers,
|
|
1587
|
+
}),
|
|
1588
|
+
);
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1386
1591
|
it("restores stored tool artifacts without returning metadata to the chat", async () => {
|
|
1387
1592
|
const toolArtifacts = new Map<string, unknown>();
|
|
1388
1593
|
const onSetMessages = vi.fn();
|