@convex-dev/agent 0.6.4 → 0.7.0-alpha.0

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 (225) hide show
  1. package/MIGRATION.md +60 -116
  2. package/dist/client/messages.d.ts +1 -398
  3. package/dist/client/messages.d.ts.map +1 -1
  4. package/dist/client/messages.js +3 -47
  5. package/dist/client/messages.js.map +1 -1
  6. package/dist/client/types.d.ts +1 -455
  7. package/dist/client/types.d.ts.map +1 -1
  8. package/dist/component/_generated/component.d.ts +805 -45
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/messages.d.ts +1252 -201
  11. package/dist/component/messages.d.ts.map +1 -1
  12. package/dist/component/messages.js +49 -23
  13. package/dist/component/messages.js.map +1 -1
  14. package/dist/component/schema.d.ts +2593 -332
  15. package/dist/component/schema.d.ts.map +1 -1
  16. package/dist/component/streams.d.ts +7 -1
  17. package/dist/component/streams.d.ts.map +1 -1
  18. package/dist/component/streams.js +24 -30
  19. package/dist/component/streams.js.map +1 -1
  20. package/dist/component/threads.d.ts.map +1 -1
  21. package/dist/component/threads.js +1 -2
  22. package/dist/component/threads.js.map +1 -1
  23. package/dist/react/index.d.ts +5 -5
  24. package/dist/react/index.d.ts.map +1 -1
  25. package/dist/react/index.js +5 -5
  26. package/dist/react/index.js.map +1 -1
  27. package/dist/shared.d.ts +19 -7
  28. package/dist/shared.d.ts.map +1 -1
  29. package/dist/shared.js +4 -4
  30. package/dist/shared.js.map +1 -1
  31. package/dist/streaming/materializePersistedUIMessageChunks.d.ts +16 -0
  32. package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -0
  33. package/dist/streaming/materializePersistedUIMessageChunks.js +302 -0
  34. package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -0
  35. package/dist/streaming/persistedUIMessageChunks.d.ts +106 -0
  36. package/dist/streaming/persistedUIMessageChunks.d.ts.map +1 -0
  37. package/dist/streaming/persistedUIMessageChunks.js +475 -0
  38. package/dist/streaming/persistedUIMessageChunks.js.map +1 -0
  39. package/dist/validators.d.ts +10026 -1055
  40. package/dist/validators.d.ts.map +1 -1
  41. package/dist/validators.js +71 -9
  42. package/dist/validators.js.map +1 -1
  43. package/dist/{UIMessages.d.ts → vercel/UIMessages.d.ts} +1 -1
  44. package/dist/vercel/UIMessages.d.ts.map +1 -0
  45. package/dist/{UIMessages.js → vercel/UIMessages.js} +63 -1
  46. package/dist/vercel/UIMessages.js.map +1 -0
  47. package/dist/{client → vercel/client}/createTool.d.ts +23 -16
  48. package/dist/vercel/client/createTool.d.ts.map +1 -0
  49. package/dist/{client → vercel/client}/createTool.js +2 -0
  50. package/dist/vercel/client/createTool.js.map +1 -0
  51. package/dist/{client → vercel/client}/definePlaygroundAPI.d.ts +533 -79
  52. package/dist/{client → vercel/client}/definePlaygroundAPI.d.ts.map +1 -1
  53. package/dist/{client → vercel/client}/definePlaygroundAPI.js +8 -7
  54. package/dist/vercel/client/definePlaygroundAPI.js.map +1 -0
  55. package/dist/{client → vercel/client}/files.d.ts +2 -2
  56. package/dist/vercel/client/files.d.ts.map +1 -0
  57. package/dist/vercel/client/files.js.map +1 -0
  58. package/dist/vercel/client/messages.d.ts +542 -0
  59. package/dist/vercel/client/messages.d.ts.map +1 -0
  60. package/dist/vercel/client/messages.js +66 -0
  61. package/dist/vercel/client/messages.js.map +1 -0
  62. package/dist/{client → vercel/client}/mockModel.d.ts +17 -17
  63. package/dist/vercel/client/mockModel.d.ts.map +1 -0
  64. package/dist/{client → vercel/client}/mockModel.js +12 -13
  65. package/dist/vercel/client/mockModel.js.map +1 -0
  66. package/dist/{client → vercel/client}/saveInputMessages.d.ts +2 -2
  67. package/dist/vercel/client/saveInputMessages.d.ts.map +1 -0
  68. package/dist/vercel/client/saveInputMessages.js.map +1 -0
  69. package/dist/{client → vercel/client}/search.d.ts +119 -22
  70. package/dist/vercel/client/search.d.ts.map +1 -0
  71. package/dist/{client → vercel/client}/search.js +2 -2
  72. package/dist/vercel/client/search.js.map +1 -0
  73. package/dist/{client → vercel/client}/start.d.ts +26 -11
  74. package/dist/vercel/client/start.d.ts.map +1 -0
  75. package/dist/{client → vercel/client}/start.js +64 -22
  76. package/dist/vercel/client/start.js.map +1 -0
  77. package/dist/{client → vercel/client}/streamText.d.ts +13 -12
  78. package/dist/vercel/client/streamText.d.ts.map +1 -0
  79. package/dist/{client → vercel/client}/streamText.js +43 -9
  80. package/dist/vercel/client/streamText.js.map +1 -0
  81. package/dist/{client → vercel/client}/streaming.d.ts +1410 -183
  82. package/dist/vercel/client/streaming.d.ts.map +1 -0
  83. package/dist/{client → vercel/client}/streaming.js +120 -52
  84. package/dist/vercel/client/streaming.js.map +1 -0
  85. package/dist/vercel/client/types.d.ts +456 -0
  86. package/dist/vercel/client/types.d.ts.map +1 -0
  87. package/dist/vercel/client/types.js +3 -0
  88. package/dist/vercel/client/types.js.map +1 -0
  89. package/dist/{client → vercel/client}/utils.d.ts +3 -2
  90. package/dist/vercel/client/utils.d.ts.map +1 -0
  91. package/dist/{client → vercel/client}/utils.js +2 -2
  92. package/dist/vercel/client/utils.js.map +1 -0
  93. package/dist/{deltas.d.ts → vercel/deltas.d.ts} +3 -4
  94. package/dist/vercel/deltas.d.ts.map +1 -0
  95. package/dist/{deltas.js → vercel/deltas.js} +195 -99
  96. package/dist/vercel/deltas.js.map +1 -0
  97. package/dist/{client → vercel}/index.d.ts +482 -106
  98. package/dist/vercel/index.d.ts.map +1 -0
  99. package/dist/{client → vercel}/index.js +64 -46
  100. package/dist/vercel/index.js.map +1 -0
  101. package/dist/{mapping.d.ts → vercel/mapping.d.ts} +5 -19
  102. package/dist/vercel/mapping.d.ts.map +1 -0
  103. package/dist/{mapping.js → vercel/mapping.js} +411 -114
  104. package/dist/vercel/mapping.js.map +1 -0
  105. package/dist/{react → vercel/react}/optimisticallySendMessage.d.ts +2 -2
  106. package/dist/vercel/react/optimisticallySendMessage.d.ts.map +1 -0
  107. package/dist/vercel/react/optimisticallySendMessage.js.map +1 -0
  108. package/dist/{react → vercel/react}/types.d.ts +1 -1
  109. package/dist/vercel/react/types.d.ts.map +1 -0
  110. package/dist/{react → vercel/react}/types.js.map +1 -1
  111. package/dist/{react → vercel/react}/useDeltaStreams.d.ts +1 -1
  112. package/dist/vercel/react/useDeltaStreams.d.ts.map +1 -0
  113. package/dist/{react → vercel/react}/useDeltaStreams.js +1 -1
  114. package/dist/vercel/react/useDeltaStreams.js.map +1 -0
  115. package/dist/{react → vercel/react}/useStreamingUIMessages.d.ts +1 -1
  116. package/dist/vercel/react/useStreamingUIMessages.d.ts.map +1 -0
  117. package/dist/{react → vercel/react}/useStreamingUIMessages.js +7 -3
  118. package/dist/vercel/react/useStreamingUIMessages.js.map +1 -0
  119. package/dist/{react → vercel/react}/useThreadMessages.d.ts +1 -1
  120. package/dist/vercel/react/useThreadMessages.d.ts.map +1 -0
  121. package/dist/{react → vercel/react}/useThreadMessages.js +1 -1
  122. package/dist/vercel/react/useThreadMessages.js.map +1 -0
  123. package/dist/{react → vercel/react}/useUIMessages.d.ts +1 -1
  124. package/dist/vercel/react/useUIMessages.d.ts.map +1 -0
  125. package/dist/{react → vercel/react}/useUIMessages.js +1 -1
  126. package/dist/vercel/react/useUIMessages.js.map +1 -0
  127. package/package.json +29 -22
  128. package/src/client/messages.ts +5 -107
  129. package/src/client/types.ts +0 -608
  130. package/src/component/_generated/component.ts +1089 -48
  131. package/src/component/messages.test.ts +273 -1
  132. package/src/component/messages.ts +62 -32
  133. package/src/component/streams.ts +47 -47
  134. package/src/component/threads.ts +5 -2
  135. package/src/eslintConfig.test.ts +44 -0
  136. package/src/react/index.ts +8 -5
  137. package/src/shared.ts +26 -36
  138. package/src/streaming/materializePersistedUIMessageChunks.test.ts +513 -0
  139. package/src/streaming/materializePersistedUIMessageChunks.ts +398 -0
  140. package/src/streaming/persistedUIMessageChunks.test.ts +205 -0
  141. package/src/streaming/persistedUIMessageChunks.ts +658 -0
  142. package/src/validators.test.ts +4 -1
  143. package/src/validators.ts +89 -8
  144. package/src/{UIMessages.combineUIMessages.test.ts → vercel/UIMessages.combineUIMessages.test.ts} +4 -1
  145. package/src/{UIMessages.ts → vercel/UIMessages.ts} +95 -3
  146. package/src/{client → vercel/client}/approval.test.ts +23 -13
  147. package/src/{client → vercel/client}/createTool.ts +62 -22
  148. package/src/{client → vercel/client}/definePlaygroundAPI.ts +15 -16
  149. package/src/{client → vercel/client}/files.ts +2 -2
  150. package/src/{client → vercel/client}/index.test.ts +99 -31
  151. package/src/vercel/client/messages.ts +182 -0
  152. package/src/{client → vercel/client}/mockModel.ts +38 -39
  153. package/src/{client → vercel/client}/saveInputMessages.test.ts +1 -1
  154. package/src/{client → vercel/client}/saveInputMessages.ts +3 -3
  155. package/src/{client → vercel/client}/search.test.ts +3 -3
  156. package/src/{client → vercel/client}/search.ts +4 -4
  157. package/src/{client → vercel/client}/setup.test.ts +6 -3
  158. package/src/vercel/client/start.test.ts +14 -0
  159. package/src/{client → vercel/client}/start.ts +147 -36
  160. package/src/vercel/client/streamText.test.ts +223 -0
  161. package/src/{client → vercel/client}/streamText.ts +102 -31
  162. package/src/{client → vercel/client}/streaming.integration.test.ts +6 -3
  163. package/src/{client → vercel/client}/streaming.test.ts +178 -4
  164. package/src/{client → vercel/client}/streaming.ts +114 -52
  165. package/src/vercel/client/types.test.ts +13 -0
  166. package/src/vercel/client/types.ts +679 -0
  167. package/src/{client → vercel/client}/utils.ts +13 -7
  168. package/src/{deltas.test.ts → vercel/deltas.test.ts} +384 -93
  169. package/src/{deltas.ts → vercel/deltas.ts} +218 -115
  170. package/src/{fromUIMessages.test.ts → vercel/fromUIMessages.test.ts} +17 -1
  171. package/src/{client → vercel}/index.ts +167 -84
  172. package/src/{mapping.test.ts → vercel/mapping.test.ts} +377 -92
  173. package/src/{mapping.ts → vercel/mapping.ts} +501 -137
  174. package/src/{react → vercel/react}/optimisticallySendMessage.ts +2 -2
  175. package/src/{react → vercel/react}/types.ts +1 -1
  176. package/src/{react → vercel/react}/useDeltaStreams.ts +6 -2
  177. package/src/{react → vercel/react}/useStreamingUIMessages.ts +7 -3
  178. package/src/{react → vercel/react}/useThreadMessages.ts +2 -2
  179. package/src/{react → vercel/react}/useUIMessages.ts +2 -2
  180. package/src/{toUIMessages.test.ts → vercel/toUIMessages.test.ts} +49 -2
  181. package/dist/UIMessages.d.ts.map +0 -1
  182. package/dist/UIMessages.js.map +0 -1
  183. package/dist/client/createTool.d.ts.map +0 -1
  184. package/dist/client/createTool.js.map +0 -1
  185. package/dist/client/definePlaygroundAPI.js.map +0 -1
  186. package/dist/client/files.d.ts.map +0 -1
  187. package/dist/client/files.js.map +0 -1
  188. package/dist/client/index.d.ts.map +0 -1
  189. package/dist/client/index.js.map +0 -1
  190. package/dist/client/mockModel.d.ts.map +0 -1
  191. package/dist/client/mockModel.js.map +0 -1
  192. package/dist/client/saveInputMessages.d.ts.map +0 -1
  193. package/dist/client/saveInputMessages.js.map +0 -1
  194. package/dist/client/search.d.ts.map +0 -1
  195. package/dist/client/search.js.map +0 -1
  196. package/dist/client/start.d.ts.map +0 -1
  197. package/dist/client/start.js.map +0 -1
  198. package/dist/client/streamText.d.ts.map +0 -1
  199. package/dist/client/streamText.js.map +0 -1
  200. package/dist/client/streaming.d.ts.map +0 -1
  201. package/dist/client/streaming.js.map +0 -1
  202. package/dist/client/utils.d.ts.map +0 -1
  203. package/dist/client/utils.js.map +0 -1
  204. package/dist/deltas.d.ts.map +0 -1
  205. package/dist/deltas.js.map +0 -1
  206. package/dist/mapping.d.ts.map +0 -1
  207. package/dist/mapping.js.map +0 -1
  208. package/dist/react/optimisticallySendMessage.d.ts.map +0 -1
  209. package/dist/react/optimisticallySendMessage.js.map +0 -1
  210. package/dist/react/types.d.ts.map +0 -1
  211. package/dist/react/useDeltaStreams.d.ts.map +0 -1
  212. package/dist/react/useDeltaStreams.js.map +0 -1
  213. package/dist/react/useStreamingUIMessages.d.ts.map +0 -1
  214. package/dist/react/useStreamingUIMessages.js.map +0 -1
  215. package/dist/react/useThreadMessages.d.ts.map +0 -1
  216. package/dist/react/useThreadMessages.js.map +0 -1
  217. package/dist/react/useUIMessages.d.ts.map +0 -1
  218. package/dist/react/useUIMessages.js.map +0 -1
  219. package/src/client/streamText.test.ts +0 -102
  220. /package/dist/{client → vercel/client}/files.js +0 -0
  221. /package/dist/{client → vercel/client}/saveInputMessages.js +0 -0
  222. /package/dist/{react → vercel/react}/optimisticallySendMessage.js +0 -0
  223. /package/dist/{react → vercel/react}/types.js +0 -0
  224. /package/src/{UIMessages.test.ts → vercel/UIMessages.test.ts} +0 -0
  225. /package/src/{react → vercel/react}/useUIMessages.test.ts +0 -0
