@convex-dev/agent 0.6.4 → 0.7.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 +31 -24
  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
@@ -1,9 +1,11 @@
1
1
  import type {
2
+ ModelMessage,
2
3
  StepResult,
3
4
  StreamTextResult,
4
5
  ToolSet,
5
6
  UIMessage as AIUIMessage,
6
7
  } from "ai";
8
+ import type { Context } from "@ai-sdk/provider-utils";
7
9
  import { streamText as streamTextAi } from "ai";
8
10
  import {
9
11
  compressUIMessageChunks,
@@ -14,16 +16,31 @@ import {
14
16
  import type {
15
17
  ActionCtx,
16
18
  AgentComponent,
17
- AgentPrompt,
18
19
  GenerationOutputMetadata,
19
20
  Options,
20
- Output,
21
+ StreamingTextArgs,
21
22
  } from "./types.js";
23
+ import type { Output as AISDKOutput } from "ai";
22
24
  import { startGeneration } from "./start.js";
23
- import type { Agent } from "./index.js";
24
- import { getModelName, getProviderName } from "../shared.js";
25
+ import type { Agent } from "../index.js";
26
+ import { getModelName, getProviderName } from "../../shared.js";
25
27
  import { errorToString, willContinue } from "./utils.js";
26
28
 
29
+ /** Finish every abort cleanup path before surfacing an internal failure. */
30
+ export async function runAbortCleanup(cleanup: {
31
+ failCall: () => Promise<void>;
32
+ failStreamer: () => Promise<void>;
33
+ onAbort?: () => PromiseLike<void> | void;
34
+ }): Promise<void> {
35
+ const results = await Promise.allSettled([
36
+ cleanup.failCall(),
37
+ cleanup.failStreamer(),
38
+ ]);
39
+ await cleanup.onAbort?.();
40
+ const failure = results.find((result) => result.status === "rejected");
41
+ if (failure) throw failure.reason;
42
+ }
43
+
27
44
  /**
28
45
  * This behaves like {@link streamText} from the "ai" package except that
29
46
  * it add context based on the userId and threadId and saves the input and
@@ -32,8 +49,14 @@ import { errorToString, willContinue } from "./utils.js";
32
49
  * to a thread (and optionally userId).
33
50
  */
34
51
  export async function streamText<
35
- TOOLS extends ToolSet,
36
- OUTPUT extends Output<any, any, any> = never,
52
+ AgentTools extends ToolSet,
53
+ TOOLS extends ToolSet | undefined = undefined,
54
+ OUTPUT extends AISDKOutput.Output<any, any, any> = AISDKOutput.Output<
55
+ string,
56
+ string,
57
+ never
58
+ >,
59
+ RUNTIME_CONTEXT extends Context = Context,
37
60
  >(
38
61
  ctx: ActionCtx,
39
62
  component: AgentComponent,
@@ -41,17 +64,7 @@ export async function streamText<
41
64
  * The arguments to the streamText function, similar to the ai sdk's
42
65
  * {@link streamText} function, along with Agent prompt options.
43
66
  */
44
- streamTextArgs: AgentPrompt &
45
- Omit<
46
- Parameters<typeof streamTextAi<TOOLS, OUTPUT>>[0],
47
- "model" | "prompt" | "messages"
48
- > & {
49
- /**
50
- * The tools to use for the tool calls. This will override tools specified
51
- * in the Agent constructor or createThread / continueThread.
52
- */
53
- tools?: TOOLS;
54
- },
67
+ streamTextArgs: StreamingTextArgs<AgentTools, TOOLS, OUTPUT, RUNTIME_CONTEXT>,
55
68
  /**
56
69
  * The {@link ContextOptions} and {@link StorageOptions}
57
70
  * options to use for fetching contextual messages and saving input/output messages.
@@ -73,17 +86,49 @@ export async function streamText<
73
86
  saveStreamDeltas?: boolean | StreamingOptions;
74
87
  agentForToolCtx?: Agent;
75
88
  },
76
- ): Promise<StreamTextResult<TOOLS, OUTPUT> & GenerationOutputMetadata> {
89
+ ): Promise<
90
+ StreamTextResult<
91
+ TOOLS extends undefined ? AgentTools : TOOLS,
92
+ RUNTIME_CONTEXT,
93
+ OUTPUT
94
+ > &
95
+ GenerationOutputMetadata
96
+ > {
97
+ type Tools = TOOLS extends undefined ? AgentTools : TOOLS;
77
98
  const { threadId } = options ?? {};
78
99
  const { args, userId, order, stepOrder, promptMessageId, ...call } =
79
- await startGeneration(ctx, component, streamTextArgs, options);
100
+ await startGeneration<
101
+ StreamingTextArgs<AgentTools, TOOLS, OUTPUT, RUNTIME_CONTEXT>,
102
+ Tools,
103
+ object,
104
+ RUNTIME_CONTEXT
105
+ >(
106
+ ctx,
107
+ component,
108
+ streamTextArgs,
109
+ options,
110
+ "streamText",
111
+ );
80
112
 
81
- const steps: StepResult<TOOLS>[] = [];
113
+ const steps: StepResult<Tools, RUNTIME_CONTEXT>[] = [];
114
+ let initialResponseMessages: ModelMessage[] = [];
115
+ let initialResponseMessagesSaved = false;
116
+ const responseMessagesForStep = (
117
+ step: StepResult<Tools, RUNTIME_CONTEXT>,
118
+ ) => [
119
+ ...(initialResponseMessagesSaved ? [] : initialResponseMessages),
120
+ ...step.response.messages,
121
+ ];
82
122
 
83
123
  // Track the final step for atomic save with stream finish (issue #181).
84
124
  // Only used when streamText awaits stream consumption itself; the
85
125
  // `returnImmediately` path saves inline instead (see onStepFinish below).
86
- let pendingFinalStep: StepResult<TOOLS> | undefined;
126
+ let pendingFinalStep:
127
+ | {
128
+ step: StepResult<Tools, RUNTIME_CONTEXT>;
129
+ responseMessages: ModelMessage[];
130
+ }
131
+ | undefined;
87
132
 
88
133
  // Whether streamText will await stream consumption before returning.
89
134
  // When false (saveStreamDeltas.returnImmediately === true), we cannot
@@ -123,7 +168,7 @@ export async function streamText<
123
168
  )
124
169
  : undefined;
125
170
 
126
- const result = streamTextAi({
171
+ const result = streamTextAi<Tools, RUNTIME_CONTEXT, OUTPUT>({
127
172
  ...args,
128
173
  abortSignal: streamer?.abortController.signal ?? args.abortSignal,
129
174
  experimental_transform: mergeTransforms(
@@ -136,7 +181,19 @@ export async function streamText<
136
181
  await streamer?.fail(errorToString(error.error));
137
182
  return streamTextArgs.onError?.(error);
138
183
  },
184
+ onAbort: async (event) => {
185
+ const reason = args.abortSignal?.reason
186
+ ? errorToString(args.abortSignal.reason)
187
+ : "streamText aborted";
188
+ await runAbortCleanup({
189
+ failCall: () => call.fail(reason),
190
+ failStreamer: async () => streamer?.fail(reason),
191
+ onAbort: () => streamTextArgs.onAbort?.(event),
192
+ });
193
+ },
139
194
  prepareStep: async (options) => {
195
+ if (options.stepNumber === 0)
196
+ initialResponseMessages = [...options.responseMessages];
140
197
  const result = await streamTextArgs.prepareStep?.(options);
141
198
  if (result) {
142
199
  const model = result.model ?? options.model;
@@ -150,7 +207,7 @@ export async function streamText<
150
207
  }
151
208
  return undefined;
152
209
  },
153
- onStepFinish: async (step) => {
210
+ onStepEnd: async (step) => {
154
211
  steps.push(step);
155
212
  const createPendingMessage = await willContinue(steps, args.stopWhen);
156
213
  if (!createPendingMessage && streamer) {
@@ -159,22 +216,36 @@ export async function streamText<
159
216
  if (willAwaitStream) {
160
217
  // We're about to `await stream` below — defer the save so it
161
218
  // happens atomically with stream finish (issue #181).
162
- pendingFinalStep = step;
219
+ pendingFinalStep = {
220
+ step,
221
+ responseMessages: responseMessagesForStep(step),
222
+ };
163
223
  } else {
164
224
  // returnImmediately path: streamText is about to return without
165
225
  // awaiting consumption, so the deferred-save block below won't
166
226
  // see this step. Save inline now (issue #265).
167
227
  const finishStreamId = await streamer.getOrCreateStreamId();
168
- await call.save({ step }, false, finishStreamId);
228
+ await call.save(
229
+ { step, responseMessages: responseMessagesForStep(step) },
230
+ false,
231
+ finishStreamId,
232
+ );
233
+ initialResponseMessagesSaved = true;
169
234
  }
170
235
  } else {
171
- await call.save({ step }, createPendingMessage);
236
+ await call.save(
237
+ { step, responseMessages: responseMessagesForStep(step) },
238
+ createPendingMessage,
239
+ );
240
+ initialResponseMessagesSaved = true;
172
241
  }
173
- return args.onStepFinish?.(step);
242
+ return (streamTextArgs.onStepEnd ?? streamTextArgs.onStepFinish)?.(step);
174
243
  },
175
- }) as StreamTextResult<TOOLS, OUTPUT>;
244
+ } as Parameters<
245
+ typeof streamTextAi<Tools, RUNTIME_CONTEXT, OUTPUT>
246
+ >[0]) as StreamTextResult<Tools, RUNTIME_CONTEXT, OUTPUT>;
176
247
  const stream = streamer?.consumeStream(
177
- result.toUIMessageStream<AIUIMessage<TOOLS>>(),
248
+ result.toUIMessageStream<AIUIMessage<Tools>>(),
178
249
  );
179
250
  if (willAwaitStream) {
180
251
  try {
@@ -188,7 +259,7 @@ export async function streamText<
188
259
  // Save the deferred final step if it was already generated but not yet persisted
189
260
  if (pendingFinalStep) {
190
261
  try {
191
- await call.save({ step: pendingFinalStep }, false);
262
+ await call.save(pendingFinalStep, false);
192
263
  } catch (saveError) {
193
264
  console.error("Failed to save deferred final step:", saveError);
194
265
  }
@@ -201,7 +272,7 @@ export async function streamText<
201
272
  // If we deferred the final step save, do it now with atomic stream finish.
202
273
  if (pendingFinalStep && streamer) {
203
274
  const finishStreamId = await streamer.getOrCreateStreamId();
204
- await call.save({ step: pendingFinalStep }, false, finishStreamId);
275
+ await call.save(pendingFinalStep, false, finishStreamId);
205
276
  }
206
277
  const metadata: GenerationOutputMetadata = {
207
278
  promptMessageId,
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, test } from "vitest";
2
- import { createThread } from "./index.js";
2
+ import { createThread } from "../../client/threads.js";
3
3
  import type { GenericSchema, SchemaDefinition } from "convex/server";
4
4
  import { streamText } from "ai";
5
5
  import { components, initConvexTest } from "./setup.test.js";
@@ -9,9 +9,12 @@ import {
9
9
  DeltaStreamer,
10
10
  mergeTransforms,
11
11
  } from "./streaming.js";
12
- import { getParts, deriveUIMessagesFromDeltas } from "../deltas.js";
12
+ import {
13
+ getParts,
14
+ deriveUIMessagesFromDeltas,
15
+ } from "../deltas.js";
13
16
  import type { TestConvex } from "convex-test";
14
- import type { StreamDelta, StreamMessage } from "../validators.js";
17
+ import type { StreamDelta, StreamMessage } from "../../validators.js";
15
18
  import { dedupeMessages } from "../react/useUIMessages.js";
16
19
 
17
20
  const defaultTestOptions = {
@@ -1,10 +1,14 @@
1
- import { beforeEach, describe, expect, test } from "vitest";
2
- import { createThread } from "./index.js";
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { createThread } from "../../client/threads.js";
3
+ import type { MutationCtx } from "../../client/types.js";
3
4
  import type { GenericSchema, SchemaDefinition } from "convex/server";
4
5
  import { streamText } from "ai";
5
6
  import { components, initConvexTest } from "./setup.test.js";
6
7
  import { mockModel } from "./mockModel.js";
7
- import { compressUIMessageChunks, DeltaStreamer } from "./streaming.js";
8
+ import {
9
+ compressUIMessageChunks,
10
+ DeltaStreamer,
11
+ } from "./streaming.js";
8
12
  import { getParts } from "../deltas.js";
9
13
  import type { TestConvex } from "convex-test";
10
14
 
@@ -181,6 +185,176 @@ describe("DeltaStreamer", () => {
181
185
  }
182
186
  });
183
187
  });
184
- // TODO: test errors & aborted states
188
+ test("honors a signal that was aborted before construction", async () => {
189
+ const abortController = new AbortController();
190
+ abortController.abort();
191
+
192
+ await t.run(async (ctx) => {
193
+ const streamer = new DeltaStreamer<string>(
194
+ components.agent,
195
+ ctx,
196
+ { ...defaultTestOptions, abortSignal: abortController.signal },
197
+ { ...testMetadata, threadId },
198
+ );
199
+
200
+ expect(streamer.abortController.signal.aborted).toBe(true);
201
+ await streamer.addParts(["ignored"]);
202
+ expect(streamer.streamId).toBeUndefined();
203
+ await expect(streamer.getOrCreateStreamId()).rejects.toThrow(
204
+ "Cannot create a stream after it has been aborted",
205
+ );
206
+ });
207
+ });
208
+
209
+ test("shares signal and fail cleanup while stream creation is in flight", async () => {
210
+ let resolveCreate!: (streamId: string) => void;
211
+ const creatingStream = new Promise<string>((resolve) => {
212
+ resolveCreate = resolve;
213
+ });
214
+ let resolveAbort!: () => void;
215
+ const abortingStream = new Promise<void>((resolve) => {
216
+ resolveAbort = resolve;
217
+ });
218
+ const runMutation = vi
219
+ .fn()
220
+ .mockImplementationOnce(() => creatingStream)
221
+ .mockImplementationOnce(() => abortingStream);
222
+ const abortController = new AbortController();
223
+ const streamer = new DeltaStreamer<string>(
224
+ components.agent,
225
+ { runMutation } as unknown as MutationCtx,
226
+ { ...defaultTestOptions, abortSignal: abortController.signal },
227
+ { ...testMetadata, threadId },
228
+ );
229
+
230
+ const streamId = streamer.getStreamId();
231
+ abortController.abort();
232
+ const failing = streamer.fail("creation failed");
233
+ let failSettled = false;
234
+ void failing.then(() => {
235
+ failSettled = true;
236
+ });
237
+ resolveCreate("stream-1");
238
+ await expect(streamId).resolves.toBe("stream-1");
239
+ await vi.waitFor(() => expect(runMutation).toHaveBeenCalledTimes(2));
240
+
241
+ expect(failSettled).toBe(false);
242
+ resolveAbort();
243
+ await failing;
244
+
245
+ expect(runMutation).toHaveBeenNthCalledWith(
246
+ 2,
247
+ components.agent.streams.abort,
248
+ { streamId: "stream-1", reason: "abortSignal" },
249
+ );
250
+ });
251
+
252
+ test("aborts the component stream when a delta write fails", async () => {
253
+ const runMutation = vi
254
+ .fn()
255
+ .mockResolvedValueOnce("stream-1")
256
+ .mockRejectedValueOnce(new Error("delta failed"))
257
+ .mockResolvedValueOnce(undefined);
258
+ let abortReason: string | undefined;
259
+ const streamer = new DeltaStreamer<string>(
260
+ components.agent,
261
+ { runMutation } as unknown as MutationCtx,
262
+ {
263
+ ...defaultTestOptions,
264
+ onAsyncAbort: async (reason) => {
265
+ abortReason = reason;
266
+ },
267
+ },
268
+ { ...testMetadata, threadId },
269
+ );
270
+
271
+ await streamer.addParts(["A"]);
272
+ await streamer.finish();
273
+
274
+ expect(abortReason).toBe("delta failed");
275
+ expect(runMutation).toHaveBeenNthCalledWith(
276
+ 3,
277
+ components.agent.streams.abort,
278
+ { streamId: "stream-1", reason: "delta failed" },
279
+ );
280
+ });
281
+
282
+ test("surfaces pending-message cleanup failure after aborting the stream", async () => {
283
+ const pendingMessageFailure = new Error("pending message cleanup failed");
284
+ let resolveComponentAbort!: () => void;
285
+ const componentAborted = new Promise<void>((resolve) => {
286
+ resolveComponentAbort = resolve;
287
+ });
288
+ const runMutation = vi
289
+ .fn()
290
+ .mockResolvedValueOnce("stream-1")
291
+ .mockRejectedValueOnce(new Error("delta failed"))
292
+ .mockImplementationOnce(() => {
293
+ resolveComponentAbort();
294
+ return Promise.resolve();
295
+ });
296
+ const streamer = new DeltaStreamer<string>(
297
+ components.agent,
298
+ { runMutation } as unknown as MutationCtx,
299
+ {
300
+ ...defaultTestOptions,
301
+ onAsyncAbort: async () => {
302
+ throw pendingMessageFailure;
303
+ },
304
+ },
305
+ { ...testMetadata, threadId },
306
+ );
307
+ const stream = {
308
+ async *[Symbol.asyncIterator]() {
309
+ yield "A";
310
+ await componentAborted;
311
+ },
312
+ } as unknown as Parameters<typeof streamer.consumeStream>[0];
313
+
314
+ await expect(streamer.consumeStream(stream)).rejects.toBe(
315
+ pendingMessageFailure,
316
+ );
317
+
318
+ expect(runMutation).toHaveBeenNthCalledWith(
319
+ 3,
320
+ components.agent.streams.abort,
321
+ { streamId: "stream-1", reason: "delta failed" },
322
+ );
323
+ });
324
+
325
+ test("finishes external abort cleanup when the active delta write fails", async () => {
326
+ const pendingMessageFailure = new Error("pending message cleanup failed");
327
+ let rejectDelta!: (error: Error) => void;
328
+ const deltaWrite = new Promise<never>((_, reject) => {
329
+ rejectDelta = reject;
330
+ });
331
+ const runMutation = vi
332
+ .fn()
333
+ .mockResolvedValueOnce("stream-1")
334
+ .mockImplementationOnce(() => deltaWrite)
335
+ .mockResolvedValueOnce(undefined);
336
+ const streamer = new DeltaStreamer<string>(
337
+ components.agent,
338
+ { runMutation } as unknown as MutationCtx,
339
+ {
340
+ ...defaultTestOptions,
341
+ onAsyncAbort: async () => {
342
+ throw pendingMessageFailure;
343
+ },
344
+ },
345
+ { ...testMetadata, threadId },
346
+ );
347
+
348
+ await streamer.addParts(["A"]);
349
+ const failing = streamer.fail("external abort");
350
+ rejectDelta(new Error("delta failed"));
351
+
352
+ await expect(failing).rejects.toBe(pendingMessageFailure);
353
+ expect(runMutation).toHaveBeenNthCalledWith(
354
+ 3,
355
+ components.agent.streams.abort,
356
+ { streamId: "stream-1", reason: "external abort" },
357
+ );
358
+ });
185
359
  // TODO: test fetching partial stream data - syncStreams w/ cursors
186
360
  });
@@ -17,7 +17,7 @@ import {
17
17
  type StreamArgs,
18
18
  type StreamDelta,
19
19
  type StreamMessage,
20
- } from "../validators.js";
20
+ } from "../../validators.js";
21
21
  import type {
22
22
  ActionCtx,
23
23
  AgentComponent,
@@ -208,6 +208,7 @@ export class DeltaStreamer<T> {
208
208
  #nextParts: T[] = [];
209
209
  #latestWrite: number = 0;
210
210
  #ongoingWrite: Promise<void> | undefined;
211
+ #abortPromise: Promise<void> | undefined;
211
212
  #cursor: number = 0;
212
213
  public abortController: AbortController;
213
214
  // When true, the stream will be finished externally (e.g., atomically via addMessages)
@@ -243,34 +244,28 @@ export class DeltaStreamer<T> {
243
244
  this.#nextParts = [];
244
245
  this.abortController = new AbortController();
245
246
  if (config.abortSignal) {
246
- config.abortSignal.addEventListener("abort", async () => {
247
- try {
248
- if (this.abortController.signal.aborted) {
249
- return;
250
- }
251
- this.abortController.abort();
252
- // Wait for in-flight stream creation before trying to abort it
253
- if (this.#creatingStreamIdPromise) {
254
- await this.#creatingStreamIdPromise;
255
- }
256
- if (this.streamId) {
257
- await this.#ongoingWrite;
258
- await this.ctx.runMutation(this.component.streams.abort, {
259
- streamId: this.streamId,
260
- reason: "abortSignal",
261
- });
262
- }
263
- } catch {
264
- // Best-effort cleanup — the stream will be garbage-collected
265
- // by the 10-minute timeout if this fails.
266
- }
267
- });
247
+ const abortFromSignal = () => {
248
+ void this.#abort("abortSignal").catch(() => {
249
+ // Best-effort cleanup — the stream timeout is the fallback.
250
+ });
251
+ };
252
+ if (config.abortSignal.aborted) {
253
+ abortFromSignal();
254
+ } else {
255
+ config.abortSignal.addEventListener("abort", abortFromSignal, {
256
+ once: true,
257
+ });
258
+ }
268
259
  }
269
260
  }
270
261
 
271
262
  // Avoid race conditions by only creating once
272
263
  #creatingStreamIdPromise: Promise<string> | undefined;
273
264
  public async getStreamId() {
265
+ if (this.abortController.signal.aborted) {
266
+ await this.#abortPromise;
267
+ throw new Error("Cannot create a stream after it has been aborted");
268
+ }
274
269
  if (!this.streamId) {
275
270
  if (!this.#creatingStreamIdPromise) {
276
271
  this.#creatingStreamIdPromise = this.ctx.runMutation(
@@ -310,9 +305,11 @@ export class DeltaStreamer<T> {
310
305
  }
311
306
  // Skip finish if it will be handled externally (atomically with message save)
312
307
  // or if the stream was aborted (e.g., due to a failed delta write).
313
- // Aborted streams are cleaned up via streams.abort (called by the abort
314
- // signal handler), so we don't need to call finish() for them.
315
- if (!this.#finishedExternally && !this.abortController.signal.aborted) {
308
+ // Abort cleanup owns the terminal component transition, so consumeStream
309
+ // must wait for it instead of also trying to finish the stream.
310
+ if (this.abortController.signal.aborted) {
311
+ await this.#waitForAbortCleanup();
312
+ } else if (!this.#finishedExternally) {
316
313
  await this.finish();
317
314
  }
318
315
  }
@@ -343,28 +340,27 @@ export class DeltaStreamer<T> {
343
340
  return;
344
341
  }
345
342
  this.#latestWrite = Date.now();
343
+ let success: boolean;
346
344
  try {
347
- const success = await this.ctx.runMutation(
345
+ success = await this.ctx.runMutation(
348
346
  this.component.streams.addDelta,
349
347
  delta,
350
348
  );
351
- if (!success) {
352
- // An in-flight #sendDelta started before markFinishedExternally()
353
- // will get `success === false` because the stream row is already
354
- // "finished". That's a benign late-write miss, not a failure —
355
- // don't convert it into an abort.
356
- if (this.#finishedExternally) {
357
- return;
358
- }
359
- await this.config.onAsyncAbort("async abort");
360
- this.abortController.abort();
361
- return;
362
- }
363
349
  } catch (e) {
364
- await this.config.onAsyncAbort(
350
+ await this.#abortDelta(
365
351
  e instanceof Error ? e.message : "unknown error",
366
352
  );
367
- this.abortController.abort();
353
+ return;
354
+ }
355
+ if (!success) {
356
+ // An in-flight #sendDelta started before markFinishedExternally()
357
+ // will get `success === false` because the stream row is already
358
+ // "finished". That's a benign late-write miss, not a failure —
359
+ // don't convert it into an abort.
360
+ if (this.#finishedExternally) {
361
+ return;
362
+ }
363
+ await this.#abortDelta("async abort");
368
364
  return;
369
365
  }
370
366
  // Now that we've sent the delta, check if we need to send another one.
@@ -400,9 +396,17 @@ export class DeltaStreamer<T> {
400
396
  if (!this.streamId) {
401
397
  return;
402
398
  }
403
- await this.#ongoingWrite;
399
+ try {
400
+ await this.#ongoingWrite;
401
+ } catch (error) {
402
+ if (this.abortController.signal.aborted) {
403
+ await this.#waitForAbortCleanup();
404
+ }
405
+ throw error;
406
+ }
404
407
  await this.#sendDelta(); // #sendDelta checks aborted internally
405
408
  if (this.abortController.signal.aborted) {
409
+ await this.#waitForAbortCleanup();
406
410
  return;
407
411
  }
408
412
  await this.ctx.runMutation(this.component.streams.finish, {
@@ -411,18 +415,76 @@ export class DeltaStreamer<T> {
411
415
  }
412
416
 
413
417
  public async fail(reason: string) {
414
- if (this.abortController.signal.aborted) {
415
- return;
418
+ await this.#abort(reason);
419
+ }
420
+
421
+ async #abortDelta(reason: string) {
422
+ let callbackFailure: { error: unknown } | undefined;
423
+ try {
424
+ await this.config.onAsyncAbort(reason);
425
+ } catch (error) {
426
+ callbackFailure = { error };
416
427
  }
417
- this.abortController.abort();
418
- if (!this.streamId) {
419
- return;
428
+ if (!this.#abortPromise) {
429
+ try {
430
+ await this.#abort(reason, false);
431
+ } catch {
432
+ // The stream timeout is the bounded cleanup fallback.
433
+ }
420
434
  }
421
- await this.#ongoingWrite;
422
- await this.ctx.runMutation(this.component.streams.abort, {
423
- streamId: this.streamId,
424
- reason,
425
- });
435
+ if (callbackFailure) throw callbackFailure.error;
436
+ }
437
+
438
+ #abort(reason: string, waitForOngoingWrite = true): Promise<void> {
439
+ if (!this.#abortPromise) {
440
+ this.abortController.abort();
441
+ this.#abortPromise = this.#abortCreatedStream(
442
+ reason,
443
+ waitForOngoingWrite,
444
+ );
445
+ }
446
+ return this.#abortPromise;
447
+ }
448
+
449
+ async #waitForAbortCleanup() {
450
+ let writeFailure: { error: unknown } | undefined;
451
+ try {
452
+ await this.#ongoingWrite;
453
+ } catch (error) {
454
+ writeFailure = { error };
455
+ }
456
+ try {
457
+ await this.#abortPromise;
458
+ } catch (error) {
459
+ writeFailure ??= { error };
460
+ }
461
+ if (writeFailure) throw writeFailure.error;
462
+ }
463
+
464
+ async #abortCreatedStream(reason: string, waitForOngoingWrite: boolean) {
465
+ if (this.#creatingStreamIdPromise) {
466
+ this.streamId ??= await this.#creatingStreamIdPromise;
467
+ }
468
+ if (!this.streamId) return;
469
+ let writeFailure: { error: unknown } | undefined;
470
+ if (waitForOngoingWrite) {
471
+ try {
472
+ await this.#ongoingWrite;
473
+ } catch (error) {
474
+ writeFailure = { error };
475
+ }
476
+ }
477
+ let abortFailure: { error: unknown } | undefined;
478
+ try {
479
+ await this.ctx.runMutation(this.component.streams.abort, {
480
+ streamId: this.streamId,
481
+ reason,
482
+ });
483
+ } catch (error) {
484
+ abortFailure = { error };
485
+ }
486
+ if (writeFailure) throw writeFailure.error;
487
+ if (abortFailure) throw abortFailure.error;
426
488
  }
427
489
  }
428
490