@convex-dev/agent 0.6.0-alpha.0 → 0.6.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 (192) hide show
  1. package/MIGRATION.md +153 -0
  2. package/package.json +4 -2
  3. package/src/UIMessages.ts +135 -0
  4. package/src/client/approval.test.ts +350 -0
  5. package/src/client/createTool.ts +50 -52
  6. package/src/client/index.ts +162 -1
  7. package/src/client/mockModel.ts +9 -2
  8. package/src/client/search.test.ts +4 -5
  9. package/src/client/search.ts +47 -2
  10. package/src/client/start.ts +41 -24
  11. package/src/client/streamText.ts +27 -3
  12. package/src/client/streaming.integration.test.ts +1206 -0
  13. package/src/client/streaming.ts +41 -12
  14. package/src/client/types.ts +14 -12
  15. package/src/component/_generated/component.ts +53 -64
  16. package/src/component/messages.ts +12 -2
  17. package/src/deltas.ts +0 -1
  18. package/src/mapping.test.ts +143 -1
  19. package/src/mapping.ts +115 -6
  20. package/src/react/useDeltaStreams.ts +6 -0
  21. package/dist/UIMessages.d.ts +0 -46
  22. package/dist/UIMessages.d.ts.map +0 -1
  23. package/dist/UIMessages.js +0 -546
  24. package/dist/UIMessages.js.map +0 -1
  25. package/dist/client/createTool.d.ts +0 -170
  26. package/dist/client/createTool.d.ts.map +0 -1
  27. package/dist/client/createTool.js +0 -115
  28. package/dist/client/createTool.js.map +0 -1
  29. package/dist/client/defaultComponent.d.ts +0 -11
  30. package/dist/client/defaultComponent.d.ts.map +0 -1
  31. package/dist/client/defaultComponent.js +0 -7
  32. package/dist/client/defaultComponent.js.map +0 -1
  33. package/dist/client/definePlaygroundAPI.d.ts +0 -1725
  34. package/dist/client/definePlaygroundAPI.d.ts.map +0 -1
  35. package/dist/client/definePlaygroundAPI.js +0 -271
  36. package/dist/client/definePlaygroundAPI.js.map +0 -1
  37. package/dist/client/files.d.ts +0 -69
  38. package/dist/client/files.d.ts.map +0 -1
  39. package/dist/client/files.js +0 -181
  40. package/dist/client/files.js.map +0 -1
  41. package/dist/client/index.d.ts +0 -2044
  42. package/dist/client/index.d.ts.map +0 -1
  43. package/dist/client/index.js +0 -804
  44. package/dist/client/index.js.map +0 -1
  45. package/dist/client/messages.d.ts +0 -461
  46. package/dist/client/messages.d.ts.map +0 -1
  47. package/dist/client/messages.js +0 -106
  48. package/dist/client/messages.js.map +0 -1
  49. package/dist/client/mockModel.d.ts +0 -42
  50. package/dist/client/mockModel.d.ts.map +0 -1
  51. package/dist/client/mockModel.js +0 -175
  52. package/dist/client/mockModel.js.map +0 -1
  53. package/dist/client/saveInputMessages.d.ts +0 -20
  54. package/dist/client/saveInputMessages.d.ts.map +0 -1
  55. package/dist/client/saveInputMessages.js +0 -58
  56. package/dist/client/saveInputMessages.js.map +0 -1
  57. package/dist/client/search.d.ts +0 -493
  58. package/dist/client/search.d.ts.map +0 -1
  59. package/dist/client/search.js +0 -425
  60. package/dist/client/search.js.map +0 -1
  61. package/dist/client/start.d.ts +0 -84
  62. package/dist/client/start.d.ts.map +0 -1
  63. package/dist/client/start.js +0 -171
  64. package/dist/client/start.js.map +0 -1
  65. package/dist/client/streamText.d.ts +0 -46
  66. package/dist/client/streamText.d.ts.map +0 -1
  67. package/dist/client/streamText.js +0 -93
  68. package/dist/client/streamText.js.map +0 -1
  69. package/dist/client/streaming.d.ts +0 -3767
  70. package/dist/client/streaming.d.ts.map +0 -1
  71. package/dist/client/streaming.js +0 -294
  72. package/dist/client/streaming.js.map +0 -1
  73. package/dist/client/threads.d.ts +0 -46
  74. package/dist/client/threads.d.ts.map +0 -1
  75. package/dist/client/threads.js +0 -49
  76. package/dist/client/threads.js.map +0 -1
  77. package/dist/client/types.d.ts +0 -460
  78. package/dist/client/types.d.ts.map +0 -1
  79. package/dist/client/types.js +0 -2
  80. package/dist/client/types.js.map +0 -1
  81. package/dist/client/utils.d.ts +0 -4
  82. package/dist/client/utils.d.ts.map +0 -1
  83. package/dist/client/utils.js +0 -21
  84. package/dist/client/utils.js.map +0 -1
  85. package/dist/component/_generated/api.d.ts +0 -48
  86. package/dist/component/_generated/api.d.ts.map +0 -1
  87. package/dist/component/_generated/api.js +0 -31
  88. package/dist/component/_generated/api.js.map +0 -1
  89. package/dist/component/_generated/component.d.ts +0 -3119
  90. package/dist/component/_generated/component.d.ts.map +0 -1
  91. package/dist/component/_generated/component.js +0 -11
  92. package/dist/component/_generated/component.js.map +0 -1
  93. package/dist/component/_generated/dataModel.d.ts +0 -46
  94. package/dist/component/_generated/dataModel.d.ts.map +0 -1
  95. package/dist/component/_generated/dataModel.js +0 -11
  96. package/dist/component/_generated/dataModel.js.map +0 -1
  97. package/dist/component/_generated/server.d.ts +0 -121
  98. package/dist/component/_generated/server.d.ts.map +0 -1
  99. package/dist/component/_generated/server.js +0 -78
  100. package/dist/component/_generated/server.js.map +0 -1
  101. package/dist/component/apiKeys.d.ts +0 -11
  102. package/dist/component/apiKeys.d.ts.map +0 -1
  103. package/dist/component/apiKeys.js +0 -69
  104. package/dist/component/apiKeys.js.map +0 -1
  105. package/dist/component/convex.config.d.ts +0 -3
  106. package/dist/component/convex.config.d.ts.map +0 -1
  107. package/dist/component/convex.config.js +0 -3
  108. package/dist/component/convex.config.js.map +0 -1
  109. package/dist/component/files.d.ts +0 -97
  110. package/dist/component/files.d.ts.map +0 -1
  111. package/dist/component/files.js +0 -190
  112. package/dist/component/files.js.map +0 -1
  113. package/dist/component/messages.d.ts +0 -3850
  114. package/dist/component/messages.d.ts.map +0 -1
  115. package/dist/component/messages.js +0 -747
  116. package/dist/component/messages.js.map +0 -1
  117. package/dist/component/schema.d.ts +0 -8026
  118. package/dist/component/schema.d.ts.map +0 -1
  119. package/dist/component/schema.js +0 -147
  120. package/dist/component/schema.js.map +0 -1
  121. package/dist/component/streams.d.ts +0 -128
  122. package/dist/component/streams.d.ts.map +0 -1
  123. package/dist/component/streams.js +0 -413
  124. package/dist/component/streams.js.map +0 -1
  125. package/dist/component/threads.d.ts +0 -115
  126. package/dist/component/threads.d.ts.map +0 -1
  127. package/dist/component/threads.js +0 -208
  128. package/dist/component/threads.js.map +0 -1
  129. package/dist/component/users.d.ts +0 -52
  130. package/dist/component/users.d.ts.map +0 -1
  131. package/dist/component/users.js +0 -229
  132. package/dist/component/users.js.map +0 -1
  133. package/dist/component/vector/index.d.ts +0 -61
  134. package/dist/component/vector/index.d.ts.map +0 -1
  135. package/dist/component/vector/index.js +0 -146
  136. package/dist/component/vector/index.js.map +0 -1
  137. package/dist/component/vector/tables.d.ts +0 -58
  138. package/dist/component/vector/tables.d.ts.map +0 -1
  139. package/dist/component/vector/tables.js +0 -56
  140. package/dist/component/vector/tables.js.map +0 -1
  141. package/dist/deltas.d.ts +0 -43
  142. package/dist/deltas.d.ts.map +0 -1
  143. package/dist/deltas.js +0 -447
  144. package/dist/deltas.js.map +0 -1
  145. package/dist/mapping.d.ts +0 -54
  146. package/dist/mapping.d.ts.map +0 -1
  147. package/dist/mapping.js +0 -626
  148. package/dist/mapping.js.map +0 -1
  149. package/dist/react/SmoothText.d.ts +0 -5
  150. package/dist/react/SmoothText.d.ts.map +0 -1
  151. package/dist/react/SmoothText.js +0 -6
  152. package/dist/react/SmoothText.js.map +0 -1
  153. package/dist/react/index.d.ts +0 -25
  154. package/dist/react/index.d.ts.map +0 -1
  155. package/dist/react/index.js +0 -70
  156. package/dist/react/index.js.map +0 -1
  157. package/dist/react/optimisticallySendMessage.d.ts +0 -42
  158. package/dist/react/optimisticallySendMessage.d.ts.map +0 -1
  159. package/dist/react/optimisticallySendMessage.js +0 -74
  160. package/dist/react/optimisticallySendMessage.js.map +0 -1
  161. package/dist/react/types.d.ts +0 -12
  162. package/dist/react/types.d.ts.map +0 -1
  163. package/dist/react/types.js +0 -2
  164. package/dist/react/types.js.map +0 -1
  165. package/dist/react/useDeltaStreams.d.ts +0 -10
  166. package/dist/react/useDeltaStreams.d.ts.map +0 -1
  167. package/dist/react/useDeltaStreams.js +0 -101
  168. package/dist/react/useDeltaStreams.js.map +0 -1
  169. package/dist/react/useSmoothText.d.ts +0 -27
  170. package/dist/react/useSmoothText.d.ts.map +0 -1
  171. package/dist/react/useSmoothText.js +0 -68
  172. package/dist/react/useSmoothText.js.map +0 -1
  173. package/dist/react/useStreamingUIMessages.d.ts +0 -22
  174. package/dist/react/useStreamingUIMessages.d.ts.map +0 -1
  175. package/dist/react/useStreamingUIMessages.js +0 -92
  176. package/dist/react/useStreamingUIMessages.js.map +0 -1
  177. package/dist/react/useThreadMessages.d.ts +0 -104
  178. package/dist/react/useThreadMessages.d.ts.map +0 -1
  179. package/dist/react/useThreadMessages.js +0 -148
  180. package/dist/react/useThreadMessages.js.map +0 -1
  181. package/dist/react/useUIMessages.d.ts +0 -96
  182. package/dist/react/useUIMessages.d.ts.map +0 -1
  183. package/dist/react/useUIMessages.js +0 -108
  184. package/dist/react/useUIMessages.js.map +0 -1
  185. package/dist/shared.d.ts +0 -26
  186. package/dist/shared.d.ts.map +0 -1
  187. package/dist/shared.js +0 -67
  188. package/dist/shared.js.map +0 -1
  189. package/dist/validators.d.ts +0 -24516
  190. package/dist/validators.d.ts.map +0 -1
  191. package/dist/validators.js +0 -475
  192. package/dist/validators.js.map +0 -1
