@assistant-ui/react-ai-sdk 1.3.7 → 1.3.9
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/ui/getVercelAIMessages.d.ts.map +1 -1
- package/dist/ui/getVercelAIMessages.js.map +1 -1
- package/dist/ui/use-chat/AssistantChatTransport.d.ts.map +1 -1
- package/dist/ui/use-chat/AssistantChatTransport.js.map +1 -1
- package/dist/ui/use-chat/useAISDKRuntime.d.ts.map +1 -1
- package/dist/ui/use-chat/useAISDKRuntime.js +22 -10
- package/dist/ui/use-chat/useAISDKRuntime.js.map +1 -1
- package/dist/ui/use-chat/useChatRuntime.d.ts.map +1 -1
- package/dist/ui/use-chat/useChatRuntime.js.map +1 -1
- package/dist/ui/use-chat/useExternalHistory.d.ts.map +1 -1
- package/dist/ui/use-chat/useExternalHistory.js +137 -24
- package/dist/ui/use-chat/useExternalHistory.js.map +1 -1
- package/dist/ui/use-chat/useStreamingTiming.d.ts +11 -0
- package/dist/ui/use-chat/useStreamingTiming.d.ts.map +1 -0
- package/dist/ui/use-chat/useStreamingTiming.js +84 -0
- package/dist/ui/use-chat/useStreamingTiming.js.map +1 -0
- package/dist/ui/utils/convertMessage.d.ts +4 -4
- package/dist/ui/utils/convertMessage.d.ts.map +1 -1
- package/dist/ui/utils/convertMessage.js +84 -75
- package/dist/ui/utils/convertMessage.js.map +1 -1
- package/dist/ui/utils/sliceMessagesUntil.d.ts.map +1 -1
- package/dist/ui/utils/sliceMessagesUntil.js.map +1 -1
- package/dist/ui/utils/vercelAttachmentAdapter.js +1 -1
- package/dist/ui/utils/vercelAttachmentAdapter.js.map +1 -1
- package/package.json +14 -8
- package/src/ui/use-chat/useAISDKRuntime.test.ts +225 -0
- package/src/ui/use-chat/{useAISDKRuntime.tsx → useAISDKRuntime.ts} +34 -10
- package/src/ui/use-chat/useExternalHistory.ts +283 -0
- package/src/ui/use-chat/useStreamingTiming.ts +102 -0
- package/src/ui/utils/convertMessage.test.ts +125 -0
- package/src/ui/utils/convertMessage.ts +98 -82
- package/src/ui/utils/vercelAttachmentAdapter.ts +1 -1
- package/src/ui/use-chat/useExternalHistory.tsx +0 -134
- /package/src/ui/{getVercelAIMessages.tsx → getVercelAIMessages.ts} +0 -0
- /package/src/ui/use-chat/{AssistantChatTransport.tsx → AssistantChatTransport.ts} +0 -0
- /package/src/ui/use-chat/{useChatRuntime.tsx → useChatRuntime.ts} +0 -0
- /package/src/ui/utils/{sliceMessagesUntil.tsx → sliceMessagesUntil.ts} +0 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AssistantRuntime,
|
|
5
|
+
ThreadHistoryAdapter,
|
|
6
|
+
ThreadMessage,
|
|
7
|
+
MessageFormatAdapter,
|
|
8
|
+
getExternalStoreMessages,
|
|
9
|
+
MessageFormatRepository,
|
|
10
|
+
ExportedMessageRepository,
|
|
11
|
+
INTERNAL,
|
|
12
|
+
useAui,
|
|
13
|
+
} from "@assistant-ui/react";
|
|
14
|
+
import { useRef, useEffect, useState, RefObject, useCallback } from "react";
|
|
15
|
+
|
|
16
|
+
const { MessageRepository } = INTERNAL;
|
|
17
|
+
|
|
18
|
+
export const toExportedMessageRepository = <TMessage>(
|
|
19
|
+
toThreadMessages: (messages: TMessage[]) => ThreadMessage[],
|
|
20
|
+
messages: MessageFormatRepository<TMessage>,
|
|
21
|
+
): ExportedMessageRepository => {
|
|
22
|
+
return {
|
|
23
|
+
headId: messages.headId!,
|
|
24
|
+
messages: messages.messages.map((m) => {
|
|
25
|
+
const message = toThreadMessages([m.message])[0]!;
|
|
26
|
+
return {
|
|
27
|
+
...m,
|
|
28
|
+
message,
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const useExternalHistory = <TMessage>(
|
|
35
|
+
runtimeRef: RefObject<AssistantRuntime>,
|
|
36
|
+
historyAdapter: ThreadHistoryAdapter | undefined,
|
|
37
|
+
toThreadMessages: (messages: TMessage[]) => ThreadMessage[],
|
|
38
|
+
storageFormatAdapter: MessageFormatAdapter<TMessage, any>,
|
|
39
|
+
onSetMessages: (messages: TMessage[]) => void,
|
|
40
|
+
) => {
|
|
41
|
+
const loadedRef = useRef(false);
|
|
42
|
+
|
|
43
|
+
const aui = useAui();
|
|
44
|
+
const optionalThreadListItem = useCallback(
|
|
45
|
+
() => (aui.threadListItem.source ? aui.threadListItem() : null),
|
|
46
|
+
[aui],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
50
|
+
|
|
51
|
+
const historyIds = useRef(new Set<string>());
|
|
52
|
+
|
|
53
|
+
const onSetMessagesRef = useRef(onSetMessages);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
onSetMessagesRef.current = onSetMessages;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!historyAdapter || loadedRef.current) return;
|
|
60
|
+
|
|
61
|
+
const loadHistory = async () => {
|
|
62
|
+
setIsLoading(true);
|
|
63
|
+
try {
|
|
64
|
+
const repo = await historyAdapter
|
|
65
|
+
.withFormat?.(storageFormatAdapter)
|
|
66
|
+
.load();
|
|
67
|
+
if (repo && repo.messages.length > 0) {
|
|
68
|
+
const converted = toExportedMessageRepository(toThreadMessages, repo);
|
|
69
|
+
runtimeRef.current.thread.import(converted);
|
|
70
|
+
|
|
71
|
+
const tempRepo = new MessageRepository();
|
|
72
|
+
tempRepo.import(converted);
|
|
73
|
+
const messages = tempRepo.getMessages();
|
|
74
|
+
|
|
75
|
+
onSetMessagesRef.current(
|
|
76
|
+
messages.map(getExternalStoreMessages<TMessage>).flat(),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
historyIds.current = new Set(
|
|
80
|
+
converted.messages.map((m) => m.message.id),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error("Failed to load message history:", error);
|
|
85
|
+
} finally {
|
|
86
|
+
setIsLoading(false);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
loadedRef.current = true;
|
|
91
|
+
|
|
92
|
+
if (!optionalThreadListItem()?.getState().remoteId) {
|
|
93
|
+
setIsLoading(false);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
loadHistory();
|
|
98
|
+
}, [
|
|
99
|
+
historyAdapter,
|
|
100
|
+
storageFormatAdapter,
|
|
101
|
+
toThreadMessages,
|
|
102
|
+
runtimeRef,
|
|
103
|
+
optionalThreadListItem,
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const runStartRef = useRef<number | null>(null);
|
|
107
|
+
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
108
|
+
const stepBoundariesRef = useRef<number[]>([]);
|
|
109
|
+
const wasRunningRef = useRef(false);
|
|
110
|
+
const toolCallCountRef = useRef(0);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const unsubscribe = runtimeRef.current.thread.subscribe(() => {
|
|
114
|
+
const { isRunning } = runtimeRef.current.thread.getState();
|
|
115
|
+
const wasRunning = wasRunningRef.current;
|
|
116
|
+
wasRunningRef.current = isRunning;
|
|
117
|
+
|
|
118
|
+
// Track step boundaries by content changes (more reliable than isRunning)
|
|
119
|
+
if (runStartRef.current != null) {
|
|
120
|
+
const lastMsg = runtimeRef.current.thread.getState().messages.at(-1);
|
|
121
|
+
if (lastMsg?.role === "assistant") {
|
|
122
|
+
const currentToolCallCount = lastMsg.content.filter(
|
|
123
|
+
(p) => p.type === "tool-call",
|
|
124
|
+
).length;
|
|
125
|
+
while (toolCallCountRef.current < currentToolCallCount) {
|
|
126
|
+
stepBoundariesRef.current.push(Date.now() - runStartRef.current);
|
|
127
|
+
toolCallCountRef.current++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isRunning) {
|
|
133
|
+
if (runStartRef.current == null) {
|
|
134
|
+
runStartRef.current = Date.now();
|
|
135
|
+
stepBoundariesRef.current = [];
|
|
136
|
+
toolCallCountRef.current = 0;
|
|
137
|
+
}
|
|
138
|
+
// Cancel any pending persist — isRunning went back to true
|
|
139
|
+
if (persistTimerRef.current) {
|
|
140
|
+
clearTimeout(persistTimerRef.current);
|
|
141
|
+
persistTimerRef.current = null;
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Only act on the true→false transition
|
|
147
|
+
if (!wasRunning) return;
|
|
148
|
+
|
|
149
|
+
// Record step boundary offset (synchronous for accuracy)
|
|
150
|
+
if (runStartRef.current != null) {
|
|
151
|
+
stepBoundariesRef.current.push(Date.now() - runStartRef.current);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Debounce: wait one macrotask so agentic step flickers are absorbed
|
|
155
|
+
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
|
156
|
+
persistTimerRef.current = setTimeout(async () => {
|
|
157
|
+
persistTimerRef.current = null;
|
|
158
|
+
|
|
159
|
+
// Re-read latest state — may have changed since the timeout was scheduled
|
|
160
|
+
const latest = runtimeRef.current.thread.getState();
|
|
161
|
+
if (latest.isRunning) return; // was just a flicker
|
|
162
|
+
|
|
163
|
+
// Derive durationMs from the last boundary (covers all steps)
|
|
164
|
+
const boundaries = stepBoundariesRef.current;
|
|
165
|
+
const durationMs =
|
|
166
|
+
boundaries.length > 0 ? boundaries.at(-1) : undefined;
|
|
167
|
+
|
|
168
|
+
// Fallback: if only 1 boundary but message has multiple steps, distribute evenly
|
|
169
|
+
if (boundaries.length === 1 && durationMs != null) {
|
|
170
|
+
const lastAssistant = latest.messages.findLast(
|
|
171
|
+
(m) => m.role === "assistant",
|
|
172
|
+
);
|
|
173
|
+
if (lastAssistant) {
|
|
174
|
+
const tcCount = lastAssistant.content.filter(
|
|
175
|
+
(p) => p.type === "tool-call",
|
|
176
|
+
).length;
|
|
177
|
+
if (tcCount > 0) {
|
|
178
|
+
const totalSteps = tcCount + 1;
|
|
179
|
+
const stepDur = durationMs / totalSteps;
|
|
180
|
+
boundaries.length = 0;
|
|
181
|
+
for (let i = 0; i < totalSteps; i++) {
|
|
182
|
+
boundaries.push(Math.round((i + 1) * stepDur));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Build per-step timestamps when there are multiple steps
|
|
189
|
+
const stepTimestamps =
|
|
190
|
+
boundaries.length > 1
|
|
191
|
+
? boundaries.map((endMs, i) => ({
|
|
192
|
+
start_ms: i === 0 ? 0 : boundaries[i - 1]!,
|
|
193
|
+
end_ms: endMs,
|
|
194
|
+
}))
|
|
195
|
+
: undefined;
|
|
196
|
+
|
|
197
|
+
runStartRef.current = null;
|
|
198
|
+
stepBoundariesRef.current = [];
|
|
199
|
+
|
|
200
|
+
const telemetryOptions = {
|
|
201
|
+
...(durationMs != null ? { durationMs } : undefined),
|
|
202
|
+
...(stepTimestamps != null ? { stepTimestamps } : undefined),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const { messages } = latest;
|
|
206
|
+
let lastInnerMessageId: string | null = null;
|
|
207
|
+
|
|
208
|
+
const getLastInnerId = (msgs: TMessage[]): string | null =>
|
|
209
|
+
msgs.length > 0 ? storageFormatAdapter.getId(msgs.at(-1)!) : null;
|
|
210
|
+
|
|
211
|
+
const toBatchItems = (msgs: TMessage[]) =>
|
|
212
|
+
msgs.map((msg, idx) => ({
|
|
213
|
+
parentId:
|
|
214
|
+
idx === 0
|
|
215
|
+
? lastInnerMessageId
|
|
216
|
+
: storageFormatAdapter.getId(msgs[idx - 1]!),
|
|
217
|
+
message: msg,
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
for (const message of messages) {
|
|
221
|
+
const innerMessages = getExternalStoreMessages<TMessage>(message);
|
|
222
|
+
|
|
223
|
+
const isReady =
|
|
224
|
+
message.status === undefined ||
|
|
225
|
+
message.status.type === "complete" ||
|
|
226
|
+
message.status.type === "incomplete";
|
|
227
|
+
|
|
228
|
+
if (!isReady) {
|
|
229
|
+
lastInnerMessageId =
|
|
230
|
+
getLastInnerId(innerMessages) ?? lastInnerMessageId;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (historyIds.current.has(message.id)) {
|
|
235
|
+
if (durationMs !== undefined) {
|
|
236
|
+
const formatAdapter =
|
|
237
|
+
historyAdapter?.withFormat?.(storageFormatAdapter);
|
|
238
|
+
let parentId = lastInnerMessageId;
|
|
239
|
+
for (const innerMessage of innerMessages) {
|
|
240
|
+
try {
|
|
241
|
+
await formatAdapter?.update?.(
|
|
242
|
+
{ parentId, message: innerMessage },
|
|
243
|
+
storageFormatAdapter.getId(innerMessage),
|
|
244
|
+
);
|
|
245
|
+
} catch {
|
|
246
|
+
// ignore update failures to avoid breaking the message processing loop
|
|
247
|
+
}
|
|
248
|
+
parentId = storageFormatAdapter.getId(innerMessage);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
lastInnerMessageId =
|
|
252
|
+
getLastInnerId(innerMessages) ?? lastInnerMessageId;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
historyIds.current.add(message.id);
|
|
256
|
+
|
|
257
|
+
const formatAdapter =
|
|
258
|
+
historyAdapter?.withFormat?.(storageFormatAdapter);
|
|
259
|
+
|
|
260
|
+
const batchItems = toBatchItems(innerMessages);
|
|
261
|
+
for (const item of batchItems) {
|
|
262
|
+
await formatAdapter?.append(item);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
lastInnerMessageId =
|
|
266
|
+
getLastInnerId(innerMessages) ?? lastInnerMessageId;
|
|
267
|
+
|
|
268
|
+
formatAdapter?.reportTelemetry?.(batchItems, telemetryOptions);
|
|
269
|
+
}
|
|
270
|
+
}, 0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return () => {
|
|
274
|
+
unsubscribe();
|
|
275
|
+
if (persistTimerRef.current) {
|
|
276
|
+
clearTimeout(persistTimerRef.current);
|
|
277
|
+
persistTimerRef.current = null;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}, [historyAdapter, storageFormatAdapter, runtimeRef]);
|
|
281
|
+
|
|
282
|
+
return isLoading;
|
|
283
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { UIMessage } from "@ai-sdk/react";
|
|
5
|
+
import { isToolUIPart } from "ai";
|
|
6
|
+
import type { MessageTiming } from "@assistant-ui/react";
|
|
7
|
+
|
|
8
|
+
type TrackingState = {
|
|
9
|
+
messageId: string;
|
|
10
|
+
startTime: number;
|
|
11
|
+
firstTokenTime?: number;
|
|
12
|
+
lastContentLength: number;
|
|
13
|
+
totalChunks: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function getTextLength(message: UIMessage | undefined): number {
|
|
17
|
+
if (!message?.parts) return 0;
|
|
18
|
+
let len = 0;
|
|
19
|
+
for (const part of message.parts) {
|
|
20
|
+
if (part.type === "text") len += part.text.length;
|
|
21
|
+
}
|
|
22
|
+
return len;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getToolCallCount(message: UIMessage | undefined): number {
|
|
26
|
+
if (!message?.parts) return 0;
|
|
27
|
+
let count = 0;
|
|
28
|
+
for (const part of message.parts) {
|
|
29
|
+
if (isToolUIPart(part)) count++;
|
|
30
|
+
}
|
|
31
|
+
return count;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Tracks streaming timing for AI SDK messages client-side.
|
|
36
|
+
*
|
|
37
|
+
* Observes `isRunning` transitions and content changes to estimate
|
|
38
|
+
* timing metrics (TTFT, duration, tok/s). Timing is finalized when
|
|
39
|
+
* streaming ends and stored per message ID.
|
|
40
|
+
*/
|
|
41
|
+
export const useStreamingTiming = (
|
|
42
|
+
messages: UIMessage[],
|
|
43
|
+
isRunning: boolean,
|
|
44
|
+
): Record<string, MessageTiming> => {
|
|
45
|
+
const [timings, setTimings] = useState<Record<string, MessageTiming>>({});
|
|
46
|
+
const trackRef = useRef<TrackingState | null>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const lastAssistant = messages.findLast((m) => m.role === "assistant");
|
|
50
|
+
|
|
51
|
+
if (isRunning && lastAssistant) {
|
|
52
|
+
// Start tracking if not already or if message changed
|
|
53
|
+
if (
|
|
54
|
+
!trackRef.current ||
|
|
55
|
+
trackRef.current.messageId !== lastAssistant.id
|
|
56
|
+
) {
|
|
57
|
+
trackRef.current = {
|
|
58
|
+
messageId: lastAssistant.id,
|
|
59
|
+
startTime: Date.now(),
|
|
60
|
+
lastContentLength: 0,
|
|
61
|
+
totalChunks: 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Track content changes
|
|
66
|
+
const t = trackRef.current;
|
|
67
|
+
const len = getTextLength(lastAssistant);
|
|
68
|
+
if (len > t.lastContentLength) {
|
|
69
|
+
if (t.firstTokenTime === undefined) {
|
|
70
|
+
t.firstTokenTime = Date.now() - t.startTime;
|
|
71
|
+
}
|
|
72
|
+
t.totalChunks++;
|
|
73
|
+
t.lastContentLength = len;
|
|
74
|
+
}
|
|
75
|
+
} else if (!isRunning && trackRef.current) {
|
|
76
|
+
// Streaming ended — finalize timing
|
|
77
|
+
const t = trackRef.current;
|
|
78
|
+
const totalStreamTime = Date.now() - t.startTime;
|
|
79
|
+
const tokenCount = Math.ceil(t.lastContentLength / 4);
|
|
80
|
+
const toolCallCount = getToolCallCount(lastAssistant);
|
|
81
|
+
|
|
82
|
+
const timing: MessageTiming = {
|
|
83
|
+
streamStartTime: t.startTime,
|
|
84
|
+
totalStreamTime,
|
|
85
|
+
totalChunks: t.totalChunks,
|
|
86
|
+
toolCallCount,
|
|
87
|
+
...(t.firstTokenTime !== undefined && {
|
|
88
|
+
firstTokenTime: t.firstTokenTime,
|
|
89
|
+
}),
|
|
90
|
+
...(tokenCount > 0 && { tokenCount }),
|
|
91
|
+
...(totalStreamTime > 0 &&
|
|
92
|
+
tokenCount > 0 && {
|
|
93
|
+
tokensPerSecond: tokenCount / (totalStreamTime / 1000),
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
setTimings((prev) => ({ ...prev, [t.messageId]: timing }));
|
|
97
|
+
trackRef.current = null;
|
|
98
|
+
}
|
|
99
|
+
}, [messages, isRunning]);
|
|
100
|
+
|
|
101
|
+
return timings;
|
|
102
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { AISDKMessageConverter } from "./convertMessage";
|
|
3
|
+
|
|
4
|
+
describe("AISDKMessageConverter", () => {
|
|
5
|
+
it("converts user files into attachments and keeps text content", () => {
|
|
6
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
7
|
+
{
|
|
8
|
+
id: "u1",
|
|
9
|
+
role: "user",
|
|
10
|
+
parts: [
|
|
11
|
+
{ type: "text", text: "hello" },
|
|
12
|
+
{
|
|
13
|
+
type: "file",
|
|
14
|
+
mediaType: "image/png",
|
|
15
|
+
url: "https://cdn/img.png",
|
|
16
|
+
filename: "img.png",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
type: "file",
|
|
20
|
+
mediaType: "application/pdf",
|
|
21
|
+
url: "https://cdn/file.pdf",
|
|
22
|
+
filename: "file.pdf",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
} as any,
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
expect(converted).toHaveLength(1);
|
|
29
|
+
expect(converted[0]?.role).toBe("user");
|
|
30
|
+
expect(converted[0]?.content).toHaveLength(1);
|
|
31
|
+
expect(converted[0]?.content[0]).toMatchObject({
|
|
32
|
+
type: "text",
|
|
33
|
+
text: "hello",
|
|
34
|
+
});
|
|
35
|
+
expect(converted[0]?.attachments).toHaveLength(2);
|
|
36
|
+
expect(converted[0]?.attachments?.[0]?.type).toBe("image");
|
|
37
|
+
expect(converted[0]?.attachments?.[1]?.type).toBe("file");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("deduplicates tool calls by toolCallId and maps interrupt states", () => {
|
|
41
|
+
const converted = AISDKMessageConverter.toThreadMessages(
|
|
42
|
+
[
|
|
43
|
+
{
|
|
44
|
+
id: "a1",
|
|
45
|
+
role: "assistant",
|
|
46
|
+
parts: [
|
|
47
|
+
{
|
|
48
|
+
type: "tool-weather",
|
|
49
|
+
toolCallId: "tc-1",
|
|
50
|
+
state: "output-available",
|
|
51
|
+
input: { city: "NYC" },
|
|
52
|
+
output: { temp: 72 },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: "tool-weather",
|
|
56
|
+
toolCallId: "tc-1",
|
|
57
|
+
state: "output-available",
|
|
58
|
+
input: { city: "NYC" },
|
|
59
|
+
output: { temp: 73 },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: "tool-approve",
|
|
63
|
+
toolCallId: "tc-2",
|
|
64
|
+
state: "approval-requested",
|
|
65
|
+
input: { action: "deploy" },
|
|
66
|
+
approval: { reason: "need human review" },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
type: "tool-human",
|
|
70
|
+
toolCallId: "tc-3",
|
|
71
|
+
state: "input-available",
|
|
72
|
+
input: { task: "confirm" },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
} as any,
|
|
76
|
+
],
|
|
77
|
+
false,
|
|
78
|
+
{
|
|
79
|
+
toolStatuses: {
|
|
80
|
+
"tc-3": {
|
|
81
|
+
type: "interrupt",
|
|
82
|
+
payload: { type: "human", payload: { kind: "human" } },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const toolCalls = converted[0]?.content.filter(
|
|
89
|
+
(part): part is any => part.type === "tool-call",
|
|
90
|
+
);
|
|
91
|
+
expect(toolCalls).toHaveLength(3);
|
|
92
|
+
|
|
93
|
+
expect(toolCalls?.filter((p) => p.toolCallId === "tc-1")).toHaveLength(1);
|
|
94
|
+
expect(toolCalls?.find((p) => p.toolCallId === "tc-2")?.status).toEqual({
|
|
95
|
+
type: "requires-action",
|
|
96
|
+
reason: "interrupt",
|
|
97
|
+
});
|
|
98
|
+
expect(toolCalls?.find((p) => p.toolCallId === "tc-3")?.interrupt).toEqual({
|
|
99
|
+
type: "human",
|
|
100
|
+
payload: { kind: "human" },
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("strips closing delimiters from streaming tool argsText", () => {
|
|
105
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
106
|
+
{
|
|
107
|
+
id: "a1",
|
|
108
|
+
role: "assistant",
|
|
109
|
+
parts: [
|
|
110
|
+
{
|
|
111
|
+
type: "tool-weather",
|
|
112
|
+
toolCallId: "tc-1",
|
|
113
|
+
state: "input-streaming",
|
|
114
|
+
input: { city: "NYC" },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
} as any,
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const toolCall = converted[0]?.content.find(
|
|
121
|
+
(part): part is any => part.type === "tool-call",
|
|
122
|
+
);
|
|
123
|
+
expect(toolCall?.argsText).toBe('{"city":"NYC');
|
|
124
|
+
});
|
|
125
|
+
});
|