package/src/shared.ts CHANGED
@@ -1,23 +1,19 @@
1
- import type {
2
- FilePart,
3
- ImagePart,
4
- ReasoningPart,
5
- ToolCallPart,
6
- ToolResultPart,
7
- ToolApprovalRequest,
8
- } from "@ai-sdk/provider-utils";
9
- import type {
10
- ModelMessage,
11
- TextPart,
12
- UIDataTypes,
13
- UIMessagePart,
14
- UITools,
15
- } from "ai";
16
- import type { Message, MessageContentParts } from "./validators.js";
1
+ type MessagePartLike = {
2
+ type: string;
3
+ text?: string;
4
+ };
5
+
6
+ type MessageLike =
7
+ | { role: "system"; content: string }
8
+ | {
9
+ role: "user" | "assistant";
10
+ content: string | readonly MessagePartLike[];
11
+ }
12
+ | { role: "tool"; content: readonly MessagePartLike[] };
17
13
 
18
14
  export const DEFAULT_RECENT_MESSAGES = 100;
19
15
 
20
- export function isTool(message: Message | ModelMessage) {
16
+ export function isTool(message: MessageLike) {
21
17
  return (
22
18
  message.role === "tool" ||
23
19
  (message.role === "assistant" &&
@@ -26,7 +22,7 @@ export function isTool(message: Message | ModelMessage) {
26
22
  );
27
23
  }
28
24
 
29
- export function extractText(message: Message | ModelMessage) {
25
+ export function extractText(message: MessageLike) {
30
26
  switch (message.role) {
31
27
  case "user":
32
28
  if (typeof message.content === "string") {
@@ -46,33 +42,27 @@ export function extractText(message: Message | ModelMessage) {
46
42
  return undefined;
47
43
  }
48
44
 
49
- export function joinText(
50
- parts: (
51
- | UIMessagePart<UIDataTypes, UITools>
52
- | TextPart
53
- | ImagePart
54
- | FilePart
55
- | ReasoningPart
56
- | ToolCallPart
57
- | ToolResultPart
58
- | MessageContentParts
59
- | ToolApprovalRequest
60
- )[],
61
- ) {
45
+ export function joinText(parts: readonly MessagePartLike[]) {
62
46
  return parts
63
- .filter((p) => p.type === "text")
64
- .map((p) => p.text)
47
+ .filter(
48
+ (part): part is MessagePartLike & { text: string } =>
49
+ part.type === "text" && typeof part.text === "string",
50
+ )
51
+ .map((part) => part.text)
65
52
  .filter(Boolean)
66
53
  .join(" ");
67
54
  }
68
55
 
69
- export function extractReasoning(message: Message | ModelMessage) {
56
+ export function extractReasoning(message: MessageLike) {
70
57
  if (typeof message.content === "string") {
71
58
  return undefined;
72
59
  }
73
60
  return message.content
74
- .filter((c) => c.type === "reasoning")
75
- .map((c) => c.text)
61
+ .filter(
62
+ (part): part is MessagePartLike & { text: string } =>
63
+ part.type === "reasoning" && typeof part.text === "string",
64
+ )
65
+ .map((part) => part.text)
76
66
  .join(" ");
77
67
  }
78
68
 
@@ -0,0 +1,513 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { validate } from "convex-helpers/validators";
3
+ import {
4
+ type StreamDelta,
5
+ type StreamMessage,
6
+ vMessageWithMetadataInternal,
7
+ } from "../validators.js";
8
+ import {
9
+ getPersistedUIMessageChunkParts,
10
+ projectPersistedUIMessageChunks,
11
+ } from "./materializePersistedUIMessageChunks.js";
12
+
13
+ const stream: StreamMessage = {
14
+ streamId: "stream-1",
15
+ status: "aborted",
16
+ format: "UIMessageChunk",
17
+ order: 4,
18
+ stepOrder: 1,
19
+ model: "model-1",
20
+ provider: "provider-1",
21
+ };
22
+
23
+ describe("projectPersistedUIMessageChunks", () => {
24
+ it("preserves canonical content tool outputs", () => {
25
+ const canonicalOutput = {
26
+ type: "content",
27
+ value: [{ type: "text", text: "already canonical" }],
28
+ };
29
+ const actual = projectPersistedUIMessageChunks(
30
+ stream,
31
+ [
32
+ {
33
+ type: "tool-input-available",
34
+ toolCallId: "call-1",
35
+ toolName: "lookup",
36
+ input: { query: "hello" },
37
+ },
38
+ {
39
+ type: "tool-output-available",
40
+ toolCallId: "call-1",
41
+ output: canonicalOutput,
42
+ },
43
+ ],
44
+ { status: "success" },
45
+ );
46
+
47
+ expect(actual[1].message).toMatchObject({
48
+ role: "tool",
49
+ content: [{ output: canonicalOutput }],
50
+ });
51
+ expect(validate(vMessageWithMetadataInternal, actual[1])).toBe(true);
52
+ });
53
+
54
+ it("keeps persisted sources on the step that produced them", () => {
55
+ const chunks = [
56
+ { type: "start-step" },
57
+ { type: "reasoning-start", id: "reasoning-1" },
58
+ { type: "reasoning-delta", id: "reasoning-1", delta: "Think" },
59
+ { type: "reasoning-end", id: "reasoning-1" },
60
+ { type: "text-start", id: "text-1" },
61
+ { type: "text-delta", id: "text-1", delta: "Answer" },
62
+ { type: "text-end", id: "text-1" },
63
+ {
64
+ type: "source-url",
65
+ sourceId: "source-1",
66
+ url: "https://example.com",
67
+ title: "Example",
68
+ },
69
+ { type: "finish-step" },
70
+ { type: "start-step" },
71
+ {
72
+ type: "tool-input-available",
73
+ toolCallId: "call-1",
74
+ toolName: "lookup",
75
+ input: { query: "hello" },
76
+ },
77
+ {
78
+ type: "tool-output-available",
79
+ toolCallId: "call-1",
80
+ output: { answer: 42 },
81
+ },
82
+ ];
83
+ const metadata = { status: "failed" as const, error: "interrupted" };
84
+
85
+ const actual = projectPersistedUIMessageChunks(stream, chunks, metadata);
86
+ expect(actual).toEqual([
87
+ {
88
+ message: {
89
+ role: "assistant",
90
+ content: [
91
+ { type: "reasoning", text: "Think" },
92
+ { type: "text", text: "Answer" },
93
+ ],
94
+ },
95
+ status: "failed",
96
+ finishReason: "stop",
97
+ model: "model-1",
98
+ provider: "provider-1",
99
+ sources: [
100
+ {
101
+ type: "source",
102
+ sourceType: "url",
103
+ url: "https://example.com",
104
+ id: "source-1",
105
+ title: "Example",
106
+ },
107
+ ],
108
+ reasoning: "Think",
109
+ error: "interrupted",
110
+ },
111
+ {
112
+ message: {
113
+ role: "assistant",
114
+ content: [
115
+ {
116
+ type: "tool-call",
117
+ toolCallId: "call-1",
118
+ toolName: "lookup",
119
+ input: { query: "hello" },
120
+ args: { query: "hello" },
121
+ },
122
+ ],
123
+ },
124
+ status: "failed",
125
+ finishReason: "tool-calls",
126
+ model: "model-1",
127
+ provider: "provider-1",
128
+ sources: [],
129
+ reasoning: "",
130
+ error: "interrupted",
131
+ },
132
+ {
133
+ message: {
134
+ role: "tool",
135
+ content: [
136
+ {
137
+ type: "tool-result",
138
+ toolCallId: "call-1",
139
+ toolName: "lookup",
140
+ output: { type: "json", value: { answer: 42 } },
141
+ },
142
+ ],
143
+ },
144
+ status: "failed",
145
+ finishReason: "tool-calls",
146
+ model: "model-1",
147
+ provider: "provider-1",
148
+ sources: [],
149
+ reasoning: "",
150
+ error: "interrupted",
151
+ },
152
+ ]);
153
+ for (const message of actual) {
154
+ expect(validate(vMessageWithMetadataInternal, message)).toBe(true);
155
+ }
156
+ });
157
+
158
+ it("uses only the contiguous persisted prefix and rejects malformed lifecycle bytes", () => {
159
+ const deltas: StreamDelta[] = [
160
+ {
161
+ streamId: stream.streamId,
162
+ start: 1,
163
+ end: 2,
164
+ parts: [{ type: "text-end", id: "text-1" }],
165
+ },
166
+ {
167
+ streamId: stream.streamId,
168
+ start: 3,
169
+ end: 4,
170
+ parts: [{ type: "text-end", id: "after-gap" }],
171
+ },
172
+ {
173
+ streamId: stream.streamId,
174
+ start: 0,
175
+ end: 1,
176
+ parts: [{ type: "text-start", id: "text-1" }],
177
+ },
178
+ ];
179
+ expect(getPersistedUIMessageChunkParts(deltas)).toMatchObject({
180
+ cursor: 2,
181
+ });
182
+ expect(() =>
183
+ getPersistedUIMessageChunkParts([
184
+ {
185
+ streamId: stream.streamId,
186
+ start: 0,
187
+ end: 2,
188
+ parts: [
189
+ { type: "text-start", id: "text-1" },
190
+ { type: "text-delta", id: "text-1", delta: "partial" },
191
+ ],
192
+ },
193
+ {
194
+ streamId: stream.streamId,
195
+ start: 1,
196
+ end: 3,
197
+ parts: [
198
+ { type: "text-delta", id: "text-1", delta: "overlap" },
199
+ { type: "text-end", id: "text-1" },
200
+ ],
201
+ },
202
+ ]),
203
+ ).toThrow("Got unexpected delta");
204
+ expect(() =>
205
+ projectPersistedUIMessageChunks(
206
+ stream,
207
+ [{ type: "text-delta", id: "missing", delta: "partial" }],
208
+ { status: "failed" },
209
+ ),
210
+ ).toThrow("missing text part");
211
+ });
212
+
213
+ it("replaces provider metadata and projects file metadata", () => {
214
+ const chunks = [
215
+ {
216
+ type: "text-start",
217
+ id: "text-1",
218
+ providerMetadata: { openai: { phase: "start" } },
219
+ },
220
+ {
221
+ type: "text-delta",
222
+ id: "text-1",
223
+ delta: "answer",
224
+ providerMetadata: { anthropic: { phase: "delta" } },
225
+ },
226
+ {
227
+ type: "text-end",
228
+ id: "text-1",
229
+ providerMetadata: { openai: { phase: "end" } },
230
+ },
231
+ {
232
+ type: "file",
233
+ url: "https://example.com/file.txt",
234
+ mediaType: "text/plain",
235
+ providerMetadata: { openai: { ignored: true } },
236
+ },
237
+ ];
238
+ const metadata = { status: "failed" as const };
239
+ const actual = projectPersistedUIMessageChunks(stream, chunks, metadata);
240
+
241
+ expect(actual).toEqual([
242
+ {
243
+ message: {
244
+ role: "assistant",
245
+ content: [
246
+ {
247
+ type: "text",
248
+ text: "answer",
249
+ providerOptions: { openai: { phase: "end" } },
250
+ },
251
+ {
252
+ type: "file",
253
+ data: "https://example.com/file.txt",
254
+ mediaType: "text/plain",
255
+ providerOptions: { openai: { ignored: true } },
256
+ },
257
+ ],
258
+ },
259
+ status: "failed",
260
+ finishReason: "stop",
261
+ model: "model-1",
262
+ provider: "provider-1",
263
+ providerMetadata: { openai: { phase: "end" } },
264
+ sources: [],
265
+ reasoning: "",
266
+ },
267
+ ]);
268
+ });
269
+
270
+ it("accepts persisted data-* chunks without losing recovered text", () => {
271
+ const chunks = [
272
+ { type: "data-progress", id: "progress-1", data: { completed: 1 } },
273
+ { type: "text-start", id: "text-1" },
274
+ { type: "text-delta", id: "text-1", delta: "recovered" },
275
+ { type: "text-end", id: "text-1" },
276
+ { type: "data-progress", id: "progress-1", data: { completed: 2 } },
277
+ ];
278
+ const metadata = { status: "failed" as const };
279
+ const actual = projectPersistedUIMessageChunks(stream, chunks, metadata);
280
+
281
+ expect(actual).toEqual([
282
+ {
283
+ message: {
284
+ role: "assistant",
285
+ content: [{ type: "text", text: "recovered" }],
286
+ },
287
+ status: "failed",
288
+ finishReason: "stop",
289
+ model: "model-1",
290
+ provider: "provider-1",
291
+ sources: [],
292
+ reasoning: "",
293
+ },
294
+ ]);
295
+ });
296
+
297
+ it("normalizes malformed tool input through durable recovery", () => {
298
+ const chunks = [
299
+ {
300
+ type: "tool-input-error",
301
+ toolCallId: "call-invalid",
302
+ toolName: "lookup",
303
+ errorText: "invalid tool input",
304
+ },
305
+ ];
306
+ const metadata = { status: "failed" as const };
307
+ const actual = projectPersistedUIMessageChunks(stream, chunks, metadata);
308
+
309
+ expect(actual).toEqual([
310
+ {
311
+ message: {
312
+ role: "assistant",
313
+ content: [
314
+ {
315
+ type: "tool-call",
316
+ toolCallId: "call-invalid",
317
+ toolName: "lookup",
318
+ input: {},
319
+ args: {},
320
+ },
321
+ ],
322
+ },
323
+ status: "failed",
324
+ finishReason: "tool-calls",
325
+ model: "model-1",
326
+ provider: "provider-1",
327
+ sources: [],
328
+ reasoning: "",
329
+ },
330
+ {
331
+ message: {
332
+ role: "tool",
333
+ content: [
334
+ {
335
+ type: "tool-result",
336
+ toolCallId: "call-invalid",
337
+ toolName: "lookup",
338
+ output: { type: "error-text", value: "invalid tool input" },
339
+ },
340
+ ],
341
+ },
342
+ status: "failed",
343
+ finishReason: "tool-calls",
344
+ model: "model-1",
345
+ provider: "provider-1",
346
+ sources: [],
347
+ reasoning: "",
348
+ },
349
+ ]);
350
+ });
351
+
352
+ it("round-trips extended UIMessageChunk parts through the same marker", () => {
353
+ const actual = projectPersistedUIMessageChunks(
354
+ stream,
355
+ [
356
+ {
357
+ type: "custom",
358
+ kind: "openai.annotation",
359
+ providerMetadata: { openai: { annotation: "kept" } },
360
+ },
361
+ {
362
+ type: "reasoning-file",
363
+ url: "https://example.com/reasoning.pdf",
364
+ mediaType: "application/pdf",
365
+ },
366
+ {
367
+ type: "tool-input-start",
368
+ toolCallId: "static-call",
369
+ toolName: "weather",
370
+ providerExecuted: true,
371
+ title: "Weather",
372
+ toolMetadata: { source: "forecast" },
373
+ providerMetadata: { openai: { call: "metadata" } },
374
+ },
375
+ {
376
+ type: "tool-input-delta",
377
+ toolCallId: "static-call",
378
+ inputTextDelta: '{"city":"Boston"}',
379
+ },
380
+ {
381
+ type: "tool-approval-request",
382
+ toolCallId: "static-call",
383
+ approvalId: "approval-1",
384
+ isAutomatic: true,
385
+ signature: "signed",
386
+ },
387
+ {
388
+ type: "tool-approval-response",
389
+ approvalId: "approval-1",
390
+ approved: false,
391
+ providerExecuted: true,
392
+ },
393
+ {
394
+ type: "tool-input-available",
395
+ toolCallId: "dynamic-call",
396
+ toolName: "unknown",
397
+ dynamic: true,
398
+ input: { value: 1 },
399
+ },
400
+ {
401
+ type: "tool-approval-request",
402
+ toolCallId: "dynamic-call",
403
+ approvalId: "approval-2",
404
+ },
405
+ {
406
+ type: "tool-approval-response",
407
+ approvalId: "approval-2",
408
+ approved: false,
409
+ reason: "denied",
410
+ },
411
+ { type: "tool-output-denied", toolCallId: "dynamic-call" },
412
+ ],
413
+ { status: "failed" },
414
+ );
415
+
416
+ expect(actual).toMatchObject([
417
+ {
418
+ message: {
419
+ role: "assistant",
420
+ content: [
421
+ {
422
+ type: "custom",
423
+ kind: "openai.annotation",
424
+ providerOptions: { openai: { annotation: "kept" } },
425
+ },
426
+ {
427
+ type: "reasoning-file",
428
+ url: "https://example.com/reasoning.pdf",
429
+ mediaType: "application/pdf",
430
+ },
431
+ {
432
+ type: "tool-call",
433
+ toolCallId: "static-call",
434
+ toolName: "weather",
435
+ input: { city: "Boston" },
436
+ providerExecuted: true,
437
+ title: "Weather",
438
+ toolMetadata: { source: "forecast" },
439
+ },
440
+ {
441
+ type: "tool-approval-request",
442
+ approvalId: "approval-1",
443
+ isAutomatic: true,
444
+ signature: "signed",
445
+ },
446
+ {
447
+ type: "tool-call",
448
+ toolCallId: "dynamic-call",
449
+ toolName: "unknown",
450
+ },
451
+ {
452
+ type: "tool-approval-request",
453
+ approvalId: "approval-2",
454
+ },
455
+ ],
456
+ },
457
+ },
458
+ {
459
+ message: {
460
+ role: "tool",
461
+ content: [
462
+ {
463
+ type: "tool-approval-response",
464
+ approvalId: "approval-1",
465
+ approved: false,
466
+ providerExecuted: true,
467
+ },
468
+ {
469
+ type: "tool-result",
470
+ toolCallId: "static-call",
471
+ output: { type: "execution-denied" },
472
+ },
473
+ {
474
+ type: "tool-approval-response",
475
+ approvalId: "approval-2",
476
+ approved: false,
477
+ reason: "denied",
478
+ },
479
+ {
480
+ type: "tool-result",
481
+ toolCallId: "dynamic-call",
482
+ output: {
483
+ type: "execution-denied",
484
+ reason: "denied",
485
+ },
486
+ },
487
+ ],
488
+ },
489
+ },
490
+ ]);
491
+ expect(
492
+ actual.flatMap((message) =>
493
+ Array.isArray(message.message.content)
494
+ ? message.message.content.filter((part) => part.type === "tool-result")
495
+ : [],
496
+ ),
497
+ ).toHaveLength(2);
498
+ expect(
499
+ actual.flatMap((message) =>
500
+ Array.isArray(message.message.content)
501
+ ? message.message.content.filter(
502
+ (part) =>
503
+ part.type === "tool-result" &&
504
+ part.toolCallId === "dynamic-call",
505
+ )
506
+ : [],
507
+ ),
508
+ ).toHaveLength(1);
509
+ for (const message of actual) {
510
+ expect(validate(vMessageWithMetadataInternal, message)).toBe(true);
511
+ }
512
+ });
513
+ });