@assistant-ui/ai-sdk 0.0.3 → 0.0.5
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/README.md +1 -1
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +31 -6
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKThreads.d.ts +10 -8
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +34 -27
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +2 -0
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +171 -68
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts.map +1 -1
- package/dist/runtime/useChatRuntime.js +3 -2
- package/dist/runtime/useChatRuntime.js.map +1 -1
- package/dist/runtime/useChatThread.d.ts +7 -0
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +2 -1
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +42 -47
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.js +1 -1
- package/dist/tools/generativeTools.js +7 -4
- package/dist/tools/generativeTools.js.map +1 -1
- package/dist/usage.d.ts +9 -2
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +13 -7
- package/dist/usage.js.map +1 -1
- package/package.json +11 -11
- package/src/converters/convertMessage.test.ts +124 -0
- package/src/converters/convertMessage.ts +64 -20
- package/src/runtime/AISDKChat.integration.test.tsx +48 -27
- package/src/runtime/AISDKThreads.cloud.test.ts +11 -1
- package/src/runtime/AISDKThreads.test.ts +102 -0
- package/src/runtime/AISDKThreads.ts +16 -9
- package/src/runtime/__tests__/controlled-transport.ts +21 -0
- package/src/runtime/useAISDKRuntime.test.ts +288 -3
- package/src/runtime/useAISDKRuntime.ts +90 -5
- package/src/runtime/useChatRuntime.integration.test.tsx +183 -4
- package/src/runtime/useChatRuntime.ts +1 -0
- package/src/runtime/useChatThread.ts +11 -0
- package/src/runtime/useExternalHistory.test.ts +64 -0
- package/src/runtime/useExternalHistory.ts +43 -56
- package/src/tools/generativeTools.test.ts +79 -0
- package/src/tools/generativeTools.ts +7 -8
- package/src/usage.test.ts +26 -8
- package/src/usage.ts +12 -9
package/dist/usage.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useAuiState } from "@assistant-ui/store";
|
|
2
1
|
import { c } from "@assistant-ui/tap/react-shim/compiler-runtime";
|
|
2
|
+
import "@assistant-ui/tap/react-shim";
|
|
3
|
+
import { useAuiState } from "@assistant-ui/store";
|
|
3
4
|
//#region src/usage.ts
|
|
4
5
|
const USAGE_KEYS = [
|
|
5
6
|
"inputTokens",
|
|
@@ -93,13 +94,10 @@ function getThreadMessageTokenUsage(message) {
|
|
|
93
94
|
if (!metadata) return void 0;
|
|
94
95
|
const topLevelUsage = normalizeUsage(metadata.usage);
|
|
95
96
|
if (topLevelUsage) return withComputedTotal(topLevelUsage);
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
97
|
+
const customUsage = normalizeUsage(asRecord(metadata.custom)?.usage);
|
|
98
|
+
if (customUsage) return withComputedTotal(customUsage);
|
|
98
99
|
return usageFromSteps(metadata.steps);
|
|
99
100
|
}
|
|
100
|
-
function getLatestThreadTokenUsage(messages) {
|
|
101
|
-
return getThreadMessageTokenUsage(findLatestMessageWithUsage(messages));
|
|
102
|
-
}
|
|
103
101
|
function findLatestMessageWithUsage(messages) {
|
|
104
102
|
if (!messages) return void 0;
|
|
105
103
|
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
|
@@ -107,6 +105,14 @@ function findLatestMessageWithUsage(messages) {
|
|
|
107
105
|
if (getThreadMessageTokenUsage(message)) return message;
|
|
108
106
|
}
|
|
109
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Reads token usage from the newest assistant message that reports any.
|
|
110
|
+
*
|
|
111
|
+
* A route attaches usage through the AI SDK's `messageMetadata` option. Because
|
|
112
|
+
* a thread message carries a fixed metadata shape, the converter moves every
|
|
113
|
+
* other key the route returns into `metadata.custom`, which is where this hook
|
|
114
|
+
* looks.
|
|
115
|
+
*/
|
|
110
116
|
function useThreadTokenUsage() {
|
|
111
117
|
const $ = c(2);
|
|
112
118
|
const msg = useAuiState(_temp);
|
|
@@ -122,6 +128,6 @@ function _temp(s) {
|
|
|
122
128
|
return findLatestMessageWithUsage(s.thread.messages);
|
|
123
129
|
}
|
|
124
130
|
//#endregion
|
|
125
|
-
export {
|
|
131
|
+
export { getThreadMessageTokenUsage, useThreadTokenUsage };
|
|
126
132
|
|
|
127
133
|
//# sourceMappingURL=usage.js.map
|
package/dist/usage.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"usage.js","names":["useAuiState","ThreadTokenUsage","totalTokens","inputTokens","outputTokens","reasoningTokens","cachedInputTokens","TokenUsageExtractableMessage","role","metadata","UsageRecord","Record","USAGE_KEYS","const","asRecord","value","Array","isArray","undefined","asPositiveTokenCount","Number","isFinite","computeTotalTokens","usage","normalizeUsage","record","result","hasFields","key","count","outputTokenDetails","inputTokenDetails","cacheReadTokens","withComputedTotal","usageFromSteps","steps","sums","present","stepsWithUsage","stepsWithComputableTotal","step","stepTotal","getThreadMessageTokenUsage","message","topLevelUsage","
|
|
1
|
+
{"version":3,"file":"usage.js","names":["useMemo","useAuiState","ThreadTokenUsage","totalTokens","inputTokens","outputTokens","reasoningTokens","cachedInputTokens","TokenUsageExtractableMessage","role","metadata","UsageRecord","Record","USAGE_KEYS","const","asRecord","value","Array","isArray","undefined","asPositiveTokenCount","Number","isFinite","computeTotalTokens","usage","normalizeUsage","record","result","hasFields","key","count","outputTokenDetails","inputTokenDetails","cacheReadTokens","withComputedTotal","usageFromSteps","steps","sums","present","stepsWithUsage","stepsWithComputableTotal","step","stepTotal","getThreadMessageTokenUsage","message","topLevelUsage","customUsage","custom","findLatestMessageWithUsage","messages","idx","length","useThreadTokenUsage","$","_c","msg","_temp","t0","s","thread"],"sources":["../src/usage.ts"],"sourcesContent":["/// <reference types=\"@assistant-ui/core/react\" />\nimport { useMemo } from \"react\";\nimport { useAuiState } from \"@assistant-ui/store\";\n\nexport type ThreadTokenUsage = {\n totalTokens?: number;\n inputTokens?: number;\n outputTokens?: number;\n reasoningTokens?: number;\n cachedInputTokens?: number;\n};\n\nexport interface TokenUsageExtractableMessage {\n role?: string;\n metadata?: unknown;\n}\n\ntype UsageRecord = Record<string, unknown>;\n\nconst USAGE_KEYS = [\n \"inputTokens\",\n \"outputTokens\",\n \"reasoningTokens\",\n \"cachedInputTokens\",\n \"totalTokens\",\n] as const satisfies (keyof ThreadTokenUsage)[];\n\nfunction asRecord(value: unknown): UsageRecord | undefined {\n if (!value || typeof value !== \"object\" || Array.isArray(value))\n return undefined;\n return value as UsageRecord;\n}\n\nfunction asPositiveTokenCount(value: unknown): number | undefined {\n if (typeof value !== \"number\" || !Number.isFinite(value) || value < 0) {\n return undefined;\n }\n return value;\n}\n\nfunction computeTotalTokens(usage: ThreadTokenUsage): number | undefined {\n if (usage.totalTokens !== undefined) return usage.totalTokens;\n if (usage.inputTokens !== undefined && usage.outputTokens !== undefined) {\n return usage.inputTokens + usage.outputTokens;\n }\n return undefined;\n}\n\nfunction normalizeUsage(value: unknown): ThreadTokenUsage | undefined {\n const record = asRecord(value);\n if (!record) return undefined;\n\n const result: ThreadTokenUsage = {};\n let hasFields = false;\n for (const key of USAGE_KEYS) {\n const count = asPositiveTokenCount(record[key]);\n if (count !== undefined) {\n result[key] = count;\n hasFields = true;\n }\n }\n // AI SDK v7 moved these under token detail objects; v6 kept them top-level.\n if (result.reasoningTokens === undefined) {\n const count = asPositiveTokenCount(\n asRecord(record.outputTokenDetails)?.reasoningTokens,\n );\n if (count !== undefined) {\n result.reasoningTokens = count;\n hasFields = true;\n }\n }\n if (result.cachedInputTokens === undefined) {\n const count = asPositiveTokenCount(\n asRecord(record.inputTokenDetails)?.cacheReadTokens,\n );\n if (count !== undefined) {\n result.cachedInputTokens = count;\n hasFields = true;\n }\n }\n return hasFields ? result : undefined;\n}\n\nfunction withComputedTotal(\n usage: ThreadTokenUsage,\n): ThreadTokenUsage | undefined {\n const totalTokens = computeTotalTokens(usage);\n return { ...usage, ...(totalTokens !== undefined && { totalTokens }) };\n}\n\nfunction usageFromSteps(value: unknown): ThreadTokenUsage | undefined {\n const steps = Array.isArray(value) ? value : [];\n\n const sums: Record<string, number> = {};\n const present: Record<string, boolean> = {};\n let stepsWithUsage = 0;\n let stepsWithComputableTotal = 0;\n\n for (const step of steps) {\n const usage = normalizeUsage(asRecord(step)?.usage);\n if (!usage) continue;\n stepsWithUsage++;\n\n const stepTotal = computeTotalTokens(usage);\n if (stepTotal !== undefined) {\n sums.totalTokens = (sums.totalTokens ?? 0) + stepTotal;\n stepsWithComputableTotal++;\n }\n\n for (const key of USAGE_KEYS) {\n if (key === \"totalTokens\") continue;\n if (usage[key] !== undefined) {\n sums[key] = (sums[key] ?? 0) + usage[key];\n present[key] = true;\n }\n }\n }\n\n if (stepsWithUsage === 0) return undefined;\n\n const result: ThreadTokenUsage = {};\n if (stepsWithComputableTotal === stepsWithUsage) {\n result.totalTokens = sums.totalTokens!;\n }\n for (const key of USAGE_KEYS) {\n if (key === \"totalTokens\") continue;\n if (present[key]) {\n result[key] = sums[key]!;\n }\n }\n return result;\n}\n\nexport function getThreadMessageTokenUsage(\n message: TokenUsageExtractableMessage | undefined,\n): ThreadTokenUsage | undefined {\n if (!message || message.role !== \"assistant\") return undefined;\n\n const metadata = asRecord(message.metadata);\n if (!metadata) return undefined;\n\n const topLevelUsage = normalizeUsage(metadata.usage);\n if (topLevelUsage) return withComputedTotal(topLevelUsage);\n\n const customUsage = normalizeUsage(asRecord(metadata.custom)?.usage);\n if (customUsage) return withComputedTotal(customUsage);\n\n return usageFromSteps(metadata.steps);\n}\n\nfunction findLatestMessageWithUsage(\n messages: readonly TokenUsageExtractableMessage[] | undefined,\n): TokenUsageExtractableMessage | undefined {\n if (!messages) return undefined;\n\n for (let idx = messages.length - 1; idx >= 0; idx -= 1) {\n const message = messages[idx];\n if (getThreadMessageTokenUsage(message)) {\n return message;\n }\n }\n\n return undefined;\n}\n\n/**\n * Reads token usage from the newest assistant message that reports any.\n *\n * A route attaches usage through the AI SDK's `messageMetadata` option. Because\n * a thread message carries a fixed metadata shape, the converter moves every\n * other key the route returns into `metadata.custom`, which is where this hook\n * looks.\n */\nexport function useThreadTokenUsage(): ThreadTokenUsage | undefined {\n const msg = useAuiState((s) => findLatestMessageWithUsage(s.thread.messages));\n return useMemo(() => getThreadMessageTokenUsage(msg), [msg]);\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/ai-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Vercel AI SDK adapter for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-sdk",
|
|
@@ -46,14 +46,14 @@
|
|
|
46
46
|
],
|
|
47
47
|
"sideEffects": false,
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@ai-sdk/mcp": "^2.0.
|
|
50
|
-
"@ai-sdk/react": "^4.0.
|
|
51
|
-
"@assistant-ui/core": "^0.3.
|
|
52
|
-
"@assistant-ui/store": "^0.3.
|
|
53
|
-
"@assistant-ui/tap": "^0.9.
|
|
54
|
-
"ai": "^7.0.
|
|
49
|
+
"@ai-sdk/mcp": "^2.0.45",
|
|
50
|
+
"@ai-sdk/react": "^4.0.96",
|
|
51
|
+
"@assistant-ui/core": "^0.3.18",
|
|
52
|
+
"@assistant-ui/store": "^0.3.13",
|
|
53
|
+
"@assistant-ui/tap": "^0.9.17",
|
|
54
|
+
"ai": "^7.0.93",
|
|
55
55
|
"assistant-cloud": "*",
|
|
56
|
-
"assistant-stream": "^0.3.
|
|
56
|
+
"assistant-stream": "^0.3.42"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"@types/react": "*",
|
|
@@ -68,11 +68,11 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
72
|
-
"@testing-library/react": "^16.3.
|
|
71
|
+
"@assistant-ui/x-buildutils": "0.0.26",
|
|
72
|
+
"@testing-library/react": "^16.3.3",
|
|
73
73
|
"@types/json-schema": "^7.0.15",
|
|
74
74
|
"@types/react": "^19.2.18",
|
|
75
|
-
"@types/react-dom": "^19.2.
|
|
75
|
+
"@types/react-dom": "^19.2.7",
|
|
76
76
|
"jsdom": "^30.0.1",
|
|
77
77
|
"react": "^19.2.8",
|
|
78
78
|
"vitest": "^4.1.11"
|
|
@@ -26,6 +26,28 @@ describe("AISDKMessageConverter", () => {
|
|
|
26
26
|
expect(converted[1]?.metadata.isOptimistic).toBe(true);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it("keeps metadata outside the thread shape reachable under custom", () => {
|
|
30
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
31
|
+
{
|
|
32
|
+
id: "a1",
|
|
33
|
+
role: "assistant",
|
|
34
|
+
parts: [{ type: "text", text: "yo" }],
|
|
35
|
+
metadata: {
|
|
36
|
+
usage: { inputTokens: 40, outputTokens: 2 },
|
|
37
|
+
modelId: "gpt-5.6-luna",
|
|
38
|
+
custom: { source: "route" },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
] as any);
|
|
42
|
+
|
|
43
|
+
expect(converted[0]?.metadata.custom).toEqual({
|
|
44
|
+
usage: { inputTokens: 40, outputTokens: 2 },
|
|
45
|
+
modelId: "gpt-5.6-luna",
|
|
46
|
+
source: "route",
|
|
47
|
+
});
|
|
48
|
+
expect(converted[0]?.metadata).not.toHaveProperty("usage");
|
|
49
|
+
});
|
|
50
|
+
|
|
29
51
|
it("does not flag messages when no optimistic id is provided", () => {
|
|
30
52
|
const converted = AISDKMessageConverter.toThreadMessages([
|
|
31
53
|
{ id: "a1", role: "assistant", parts: [{ type: "text", text: "yo" }] },
|
|
@@ -372,6 +394,108 @@ describe("AISDKMessageConverter", () => {
|
|
|
372
394
|
});
|
|
373
395
|
});
|
|
374
396
|
|
|
397
|
+
it("preserves producer-defined approval fields and gives prompt precedence", () => {
|
|
398
|
+
const descriptor = { scope: "account:deploy" };
|
|
399
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
400
|
+
{
|
|
401
|
+
id: "a1",
|
|
402
|
+
role: "assistant",
|
|
403
|
+
parts: [
|
|
404
|
+
{
|
|
405
|
+
type: "tool-deploy",
|
|
406
|
+
toolCallId: "tc-1",
|
|
407
|
+
state: "approval-responded",
|
|
408
|
+
input: { environment: "production" },
|
|
409
|
+
approval: {
|
|
410
|
+
id: "approval-1",
|
|
411
|
+
approved: true,
|
|
412
|
+
reason: "approved by operator",
|
|
413
|
+
prompt: "Deploy to production?",
|
|
414
|
+
descriptor,
|
|
415
|
+
requestReason: "Production access requires approval",
|
|
416
|
+
signature: "signed-approval",
|
|
417
|
+
futureField: "preserved",
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
} as any,
|
|
422
|
+
]);
|
|
423
|
+
|
|
424
|
+
const toolCall = converted[0]?.content.find(
|
|
425
|
+
(part): part is any => part.type === "tool-call",
|
|
426
|
+
);
|
|
427
|
+
expect(toolCall?.approval).toEqual({
|
|
428
|
+
id: "approval-1",
|
|
429
|
+
approved: true,
|
|
430
|
+
reason: "approved by operator",
|
|
431
|
+
prompt: "Deploy to production?",
|
|
432
|
+
descriptor,
|
|
433
|
+
requestReason: "Production access requires approval",
|
|
434
|
+
signature: "signed-approval",
|
|
435
|
+
futureField: "preserved",
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("drops fields the AI SDK cannot answer and uses requestReason as the prompt", () => {
|
|
440
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
441
|
+
{
|
|
442
|
+
id: "a1",
|
|
443
|
+
role: "assistant",
|
|
444
|
+
parts: [
|
|
445
|
+
{
|
|
446
|
+
type: "tool-deploy",
|
|
447
|
+
toolCallId: "tc-1",
|
|
448
|
+
state: "approval-requested",
|
|
449
|
+
input: {},
|
|
450
|
+
approval: {
|
|
451
|
+
id: "approval-1",
|
|
452
|
+
display: "select",
|
|
453
|
+
allowFreeform: true,
|
|
454
|
+
options: [{ id: "once", kind: "allow-once" }],
|
|
455
|
+
optionId: "once",
|
|
456
|
+
text: "an answer",
|
|
457
|
+
resolution: "cancelled",
|
|
458
|
+
requestReason: "kept",
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
} as any,
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
const toolCall = converted[0]?.content.find(
|
|
466
|
+
(part): part is any => part.type === "tool-call",
|
|
467
|
+
);
|
|
468
|
+
expect(toolCall?.approval).toEqual({
|
|
469
|
+
id: "approval-1",
|
|
470
|
+
prompt: "kept",
|
|
471
|
+
resolution: "cancelled",
|
|
472
|
+
requestReason: "kept",
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("drops a resolution the core contract does not declare", () => {
|
|
477
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
478
|
+
{
|
|
479
|
+
id: "a1",
|
|
480
|
+
role: "assistant",
|
|
481
|
+
parts: [
|
|
482
|
+
{
|
|
483
|
+
type: "tool-deploy",
|
|
484
|
+
toolCallId: "tc-1",
|
|
485
|
+
state: "approval-requested",
|
|
486
|
+
input: {},
|
|
487
|
+
approval: { id: "approval-1", resolution: "whatever" },
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
} as any,
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
const toolCall = converted[0]?.content.find(
|
|
494
|
+
(part): part is any => part.type === "tool-call",
|
|
495
|
+
);
|
|
496
|
+
expect(toolCall?.approval).toEqual({ id: "approval-1" });
|
|
497
|
+
});
|
|
498
|
+
|
|
375
499
|
it("strips closing delimiters from streaming tool argsText", () => {
|
|
376
500
|
const converted = AISDKMessageConverter.toThreadMessages([
|
|
377
501
|
{
|
|
@@ -31,6 +31,30 @@ import {
|
|
|
31
31
|
import { unwrapModelContentEnvelope } from "./modelContentEnvelope";
|
|
32
32
|
|
|
33
33
|
type MessageMetadata = ThreadMessageLike["metadata"];
|
|
34
|
+
|
|
35
|
+
const THREAD_METADATA_KEYS = new Set([
|
|
36
|
+
"unstable_state",
|
|
37
|
+
"unstable_annotations",
|
|
38
|
+
"unstable_data",
|
|
39
|
+
"steps",
|
|
40
|
+
"timing",
|
|
41
|
+
"submittedFeedback",
|
|
42
|
+
"isOptimistic",
|
|
43
|
+
"custom",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const toThreadMetadata = (metadata: unknown): MessageMetadata => {
|
|
47
|
+
if (!metadata || typeof metadata !== "object") return undefined;
|
|
48
|
+
const result: Record<string, unknown> = {};
|
|
49
|
+
const extra: Record<string, unknown> = {};
|
|
50
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
51
|
+
(THREAD_METADATA_KEYS.has(key) ? result : extra)[key] = value;
|
|
52
|
+
}
|
|
53
|
+
if (Object.keys(extra).length > 0) {
|
|
54
|
+
result.custom = { ...extra, ...(result.custom as object | undefined) };
|
|
55
|
+
}
|
|
56
|
+
return result as MessageMetadata;
|
|
57
|
+
};
|
|
34
58
|
export type AISDKMessageConverterMetadata =
|
|
35
59
|
useExternalMessageConverter.Metadata & {
|
|
36
60
|
toolArgsKeyOrderCache?: Map<string, Map<string, string[]>>;
|
|
@@ -130,30 +154,50 @@ function extractMcpAppMetadata(
|
|
|
130
154
|
|
|
131
155
|
function getToolApprovalAndInterrupt(
|
|
132
156
|
part: {
|
|
133
|
-
approval?:
|
|
134
|
-
| {
|
|
135
|
-
id: string;
|
|
136
|
-
approved?: boolean;
|
|
137
|
-
reason?: string;
|
|
138
|
-
isAutomatic?: boolean;
|
|
139
|
-
}
|
|
140
|
-
| undefined;
|
|
157
|
+
approval?: Record<string, unknown> | undefined;
|
|
141
158
|
},
|
|
142
159
|
toolStatus: { type: string; payload?: unknown } | undefined,
|
|
143
160
|
): {
|
|
144
161
|
approval?: NonNullable<ToolCallMessagePart["approval"]>;
|
|
145
162
|
interrupt?: NonNullable<ToolCallMessagePart["interrupt"]>;
|
|
146
163
|
} {
|
|
147
|
-
if (part.approval
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
if (part.approval) {
|
|
165
|
+
// The AI SDK sends only id, approved and reason back to the server, so a
|
|
166
|
+
// request shape promising any other answer would render controls whose
|
|
167
|
+
// response cannot travel.
|
|
168
|
+
const {
|
|
169
|
+
id,
|
|
170
|
+
prompt,
|
|
171
|
+
approved,
|
|
172
|
+
reason,
|
|
173
|
+
isAutomatic,
|
|
174
|
+
resolution,
|
|
175
|
+
display,
|
|
176
|
+
allowFreeform,
|
|
177
|
+
options,
|
|
178
|
+
optionId,
|
|
179
|
+
text,
|
|
180
|
+
...additionalApprovalFields
|
|
181
|
+
} = part.approval;
|
|
182
|
+
const requestReason = additionalApprovalFields.requestReason;
|
|
183
|
+
if (typeof id === "string")
|
|
184
|
+
return {
|
|
185
|
+
approval: {
|
|
186
|
+
...additionalApprovalFields,
|
|
187
|
+
id,
|
|
188
|
+
...(typeof prompt === "string"
|
|
189
|
+
? { prompt }
|
|
190
|
+
: typeof requestReason === "string"
|
|
191
|
+
? { prompt: requestReason }
|
|
192
|
+
: {}),
|
|
193
|
+
...(typeof approved === "boolean" && { approved }),
|
|
194
|
+
...(typeof reason === "string" && { reason }),
|
|
195
|
+
...(isAutomatic === true && { isAutomatic: true }),
|
|
196
|
+
...((resolution === "cancelled" || resolution === "expired") && {
|
|
197
|
+
resolution,
|
|
198
|
+
}),
|
|
199
|
+
} as NonNullable<ToolCallMessagePart["approval"]>,
|
|
200
|
+
};
|
|
157
201
|
}
|
|
158
202
|
|
|
159
203
|
if (toolStatus?.type === "interrupt") {
|
|
@@ -427,7 +471,7 @@ export const AISDKMessageConverter = unstable_createMessageConverter(
|
|
|
427
471
|
status: { type: "complete" as const },
|
|
428
472
|
};
|
|
429
473
|
}),
|
|
430
|
-
metadata: message.metadata
|
|
474
|
+
metadata: toThreadMetadata(message.metadata),
|
|
431
475
|
};
|
|
432
476
|
|
|
433
477
|
case "system":
|
|
@@ -442,7 +486,7 @@ export const AISDKMessageConverter = unstable_createMessageConverter(
|
|
|
442
486
|
createdAt,
|
|
443
487
|
content,
|
|
444
488
|
metadata: {
|
|
445
|
-
...(message.metadata
|
|
489
|
+
...toThreadMetadata(message.metadata),
|
|
446
490
|
...(timing && { timing }),
|
|
447
491
|
...(isOptimistic && { isOptimistic: true }),
|
|
448
492
|
},
|
|
@@ -1,47 +1,68 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { render, waitFor } from "@testing-library/react";
|
|
5
|
-
import { StrictMode } from "react";
|
|
3
|
+
import { StrictMode, type ReactNode } from "react";
|
|
4
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
6
5
|
import { AuiConfig, AuiProvider, useAui } from "@assistant-ui/store";
|
|
7
|
-
import {
|
|
6
|
+
import type { ChatTransport, UIMessage } from "ai";
|
|
8
7
|
import { describe, expect, it } from "vitest";
|
|
9
8
|
import { AISDKChat } from "./AISDKChat";
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createCancellableTransport,
|
|
11
|
+
createStreamHarness,
|
|
12
|
+
} from "./__tests__/controlled-transport";
|
|
11
13
|
|
|
12
14
|
describe("AISDKChat React integration", () => {
|
|
13
|
-
it("
|
|
14
|
-
const { transport, getCancelCount
|
|
15
|
-
|
|
16
|
-
let isRunning = () => false;
|
|
17
|
-
|
|
18
|
-
const SendOnMount = () => {
|
|
19
|
-
const aui = useAui();
|
|
20
|
-
isRunning = () => aui.thread.getState().isRunning;
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
if (started) return;
|
|
23
|
-
started = true;
|
|
24
|
-
flushTapSync(() => aui.composer.setText("keep streaming"));
|
|
25
|
-
flushTapSync(() => aui.composer.send());
|
|
26
|
-
}, [aui]);
|
|
27
|
-
return null;
|
|
28
|
-
};
|
|
15
|
+
it("aborts the in-flight transport after a real unmount", async () => {
|
|
16
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
17
|
+
const { Probe, send, isRunning } = createStreamHarness();
|
|
29
18
|
|
|
30
19
|
const view = render(
|
|
31
20
|
<StrictMode>
|
|
32
21
|
<AuiProvider config={AuiConfig({ threads: AISDKChat({ transport }) })}>
|
|
33
|
-
<
|
|
22
|
+
<Probe />
|
|
34
23
|
</AuiProvider>
|
|
35
24
|
</StrictMode>,
|
|
36
25
|
);
|
|
37
26
|
|
|
38
|
-
await
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
await act(async () => send());
|
|
28
|
+
await waitFor(() => expect(isRunning()).toBe(true));
|
|
29
|
+
// the Strict Mode double mount already ran a host cleanup by now
|
|
30
|
+
expect(getCancelCount()).toBe(0);
|
|
41
31
|
|
|
42
32
|
view.unmount();
|
|
43
|
-
await
|
|
33
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("AISDKChat legacy useAui host integration", () => {
|
|
38
|
+
const LegacyProvider = ({
|
|
39
|
+
transport,
|
|
40
|
+
children,
|
|
41
|
+
}: {
|
|
42
|
+
transport: ChatTransport<UIMessage>;
|
|
43
|
+
children: ReactNode;
|
|
44
|
+
}) => {
|
|
45
|
+
const aui = useAui(AuiConfig({ threads: AISDKChat({ transport }) }));
|
|
46
|
+
return <AuiProvider value={aui}>{children}</AuiProvider>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
it("aborts the in-flight transport after a real unmount", async () => {
|
|
50
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
51
|
+
const { Probe, send, isRunning } = createStreamHarness();
|
|
52
|
+
|
|
53
|
+
const view = render(
|
|
54
|
+
<StrictMode>
|
|
55
|
+
<LegacyProvider transport={transport}>
|
|
56
|
+
<Probe />
|
|
57
|
+
</LegacyProvider>
|
|
58
|
+
</StrictMode>,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await act(async () => send());
|
|
62
|
+
await waitFor(() => expect(isRunning()).toBe(true));
|
|
44
63
|
expect(getCancelCount()).toBe(0);
|
|
45
|
-
|
|
64
|
+
|
|
65
|
+
view.unmount();
|
|
66
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
46
67
|
});
|
|
47
68
|
});
|
|
@@ -95,7 +95,7 @@ describe("AISDKThreads cloud", () => {
|
|
|
95
95
|
handle.destroy();
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
it("
|
|
98
|
+
it("keeps an in-flight cloud chat running across a switch and stops it on delete", async () => {
|
|
99
99
|
const chat = createCancellableTransport();
|
|
100
100
|
const handle = createAssistantClient(
|
|
101
101
|
AuiConfig({
|
|
@@ -126,6 +126,16 @@ describe("AISDKThreads cloud", () => {
|
|
|
126
126
|
await vi.waitFor(() => {
|
|
127
127
|
expect(handle.getClient().threads.getState().mainThreadId).toBe("t2");
|
|
128
128
|
});
|
|
129
|
+
expect(chat.getCancelCount()).toBe(0);
|
|
130
|
+
await vi.waitFor(() => {
|
|
131
|
+
expect(
|
|
132
|
+
handle.getClient().threads.item({ id: "t1" }).getState().isRunning,
|
|
133
|
+
).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
flushTapSync(() =>
|
|
137
|
+
handle.getClient().threads.item({ id: "t1" }).delete(),
|
|
138
|
+
);
|
|
129
139
|
await vi.waitFor(() => {
|
|
130
140
|
expect(chat.getCancelCount()).toBe(1);
|
|
131
141
|
});
|
|
@@ -546,3 +546,105 @@ describe("AISDKThreads", () => {
|
|
|
546
546
|
}
|
|
547
547
|
});
|
|
548
548
|
});
|
|
549
|
+
|
|
550
|
+
describe("AISDKThreads branch isolation", () => {
|
|
551
|
+
const completeRound = async (
|
|
552
|
+
handle: ReturnType<typeof createAssistantClient>,
|
|
553
|
+
emit: (...chunks: never[]) => void,
|
|
554
|
+
close: () => void,
|
|
555
|
+
question: string,
|
|
556
|
+
answer: string,
|
|
557
|
+
) => {
|
|
558
|
+
const aui = handle.getClient();
|
|
559
|
+
flushTapSync(() => aui.composer.setText(question));
|
|
560
|
+
flushTapSync(() => aui.composer.send());
|
|
561
|
+
await vi.waitFor(() => {
|
|
562
|
+
expect(aui.thread.getState().messages.length).toBeGreaterThan(0);
|
|
563
|
+
});
|
|
564
|
+
emit(...(textReply(answer) as never[]));
|
|
565
|
+
close();
|
|
566
|
+
await vi.waitFor(() => {
|
|
567
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
it("keeps switching between populated threads free of cross-thread branches", async () => {
|
|
572
|
+
const { transport, emit, close } = createControlledTransport();
|
|
573
|
+
const handle = createAssistantClient(
|
|
574
|
+
AuiConfig({ threads: AISDKThreads({ transport }) }),
|
|
575
|
+
);
|
|
576
|
+
handle.subscribe(() => {});
|
|
577
|
+
const aui = handle.getClient();
|
|
578
|
+
|
|
579
|
+
await completeRound(
|
|
580
|
+
handle,
|
|
581
|
+
emit as never,
|
|
582
|
+
close,
|
|
583
|
+
"thread a question",
|
|
584
|
+
"thread a answer",
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
flushTapSync(() => aui.threads.switchToNewThread());
|
|
588
|
+
await completeRound(
|
|
589
|
+
handle,
|
|
590
|
+
emit as never,
|
|
591
|
+
close,
|
|
592
|
+
"thread b question",
|
|
593
|
+
"thread b answer",
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
flushTapSync(() => aui.threads.switchToThread("main"));
|
|
597
|
+
await vi.waitFor(() => {
|
|
598
|
+
expect(threadText(handle as never)).toEqual([
|
|
599
|
+
"thread a question",
|
|
600
|
+
"thread a answer",
|
|
601
|
+
]);
|
|
602
|
+
});
|
|
603
|
+
expect(aui.thread.message({ index: 0 }).getState().branchCount).toBe(1);
|
|
604
|
+
expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(1);
|
|
605
|
+
|
|
606
|
+
handle.destroy();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("preserves intra-thread branches across a switch through an empty thread", async () => {
|
|
610
|
+
const { transport, emit, close } = createControlledTransport();
|
|
611
|
+
const handle = createAssistantClient(
|
|
612
|
+
AuiConfig({ threads: AISDKThreads({ transport }) }),
|
|
613
|
+
);
|
|
614
|
+
handle.subscribe(() => {});
|
|
615
|
+
const aui = handle.getClient();
|
|
616
|
+
|
|
617
|
+
await completeRound(
|
|
618
|
+
handle,
|
|
619
|
+
emit as never,
|
|
620
|
+
close,
|
|
621
|
+
"branchy question",
|
|
622
|
+
"first answer",
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
flushTapSync(() => aui.thread.message({ index: 1 }).reload());
|
|
626
|
+
await vi.waitFor(() => {
|
|
627
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(true);
|
|
628
|
+
});
|
|
629
|
+
emit(...(textReply("second answer") as never[]));
|
|
630
|
+
close();
|
|
631
|
+
await vi.waitFor(() => {
|
|
632
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(false);
|
|
633
|
+
expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
flushTapSync(() => aui.threads.switchToNewThread());
|
|
637
|
+
expect(handle.getClient().thread.getState().messages).toHaveLength(0);
|
|
638
|
+
|
|
639
|
+
flushTapSync(() => handle.getClient().threads.switchToThread("main"));
|
|
640
|
+
await vi.waitFor(() => {
|
|
641
|
+
expect(threadText(handle as never)).toEqual([
|
|
642
|
+
"branchy question",
|
|
643
|
+
"second answer",
|
|
644
|
+
]);
|
|
645
|
+
});
|
|
646
|
+
expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
|
|
647
|
+
|
|
648
|
+
handle.destroy();
|
|
649
|
+
});
|
|
650
|
+
});
|