@@ -210,6 +210,9 @@ export class DeltaStreamer<T> {
210
210
  #ongoingWrite: Promise<void> | undefined;
211
211
  #cursor: number = 0;
212
212
  public abortController: AbortController;
213
+ // When true, the stream will be finished externally (e.g., atomically via addMessages)
214
+ // and consumeStream should skip calling finish().
215
+ #finishedExternally: boolean = false;
213
216
 
214
217
  constructor(
215
218
  public readonly component: AgentComponent,
@@ -259,17 +262,16 @@ export class DeltaStreamer<T> {
259
262
  // Avoid race conditions by only creating once
260
263
  #creatingStreamIdPromise: Promise<string> | undefined;
261
264
  public async getStreamId() {
262
- if (this.streamId) {
263
- return this.streamId;
264
- }
265
- if (this.#creatingStreamIdPromise) {
266
- return this.#creatingStreamIdPromise;
265
+ if (!this.streamId) {
266
+ if (!this.#creatingStreamIdPromise) {
267
+ this.#creatingStreamIdPromise = this.ctx.runMutation(
268
+ this.component.streams.create,
269
+ this.metadata,
270
+ );
271
+ }
272
+ this.streamId = await this.#creatingStreamIdPromise;
267
273
  }
268
- this.#creatingStreamIdPromise = this.ctx.runMutation(
269
- this.component.streams.create,
270
- this.metadata,
271
- );
272
- this.streamId = await this.#creatingStreamIdPromise;
274
+ return this.streamId;
273
275
  }
274
276
 
275
277
  public async addParts(parts: T[]) {
@@ -290,7 +292,28 @@ export class DeltaStreamer<T> {
290
292
  for await (const chunk of stream) {
291
293
  await this.addParts([chunk]);
292
294
  }
293
- await this.finish();
295
+ // Skip finish if it will be handled externally (atomically with message save)
296
+ // or if the stream was aborted (e.g., due to a failed delta write)
297
+ if (!this.#finishedExternally && !this.abortController.signal.aborted) {
298
+ await this.finish();
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Mark the stream as being finished externally (e.g., atomically via addMessages).
304
+ * When called, consumeStream() will skip calling finish() since it will be
305
+ * handled elsewhere in the same mutation as message saving.
306
+ */
307
+ public markFinishedExternally(): void {
308
+ this.#finishedExternally = true;
309
+ }
310
+
311
+ /**
312
+ * Get the stream ID, waiting for it to be created if necessary.
313
+ * Useful for passing to addMessages for atomic finish.
314
+ */
315
+ public async getOrCreateStreamId(): Promise<string> {
316
+ return this.getStreamId();
294
317
  }
295
318
 
296
319
  async #sendDelta() {
@@ -317,7 +340,7 @@ export class DeltaStreamer<T> {
317
340
  e instanceof Error ? e.message : "unknown error",
318
341
  );
319
342
  this.abortController.abort();
320
- throw e;
343
+ return;
321
344
  }
322
345
  // Now that we've sent the delta, check if we need to send another one.
323
346
  if (
@@ -353,7 +376,13 @@ export class DeltaStreamer<T> {
353
376
  return;
354
377
  }
355
378
  await this.#ongoingWrite;
379
+ if (this.abortController.signal.aborted) {
380
+ return;
381
+ }
356
382
  await this.#sendDelta();
383
+ if (this.abortController.signal.aborted) {
384
+ return;
385
+ }
357
386
  await this.ctx.runMutation(this.component.streams.finish, {
358
387
  streamId: this.streamId,
359
388
  });
@@ -46,6 +46,14 @@ import type {
46
46
  import type { StreamingOptions } from "./streaming.js";
47
47
  import type { ComponentApi } from "../component/_generated/component.js";
48
48
 
49
+ /**
50
+ * Type-level check that ensures models are from AI SDK v6.
51
+ * If a v5 model (LanguageModelV2) is passed, TypeScript will show the error message string.
52
+ */
53
+ type AssertAISDKv6<T> = T extends { specificationVersion: "v3" }
54
+ ? T
55
+ : "⚠️ @convex-dev/agent v0.6.0 requires AI SDK v6. Update your dependencies: npm install ai@^6.0.35 @ai-sdk/openai@^3.0.10 (or other provider). See: node_modules/@convex-dev/agent/MIGRATION.md";
56
+
49
57
  export type AgentPrompt = {
50
58
  /**
51
59
  * System message to include in the prompt. Overwrites Agent instructions.
@@ -91,23 +99,17 @@ export type AgentPrompt = {
91
99
  export type Config = {
92
100
  /**
93
101
  * The LLM model to use for generating / streaming text and objects.
94
- * e.g.
102
+ * Requires AI SDK v6 (@ai-sdk/* packages v3.x).
103
+ *
104
+ * @example
95
105
  * import { openai } from "@ai-sdk/openai"
96
106
  * const myAgent = new Agent(components.agent, {
97
107
  * languageModel: openai.chat("gpt-4o-mini"),
108
+ * })
98
109
  */
99
- languageModel?: LanguageModel;
110
+ languageModel?: AssertAISDKv6<LanguageModel>;
100
111
  /**
101
- * The model to use for text embeddings. Optional.
102
- * If specified, it will use this for generating vector embeddings
103
- * of chats, and can opt-in to doing vector search for automatic context
104
- * on generateText, etc.
105
- * e.g.
106
- * import { openai } from "@ai-sdk/openai"
107
- * const myAgent = new Agent(components.agent, {
108
- * ...
109
- * textEmbeddingModel: openai.embedding("text-embedding-3-small")
110
- * @deprecated — Use embeddingModel instead.
112
+ * @deprecated Use `embeddingModel` instead.
111
113
  */
112
114
  textEmbeddingModel?: EmbeddingModel;
113
115
  /**
@@ -154,6 +154,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
154
154
  vectors: Array<Array<number> | null>;
155
155
  };
156
156
  failPendingSteps?: boolean;
157
+ finishStreamId?: string;
157
158
  hideFromUserIdSearch?: boolean;
158
159
  messages: Array<{
159
160
  error?: string;
@@ -1420,22 +1421,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1420
1421
  toolName: string;
1421
1422
  type: "tool-call";
1422
1423
  }
1423
- | {
1424
- args: any;
1425
- input?: any;
1426
- providerExecuted?: boolean;
1427
- providerMetadata?: Record<
1428
- string,
1429
- Record<string, any>
1430
- >;
1431
- providerOptions?: Record<
1432
- string,
1433
- Record<string, any>
1434
- >;
1435
- toolCallId: string;
1436
- toolName: string;
1437
- type: "tool-call";
1438
- }
1424
+ | {
1425
+ args: any;
1426
+ input?: any;
1427
+ providerExecuted?: boolean;
1428
+ providerMetadata?: Record<
1429
+ string,
1430
+ Record<string, any>
1431
+ >;
1432
+ providerOptions?: Record<string, Record<string, any>>;
1433
+ toolCallId: string;
1434
+ toolName: string;
1435
+ type: "tool-call";
1436
+ }
1439
1437
  | {
1440
1438
  args?: any;
1441
1439
  experimental_content?: Array<
@@ -2533,22 +2531,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2533
2531
  toolName: string;
2534
2532
  type: "tool-call";
2535
2533
  }
2536
- | {
2537
- args: any;
2538
- input?: any;
2539
- providerExecuted?: boolean;
2540
- providerMetadata?: Record<
2541
- string,
2542
- Record<string, any>
2543
- >;
2544
- providerOptions?: Record<
2545
- string,
2546
- Record<string, any>
2547
- >;
2548
- toolCallId: string;
2549
- toolName: string;
2550
- type: "tool-call";
2551
- }
2534
+ | {
2535
+ args: any;
2536
+ input?: any;
2537
+ providerExecuted?: boolean;
2538
+ providerMetadata?: Record<
2539
+ string,
2540
+ Record<string, any>
2541
+ >;
2542
+ providerOptions?: Record<string, Record<string, any>>;
2543
+ toolCallId: string;
2544
+ toolName: string;
2545
+ type: "tool-call";
2546
+ }
2552
2547
  | {
2553
2548
  args?: any;
2554
2549
  experimental_content?: Array<
@@ -3054,22 +3049,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
3054
3049
  toolName: string;
3055
3050
  type: "tool-call";
3056
3051
  }
3057
- | {
3058
- args: any;
3059
- input?: any;
3060
- providerExecuted?: boolean;
3061
- providerMetadata?: Record<
3062
- string,
3063
- Record<string, any>
3064
- >;
3065
- providerOptions?: Record<
3066
- string,
3067
- Record<string, any>
3068
- >;
3069
- toolCallId: string;
3070
- toolName: string;
3071
- type: "tool-call";
3072
- }
3052
+ | {
3053
+ args: any;
3054
+ input?: any;
3055
+ providerExecuted?: boolean;
3056
+ providerMetadata?: Record<
3057
+ string,
3058
+ Record<string, any>
3059
+ >;
3060
+ providerOptions?: Record<string, Record<string, any>>;
3061
+ toolCallId: string;
3062
+ toolName: string;
3063
+ type: "tool-call";
3064
+ }
3073
3065
  | {
3074
3066
  args?: any;
3075
3067
  experimental_content?: Array<
@@ -4063,22 +4055,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
4063
4055
  toolName: string;
4064
4056
  type: "tool-call";
4065
4057
  }
4066
- | {
4067
- args: any;
4068
- input?: any;
4069
- providerExecuted?: boolean;
4070
- providerMetadata?: Record<
4071
- string,
4072
- Record<string, any>
4073
- >;
4074
- providerOptions?: Record<
4075
- string,
4076
- Record<string, any>
4077
- >;
4078
- toolCallId: string;
4079
- toolName: string;
4080
- type: "tool-call";
4081
- }
4058
+ | {
4059
+ args: any;
4060
+ input?: any;
4061
+ providerExecuted?: boolean;
4062
+ providerMetadata?: Record<
4063
+ string,
4064
+ Record<string, any>
4065
+ >;
4066
+ providerOptions?: Record<string, Record<string, any>>;
4067
+ toolCallId: string;
4068
+ toolName: string;
4069
+ type: "tool-call";
4070
+ }
4082
4071
  | {
4083
4072
  args?: any;
4084
4073
  experimental_content?: Array<
@@ -39,7 +39,7 @@ import {
39
39
  vVectorId,
40
40
  } from "./vector/tables.js";
41
41
  import { changeRefcount } from "./files.js";
42
- import { getStreamingMessagesWithMetadata } from "./streams.js";
42
+ import { getStreamingMessagesWithMetadata, finishHandler } from "./streams.js";
43
43
  import { partial } from "convex-helpers/validators";
44
44
 
45
45
  function publicMessage(message: Doc<"messages">): MessageDoc {
@@ -141,6 +141,9 @@ const addMessagesArgs = {
141
141
  // if set to true, these messages will not show up in text or vector search
142
142
  // results for the userId
143
143
  hideFromUserIdSearch: v.optional(v.boolean()),
144
+ // If provided, finish this stream atomically with the message save.
145
+ // This prevents UI flickering from separate mutations (issue #181).
146
+ finishStreamId: v.optional(v.id("streamingMessages")),
144
147
  };
145
148
  export const addMessages = mutation({
146
149
  args: addMessagesArgs,
@@ -161,6 +164,8 @@ async function addMessagesHandler(
161
164
  const {
162
165
  embeddings,
163
166
  failPendingSteps,
167
+ // Destructured separately to exclude from `...rest` (used in addMessages args, not message fields)
168
+ finishStreamId,
164
169
  messages,
165
170
  promptMessageId,
166
171
  pendingMessageId,
@@ -274,7 +279,7 @@ async function addMessagesHandler(
274
279
  order: pendingMessage.order,
275
280
  stepOrder: pendingMessage.stepOrder,
276
281
  });
277
- toReturn.push(pendingMessage);
282
+ toReturn.push((await ctx.db.get(pendingMessage._id))!);
278
283
  continue;
279
284
  }
280
285
  if (message.message.role === "user") {
@@ -303,6 +308,11 @@ async function addMessagesHandler(
303
308
  // TODO: delete the associated stream data for the order/stepOrder
304
309
  toReturn.push((await ctx.db.get(messageId))!);
305
310
  }
311
+ // Atomically finish the stream if requested, preventing UI flickering
312
+ // from separate mutations for message save and stream finish (issue #181).
313
+ if (finishStreamId) {
314
+ await finishHandler(ctx, { streamId: finishStreamId });
315
+ }
306
316
  return { messages: toReturn.map(publicMessage) };
307
317
  }
308
318
 
package/src/deltas.ts CHANGED
@@ -124,7 +124,6 @@ export async function deriveUIMessagesFromDeltas(
124
124
  blankUIMessage(streamMessage, threadId),
125
125
  parts,
126
126
  );
127
- // TODO: this fails on partial tool calls
128
127
  messages.push(uiMessage);
129
128
  } else {
130
129
  const [uiMessages] = deriveUIMessagesFromTextStreamParts(
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "vitest";
1
+ import { describe, test, expect, vi } from "vitest";
2
2
  import {
3
3
  guessMimeType,
4
4
  serializeDataOrUrl,
@@ -7,6 +7,7 @@ import {
7
7
  toModelMessage,
8
8
  serializeContent,
9
9
  toModelMessageContent,
10
+ autoDenyUnresolvedApprovals,
10
11
  } from "./mapping.js";
11
12
  import { api } from "./component/_generated/api.js";
12
13
  import type { AgentComponent, ActionCtx } from "./client/types.js";
@@ -257,4 +258,145 @@ describe("mapping", () => {
257
258
  expect(content).toHaveLength(1);
258
259
  expect((content as unknown[])[0]).toMatchObject(approvalResponse);
259
260
  });
261
+
262
+ describe("autoDenyUnresolvedApprovals", () => {
263
+ test("returns messages unchanged when no unresolved approvals", () => {
264
+ const messages = [
265
+ { role: "user" as const, content: "hello" },
266
+ {
267
+ role: "assistant" as const,
268
+ content: [
269
+ { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
270
+ { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
271
+ ],
272
+ },
273
+ {
274
+ role: "tool" as const,
275
+ content: [
276
+ { type: "tool-approval-response", approvalId: "ap1", approved: true },
277
+ ],
278
+ },
279
+ ] as any;
280
+
281
+ const result = autoDenyUnresolvedApprovals(messages);
282
+ expect(result).toBe(messages); // same reference, no changes
283
+ });
284
+
285
+ test("injects synthetic denial for a single unresolved approval", () => {
286
+ const messages = [
287
+ { role: "user" as const, content: "hello" },
288
+ {
289
+ role: "assistant" as const,
290
+ content: [
291
+ { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
292
+ { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
293
+ ],
294
+ },
295
+ { role: "user" as const, content: "new message" },
296
+ ] as any;
297
+
298
+ const result = autoDenyUnresolvedApprovals(messages);
299
+ expect(result).toHaveLength(4); // original 3 + 1 synthetic tool message
300
+ // Synthetic denial should be inserted right after the assistant message (index 1)
301
+ expect(result[2].role).toBe("tool");
302
+ const denialContent = result[2].content as any[];
303
+ expect(denialContent).toHaveLength(1);
304
+ expect(denialContent[0].type).toBe("tool-approval-response");
305
+ expect(denialContent[0].approvalId).toBe("ap1");
306
+ expect(denialContent[0].approved).toBe(false);
307
+ expect(denialContent[0].reason).toBe("auto-denied: new generation started");
308
+ // The new user message should follow
309
+ expect(result[3].role).toBe("user");
310
+ expect(result[3].content).toBe("new message");
311
+ });
312
+
313
+ test("groups multiple unresolved approvals from the same step into a single synthetic message", () => {
314
+ const messages = [
315
+ {
316
+ role: "assistant" as const,
317
+ content: [
318
+ { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
319
+ { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
320
+ { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
321
+ { type: "tool-approval-request", approvalId: "ap2", toolCallId: "tc2" },
322
+ ],
323
+ },
324
+ ] as any;
325
+
326
+ const result = autoDenyUnresolvedApprovals(messages);
327
+ expect(result).toHaveLength(2); // assistant + 1 synthetic tool message
328
+ expect(result[1].role).toBe("tool");
329
+ const denialContent = result[1].content as any[];
330
+ expect(denialContent).toHaveLength(2);
331
+ expect(denialContent[0].approvalId).toBe("ap1");
332
+ expect(denialContent[0].approved).toBe(false);
333
+ expect(denialContent[1].approvalId).toBe("ap2");
334
+ expect(denialContent[1].approved).toBe(false);
335
+ });
336
+
337
+ test("only auto-denies unresolved approvals, leaves resolved ones alone", () => {
338
+ const messages = [
339
+ {
340
+ role: "assistant" as const,
341
+ content: [
342
+ { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
343
+ { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
344
+ { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
345
+ { type: "tool-approval-request", approvalId: "ap2", toolCallId: "tc2" },
346
+ ],
347
+ },
348
+ {
349
+ role: "tool" as const,
350
+ content: [
351
+ { type: "tool-approval-response", approvalId: "ap1", approved: true },
352
+ ],
353
+ },
354
+ { role: "user" as const, content: "next question" },
355
+ ] as any;
356
+
357
+ const result = autoDenyUnresolvedApprovals(messages);
358
+ // Should inject a denial for ap2 (unresolved) after the assistant message
359
+ expect(result).toHaveLength(4); // assistant + existing tool + synthetic denial + user
360
+ // The synthetic denial is inserted after the assistant (index 0)
361
+ expect(result[0].role).toBe("assistant");
362
+ expect(result[1].role).toBe("tool"); // synthetic denial for ap2
363
+ const denialContent = result[1].content as any[];
364
+ expect(denialContent).toHaveLength(1);
365
+ expect(denialContent[0].approvalId).toBe("ap2");
366
+ expect(denialContent[0].approved).toBe(false);
367
+ // Original tool message (ap1 response) follows
368
+ expect(result[2].role).toBe("tool");
369
+ const originalToolContent = result[2].content as any[];
370
+ expect(originalToolContent[0].approvalId).toBe("ap1");
371
+ expect(originalToolContent[0].approved).toBe(true);
372
+ // User message last
373
+ expect(result[3].role).toBe("user");
374
+ });
375
+
376
+ test("emits console.warn for each auto-denied approval", () => {
377
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
378
+ const messages = [
379
+ {
380
+ role: "assistant" as const,
381
+ content: [
382
+ { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
383
+ { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
384
+ { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
385
+ { type: "tool-approval-request", approvalId: "ap2", toolCallId: "tc2" },
386
+ ],
387
+ },
388
+ ] as any;
389
+
390
+ autoDenyUnresolvedApprovals(messages);
391
+
392
+ expect(warnSpy).toHaveBeenCalledTimes(2);
393
+ expect(warnSpy).toHaveBeenCalledWith(
394
+ expect.stringContaining("ap1"),
395
+ );
396
+ expect(warnSpy).toHaveBeenCalledWith(
397
+ expect.stringContaining("ap2"),
398
+ );
399
+ warnSpy.mockRestore();
400
+ });
401
+ });
260
402
  });
package/src/mapping.ts CHANGED
@@ -139,6 +139,97 @@ export function docsToModelMessages(messages: MessageDoc[]): ModelMessage[] {
139
139
  .map(toModelMessage);
140
140
  }
141
141
 
142
+ /**
143
+ * Scan messages for unresolved `tool-approval-request` parts and inject
144
+ * synthetic `tool-approval-response` denials so that the AI SDK receives
145
+ * a complete history (every tool-call has a corresponding result or denial).
146
+ *
147
+ * This handles the case where a user sends a new message instead of
148
+ * resolving pending approvals — the old approvals are auto-denied rather
149
+ * than silently dropped.
150
+ */
151
+ export function autoDenyUnresolvedApprovals(
152
+ messages: ModelMessage[],
153
+ ): ModelMessage[] {
154
+ // Collect all approval requests: approvalId → { toolCallId, messageIndex }
155
+ const requests = new Map<
156
+ string,
157
+ { toolCallId: string; messageIndex: number }
158
+ >();
159
+ // Collect all resolved approval IDs
160
+ const resolvedIds = new Set<string>();
161
+
162
+ for (let i = 0; i < messages.length; i++) {
163
+ const msg = messages[i];
164
+ if (!Array.isArray(msg.content)) continue;
165
+ for (const part of msg.content as any[]) {
166
+ if (part.type === "tool-approval-request") {
167
+ requests.set(part.approvalId, {
168
+ toolCallId: part.toolCallId,
169
+ messageIndex: i,
170
+ });
171
+ } else if (part.type === "tool-approval-response") {
172
+ resolvedIds.add(part.approvalId);
173
+ }
174
+ }
175
+ }
176
+
177
+ // Find unresolved approvals
178
+ const unresolved: Array<{
179
+ approvalId: string;
180
+ toolCallId: string;
181
+ messageIndex: number;
182
+ }> = [];
183
+ for (const [approvalId, info] of requests) {
184
+ if (!resolvedIds.has(approvalId)) {
185
+ unresolved.push({ approvalId, ...info });
186
+ }
187
+ }
188
+
189
+ if (unresolved.length === 0) {
190
+ return messages;
191
+ }
192
+
193
+ // Group unresolved approvals by the assistant message index they came from
194
+ const byMessageIndex = new Map<
195
+ number,
196
+ Array<{ approvalId: string; toolCallId: string }>
197
+ >();
198
+ for (const entry of unresolved) {
199
+ console.warn(
200
+ `Auto-denying unresolved tool approval ${entry.approvalId} ` +
201
+ `(toolCallId: ${entry.toolCallId}): new generation started`,
202
+ );
203
+ let group = byMessageIndex.get(entry.messageIndex);
204
+ if (!group) {
205
+ group = [];
206
+ byMessageIndex.set(entry.messageIndex, group);
207
+ }
208
+ group.push(entry);
209
+ }
210
+
211
+ // Build result by inserting synthetic denial messages after each relevant
212
+ // assistant message
213
+ const result: ModelMessage[] = [];
214
+ for (let i = 0; i < messages.length; i++) {
215
+ result.push(messages[i]);
216
+ const group = byMessageIndex.get(i);
217
+ if (group) {
218
+ result.push({
219
+ role: "tool",
220
+ content: group.map((entry) => ({
221
+ type: "tool-approval-response" as const,
222
+ approvalId: entry.approvalId,
223
+ approved: false,
224
+ reason: "auto-denied: new generation started",
225
+ })),
226
+ });
227
+ }
228
+ }
229
+
230
+ return result;
231
+ }
232
+
142
233
  export function serializeUsage(usage: LanguageModelUsage): Usage {
143
234
  return {
144
235
  promptTokens: usage.inputTokens ?? 0,
@@ -211,6 +302,16 @@ export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
211
302
  component: AgentComponent,
212
303
  step: StepResult<TOOLS>,
213
304
  model: ModelOrMetadata | undefined,
305
+ /**
306
+ * If provided, these are the new response messages for this step
307
+ * (pre-sliced by the caller). When not provided, falls back to the
308
+ * existing heuristic of slicing the last 1-2 messages.
309
+ *
310
+ * This is needed for tool approval flows where the SDK adds extra
311
+ * messages (e.g. approval tool-results) at the beginning of
312
+ * responseMessages that the old slice(-1/-2) logic would miss.
313
+ */
314
+ newResponseMessages?: ModelMessage[],
214
315
  ): Promise<{ messages: MessageWithMetadata[] }> {
215
316
  // If there are tool results, there's another message with the tool results
216
317
  // ref: https://github.com/vercel/ai/blob/main/packages/ai/src/generate-text/to-response-messages.ts#L120
@@ -228,13 +329,21 @@ export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
228
329
  sources: hasToolMessage ? undefined : step.sources,
229
330
  } satisfies Omit<MessageWithMetadata, "message" | "text" | "fileIds">;
230
331
  const toolFields = { sources: step.sources };
332
+
333
+ // Determine which messages to serialize for this step
334
+ let messagesToSerialize: ModelMessage[];
335
+ if (newResponseMessages) {
336
+ messagesToSerialize = newResponseMessages;
337
+ } else if (hasToolMessage) {
338
+ messagesToSerialize = step.response.messages.slice(-2);
339
+ } else if (step.content.length) {
340
+ messagesToSerialize = step.response.messages.slice(-1);
341
+ } else {
342
+ messagesToSerialize = [{ role: "assistant" as const, content: [] }];
343
+ }
344
+
231
345
  const messages: MessageWithMetadata[] = await Promise.all(
232
- (hasToolMessage
233
- ? step.response.messages.slice(-2)
234
- : step.content.length
235
- ? step.response.messages.slice(-1)
236
- : [{ role: "assistant" as const, content: [] }]
237
- ).map(async (msg): Promise<MessageWithMetadata> => {
346
+ messagesToSerialize.map(async (msg): Promise<MessageWithMetadata> => {
238
347
  const { message, fileIds } = await serializeMessage(ctx, component, msg);
239
348
  return parse(vMessageWithMetadata, {
240
349
  message,
@@ -83,6 +83,12 @@ export function useDeltaStreams<
83
83
  ),
84
84
  );
85
85
 
86
+ // When no active streams remain, clear the stale state so we stop
87
+ // returning old streaming UIMessages.
88
+ if (streamMessages !== undefined && streamMessages.length === 0) {
89
+ state.deltaStreams = undefined;
90
+ }
91
+
86
92
  // Get the deltas for all the active streams, if any.
87
93
  const cursorQuery = useQuery(
88
94
  query,