@convex-dev/agent 0.6.0-alpha.0 → 0.6.0-beta.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 (75) hide show
  1. package/MIGRATION.md +153 -0
  2. package/dist/UIMessages.d.ts.map +1 -1
  3. package/dist/UIMessages.js +88 -0
  4. package/dist/UIMessages.js.map +1 -1
  5. package/dist/client/createTool.d.ts +18 -21
  6. package/dist/client/createTool.d.ts.map +1 -1
  7. package/dist/client/createTool.js +3 -2
  8. package/dist/client/createTool.js.map +1 -1
  9. package/dist/client/definePlaygroundAPI.d.ts +31 -31
  10. package/dist/client/index.d.ts +70 -23
  11. package/dist/client/index.d.ts.map +1 -1
  12. package/dist/client/index.js +126 -1
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/client/messages.d.ts +9 -9
  15. package/dist/client/mockModel.d.ts.map +1 -1
  16. package/dist/client/mockModel.js +9 -2
  17. package/dist/client/mockModel.js.map +1 -1
  18. package/dist/client/search.d.ts +9 -9
  19. package/dist/client/search.d.ts.map +1 -1
  20. package/dist/client/search.js +12 -2
  21. package/dist/client/search.js.map +1 -1
  22. package/dist/client/start.d.ts +1 -1
  23. package/dist/client/start.d.ts.map +1 -1
  24. package/dist/client/start.js +29 -15
  25. package/dist/client/start.js.map +1 -1
  26. package/dist/client/streamText.d.ts.map +1 -1
  27. package/dist/client/streamText.js +37 -3
  28. package/dist/client/streamText.js.map +1 -1
  29. package/dist/client/streaming.d.ts +78 -67
  30. package/dist/client/streaming.d.ts.map +1 -1
  31. package/dist/client/streaming.js +64 -28
  32. package/dist/client/streaming.js.map +1 -1
  33. package/dist/client/types.d.ts +13 -12
  34. package/dist/client/types.d.ts.map +1 -1
  35. package/dist/component/_generated/component.d.ts +1 -0
  36. package/dist/component/_generated/component.d.ts.map +1 -1
  37. package/dist/component/messages.d.ts +107 -106
  38. package/dist/component/messages.d.ts.map +1 -1
  39. package/dist/component/messages.js +13 -3
  40. package/dist/component/messages.js.map +1 -1
  41. package/dist/component/schema.d.ts +40 -40
  42. package/dist/component/streams.d.ts +4 -4
  43. package/dist/component/threads.d.ts +17 -17
  44. package/dist/component/users.d.ts +3 -3
  45. package/dist/component/vector/index.d.ts +1 -1
  46. package/dist/deltas.d.ts.map +1 -1
  47. package/dist/deltas.js +0 -1
  48. package/dist/deltas.js.map +1 -1
  49. package/dist/mapping.d.ts +22 -0
  50. package/dist/mapping.d.ts.map +1 -1
  51. package/dist/mapping.js +99 -5
  52. package/dist/mapping.js.map +1 -1
  53. package/dist/react/useDeltaStreams.d.ts.map +1 -1
  54. package/dist/react/useDeltaStreams.js +5 -0
  55. package/dist/react/useDeltaStreams.js.map +1 -1
  56. package/dist/validators.d.ts +13 -13
  57. package/package.json +4 -2
  58. package/src/UIMessages.ts +126 -0
  59. package/src/client/approval.test.ts +494 -0
  60. package/src/client/createTool.ts +50 -52
  61. package/src/client/index.ts +170 -1
  62. package/src/client/mockModel.ts +9 -2
  63. package/src/client/search.test.ts +4 -5
  64. package/src/client/search.ts +18 -2
  65. package/src/client/start.ts +42 -25
  66. package/src/client/streamText.ts +36 -3
  67. package/src/client/streaming.integration.test.ts +1206 -0
  68. package/src/client/streaming.ts +67 -31
  69. package/src/client/types.ts +14 -12
  70. package/src/component/_generated/component.ts +53 -64
  71. package/src/component/messages.ts +12 -2
  72. package/src/deltas.ts +0 -1
  73. package/src/mapping.test.ts +143 -1
  74. package/src/mapping.ts +131 -6
  75. package/src/react/useDeltaStreams.ts +6 -0
@@ -19,7 +19,7 @@ import type {
19
19
  } from "ai";
20
20
  import { generateObject, generateText, stepCountIs, streamObject } from "ai";
21
21
 
22
- const MIGRATION_URL = "https://github.com/get-convex/agent/blob/main/MIGRATION.md";
22
+ const MIGRATION_URL = "node_modules/@convex-dev/agent/MIGRATION.md";
23
23
  const warnedDeprecations = new Set<string>();
24
24
  function warnDeprecation(key: string, message: string) {
25
25
  if (!warnedDeprecations.has(key)) {
@@ -1016,6 +1016,175 @@ export class Agent<
1016
1016
  );
1017
1017
  }
1018
1018
 
1019
+ /**
1020
+ * Approve a tool call that requires human approval.
1021
+ * Saves a `tool-approval-response` message to the thread.
1022
+ * After calling this, call `agent.streamText` or `agent.generateText`
1023
+ * with `promptMessageId` set to the returned `messageId` to continue
1024
+ * generation — the AI SDK will automatically execute the approved tool.
1025
+ *
1026
+ * The approval response is attached to the same generation order as the
1027
+ * original approval request, preserving tool_call/tool_result adjacency in
1028
+ * the continuation context even if newer thread messages exist.
1029
+ *
1030
+ * @param ctx A ctx object from a mutation.
1031
+ * @param args.threadId The thread containing the tool call.
1032
+ * @param args.approvalId The approval ID from the tool-approval-request part.
1033
+ * @param args.reason Optional reason for approval.
1034
+ * @returns The messageId of the saved approval response message.
1035
+ */
1036
+ async approveToolCall(
1037
+ ctx: MutationCtx,
1038
+ args: { threadId: string; approvalId: string; reason?: string },
1039
+ ): Promise<{ messageId: string }> {
1040
+ return this.respondToToolCallApproval(ctx, { ...args, approved: true });
1041
+ }
1042
+
1043
+ /**
1044
+ * Deny a tool call that requires human approval.
1045
+ * Saves a `tool-approval-response` message to the thread.
1046
+ * After calling this, call `agent.streamText` or `agent.generateText`
1047
+ * with `promptMessageId` set to the returned `messageId` to continue
1048
+ * generation — the AI SDK will automatically create an `execution-denied`
1049
+ * result and let the model respond accordingly.
1050
+ *
1051
+ * @param ctx A ctx object from a mutation.
1052
+ * @param args.threadId The thread containing the tool call.
1053
+ * @param args.approvalId The approval ID from the tool-approval-request part.
1054
+ * @param args.reason Optional reason for denial.
1055
+ * @returns The messageId of the saved denial response message.
1056
+ */
1057
+ async denyToolCall(
1058
+ ctx: MutationCtx,
1059
+ args: { threadId: string; approvalId: string; reason?: string },
1060
+ ): Promise<{ messageId: string }> {
1061
+ return this.respondToToolCallApproval(ctx, { ...args, approved: false });
1062
+ }
1063
+
1064
+ private async respondToToolCallApproval(
1065
+ ctx: MutationCtx,
1066
+ args: {
1067
+ threadId: string;
1068
+ approvalId: string;
1069
+ approved: boolean;
1070
+ reason?: string;
1071
+ },
1072
+ ): Promise<{ messageId: string }> {
1073
+ const { promptMessageId, existingResponseMessage } =
1074
+ await this.findApprovalContext(ctx, {
1075
+ threadId: args.threadId,
1076
+ approvalId: args.approvalId,
1077
+ });
1078
+
1079
+ const newPart = {
1080
+ type: "tool-approval-response" as const,
1081
+ approvalId: args.approvalId,
1082
+ approved: args.approved,
1083
+ reason: args.reason,
1084
+ };
1085
+
1086
+ // Merge into an existing approval-response message for this step
1087
+ // so the AI SDK sees a single tool message per step.
1088
+ if (existingResponseMessage) {
1089
+ const existingContent = existingResponseMessage.message?.content;
1090
+ const mergedContent = Array.isArray(existingContent)
1091
+ ? [...(existingContent as any[]), newPart]
1092
+ : [newPart];
1093
+ await this.updateMessage(ctx, {
1094
+ messageId: existingResponseMessage._id,
1095
+ patch: {
1096
+ message: { role: "tool", content: mergedContent },
1097
+ status: "success",
1098
+ },
1099
+ });
1100
+ return { messageId: existingResponseMessage._id };
1101
+ }
1102
+
1103
+ const { messageId } = await this.saveMessage(ctx, {
1104
+ threadId: args.threadId,
1105
+ promptMessageId,
1106
+ skipEmbeddings: true,
1107
+ message: {
1108
+ role: "tool",
1109
+ content: [newPart],
1110
+ },
1111
+ });
1112
+ return { messageId };
1113
+ }
1114
+
1115
+ private async findApprovalContext(
1116
+ ctx: MutationCtx,
1117
+ args: { threadId: string; approvalId: string },
1118
+ ): Promise<{
1119
+ promptMessageId: string;
1120
+ existingResponseMessage: MessageDoc | undefined;
1121
+ }> {
1122
+ // NOTE: This pagination returns messages in descending order (newest first).
1123
+ // The "already handled" check (tool-approval-response) relies on seeing
1124
+ // responses before their corresponding requests. If the pagination order
1125
+ // changes, this logic will need to be updated.
1126
+ let existingResponseMessage: MessageDoc | undefined;
1127
+ // Limit the search to the most recent messages. Approvals should always
1128
+ // be near the end of the thread.
1129
+ const page = await this.listMessages(ctx, {
1130
+ threadId: args.threadId,
1131
+ paginationOpts: { cursor: null, numItems: 100 },
1132
+ });
1133
+ {
1134
+ for (const message of page.page) {
1135
+ const content = message.message?.content;
1136
+ if (!Array.isArray(content)) continue;
1137
+ // Check if this assistant message starts a different approval step.
1138
+ // If so, any response message we've seen so far belongs to a newer
1139
+ // step — reset it so we don't merge across step boundaries.
1140
+ // Only reset if the target approval is NOT in this message (i.e.,
1141
+ // this is a genuinely different step, not the same step with
1142
+ // multiple tool calls).
1143
+ if (
1144
+ message.message?.role === "assistant" &&
1145
+ content.some(
1146
+ (p: any) =>
1147
+ p.type === "tool-approval-request" &&
1148
+ p.approvalId !== args.approvalId,
1149
+ ) &&
1150
+ !content.some(
1151
+ (p: any) =>
1152
+ p.type === "tool-approval-request" &&
1153
+ p.approvalId === args.approvalId,
1154
+ )
1155
+ ) {
1156
+ existingResponseMessage = undefined;
1157
+ }
1158
+ for (const part of content) {
1159
+ const typedPart = part as { type?: unknown; approvalId?: unknown };
1160
+ if (
1161
+ typedPart.type === "tool-approval-response" &&
1162
+ typedPart.approvalId === args.approvalId
1163
+ ) {
1164
+ throw new Error(`Approval ${args.approvalId} was already handled`);
1165
+ }
1166
+ // Track the most recent tool-approval-response message for merging
1167
+ if (
1168
+ typedPart.type === "tool-approval-response" &&
1169
+ !existingResponseMessage
1170
+ ) {
1171
+ existingResponseMessage = message;
1172
+ }
1173
+ if (
1174
+ typedPart.type === "tool-approval-request" &&
1175
+ typedPart.approvalId === args.approvalId
1176
+ ) {
1177
+ return { promptMessageId: message._id, existingResponseMessage };
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ throw new Error(
1184
+ `Approval request ${args.approvalId} was not found in the last 100 messages of thread ${args.threadId}`,
1185
+ );
1186
+ }
1187
+
1019
1188
  /**
1020
1189
  * Explicitly save a "step" created by the AI SDK.
1021
1190
  * @param ctx The ctx argument to a mutation or action.
@@ -16,8 +16,15 @@ const DEFAULT_USAGE = {
16
16
  outputTokens: 10,
17
17
  inputTokens: 3,
18
18
  totalTokens: 13,
19
- inputTokenDetails: undefined,
20
- outputTokenDetails: undefined,
19
+ inputTokenDetails: {
20
+ noCacheTokens: 3,
21
+ cacheReadTokens: 0,
22
+ cacheWriteTokens: 0,
23
+ },
24
+ outputTokenDetails: {
25
+ textTokens: 10,
26
+ reasoningTokens: 0,
27
+ },
21
28
  };
22
29
 
23
30
  export type MockModelArgs = {
@@ -292,7 +292,7 @@ describe("search.ts", () => {
292
292
  expect(result[1]._id).toBe("2");
293
293
  });
294
294
 
295
- it("should filter out tool calls with approval request but NO approval response", () => {
295
+ it("should keep tool calls with approval request but NO approval response (auto-deny handles them)", () => {
296
296
  const messages: MessageDoc[] = [
297
297
  {
298
298
  _id: "1",
@@ -321,19 +321,18 @@ describe("search.ts", () => {
321
321
 
322
322
  const result = filterOutOrphanedToolMessages(messages);
323
323
  expect(result).toHaveLength(1);
324
- // The assistant message should have the tool-call filtered out
324
+ // The assistant message should keep the tool-call (auto-deny resolves it downstream)
325
325
  const assistantContent = result[0].message?.content;
326
326
  expect(Array.isArray(assistantContent)).toBe(true);
327
327
  if (Array.isArray(assistantContent)) {
328
- // Text and approval-request should remain, but tool-call should be filtered
329
- expect(assistantContent).toHaveLength(2);
328
+ expect(assistantContent).toHaveLength(3);
330
329
  expect(assistantContent.find((p) => p.type === "text")).toBeDefined();
331
330
  expect(
332
331
  assistantContent.find((p) => p.type === "tool-approval-request"),
333
332
  ).toBeDefined();
334
333
  expect(
335
334
  assistantContent.find((p) => p.type === "tool-call"),
336
- ).toBeUndefined();
335
+ ).toBeDefined();
337
336
  }
338
337
  });
339
338
 
@@ -29,7 +29,11 @@ import type {
29
29
  ActionCtx,
30
30
  } from "./types.js";
31
31
  import { inlineMessagesFiles } from "./files.js";
32
- import { docsToModelMessages, toModelMessage } from "../mapping.js";
32
+ import {
33
+ autoDenyUnresolvedApprovals,
34
+ docsToModelMessages,
35
+ toModelMessage,
36
+ } from "../mapping.js";
33
37
 
34
38
  const DEFAULT_VECTOR_SCORE_THRESHOLD = 0.0;
35
39
  // 10k characters should be more than enough for most cases, and stays under
@@ -285,6 +289,12 @@ export function filterOutOrphanedToolMessages(docs: MessageDoc[]) {
285
289
  return approvalId !== undefined && approvalResponseIds.has(approvalId);
286
290
  };
287
291
 
292
+ // Helper: check if tool call has a pending approval request
293
+ // (auto-deny handles these downstream, so they must survive the filter)
294
+ const hasApprovalRequest = (toolCallId: string) => {
295
+ return approvalRequestsByToolCallId.has(toolCallId);
296
+ };
297
+
288
298
  for (const doc of docs) {
289
299
  if (
290
300
  doc.message?.role === "assistant" &&
@@ -294,7 +304,8 @@ export function filterOutOrphanedToolMessages(docs: MessageDoc[]) {
294
304
  (p) =>
295
305
  p.type !== "tool-call" ||
296
306
  toolResultIds.has(p.toolCallId) ||
297
- hasApprovalResponse(p.toolCallId),
307
+ hasApprovalResponse(p.toolCallId) ||
308
+ hasApprovalRequest(p.toolCallId),
298
309
  );
299
310
  if (content.length) {
300
311
  result.push({
@@ -657,6 +668,11 @@ export async function fetchContextWithPrompt(
657
668
  })
658
669
  : allMessages;
659
670
 
671
+ // Post-process: auto-deny unresolved approvals so the AI SDK sees a
672
+ // complete history. Applied after contextHandler so custom handlers
673
+ // don't need to handle this.
674
+ processedMessages = autoDenyUnresolvedApprovals(processedMessages);
675
+
660
676
  // Process messages to inline localhost files (if not, file urls pointing to localhost will be sent to LLM providers)
661
677
  if (process.env.CONVEX_CLOUD_URL?.startsWith("http://127.0.0.1")) {
662
678
  processedMessages = await inlineMessagesFiles(processedMessages);
@@ -10,7 +10,7 @@ import {
10
10
  type ToolSet,
11
11
  } from "ai";
12
12
  import {
13
- serializeNewMessagesInStep,
13
+ serializeResponseMessages,
14
14
  serializeObjectResult,
15
15
  } from "../mapping.js";
16
16
  import { embedMessages, fetchContextWithPrompt } from "./search.js";
@@ -112,6 +112,7 @@ export async function startGeneration<
112
112
  | { step: StepResult<TOOLS> }
113
113
  | { object: GenerateObjectResult<unknown> },
114
114
  createPendingMessage?: boolean,
115
+ finishStreamId?: string,
115
116
  ) => Promise<void>;
116
117
  fail: (reason: string) => Promise<void>;
117
118
  getSavedMessages: () => MessageDoc[];
@@ -200,16 +201,18 @@ export async function startGeneration<
200
201
  tools?: Tools;
201
202
  _internal?: { generateId?: IdGenerator };
202
203
  } & CallSettings;
203
- if (pendingMessageId) {
204
- if (!aiArgs._internal?.generateId) {
205
- aiArgs._internal = {
206
- ...aiArgs._internal,
207
- generateId: pendingMessageId
208
- ? () => pendingMessageId ?? crypto.randomUUID()
209
- : undefined,
210
- };
211
- }
212
- }
204
+ // NOTE: We intentionally do NOT override _internal.generateId here.
205
+ // The AI SDK uses generateId() for many internal IDs (approval IDs,
206
+ // tool execution IDs, message IDs, etc.) and they must be unique.
207
+ // The pending message is linked via the explicit `pendingMessageId`
208
+ // parameter passed to addMessages in the save closure.
209
+ // Track how many response messages we've already saved across steps.
210
+ // step.response.messages is cumulative — each step appends to it.
211
+ // We need to know which messages are new in each step to serialize
212
+ // only the new ones (important for tool approval flows where the SDK
213
+ // may add extra messages like approval tool-results).
214
+ let previousResponseMessageCount = 0;
215
+
213
216
  return {
214
217
  args: aiArgs,
215
218
  order: order ?? 0,
@@ -228,22 +231,35 @@ export async function startGeneration<
228
231
  | { step: StepResult<TOOLS> }
229
232
  | { object: GenerateObjectResult<unknown> },
230
233
  createPendingMessage?: boolean,
234
+ /**
235
+ * If provided, finish this stream atomically with the message save.
236
+ * This prevents UI flickering from separate mutations (issue #181).
237
+ */
238
+ finishStreamId?: string,
231
239
  ) => {
232
240
  if (threadId && saveMessages !== "none") {
233
- const serialized =
234
- "object" in toSave
235
- ? await serializeObjectResult(
236
- ctx,
237
- component,
238
- toSave.object,
239
- activeModel,
240
- )
241
- : await serializeNewMessagesInStep(
242
- ctx,
243
- component,
244
- toSave.step,
245
- activeModel,
246
- );
241
+ let serialized;
242
+ if ("object" in toSave) {
243
+ serialized = await serializeObjectResult(
244
+ ctx,
245
+ component,
246
+ toSave.object,
247
+ activeModel,
248
+ );
249
+ } else {
250
+ const allResponseMessages = toSave.step.response.messages;
251
+ const newResponseMessages = allResponseMessages.slice(
252
+ previousResponseMessageCount,
253
+ );
254
+ previousResponseMessageCount = allResponseMessages.length;
255
+ serialized = await serializeResponseMessages(
256
+ ctx,
257
+ component,
258
+ toSave.step,
259
+ activeModel,
260
+ newResponseMessages,
261
+ );
262
+ }
247
263
  const embeddings = await embedMessages(
248
264
  ctx,
249
265
  { threadId, ...opts, userId },
@@ -265,6 +281,7 @@ export async function startGeneration<
265
281
  messages: serialized.messages,
266
282
  embeddings,
267
283
  failPendingSteps: false,
284
+ finishStreamId,
268
285
  });
269
286
  const lastMessage = saved.messages.at(-1)!;
270
287
  if (createPendingMessage) {
@@ -80,6 +80,9 @@ export async function streamText<
80
80
 
81
81
  const steps: StepResult<TOOLS>[] = [];
82
82
 
83
+ // Track the final step for atomic save with stream finish (issue #181)
84
+ let pendingFinalStep: StepResult<TOOLS> | undefined;
85
+
83
86
  const streamer =
84
87
  threadId && options.saveStreamDeltas
85
88
  ? new DeltaStreamer(
@@ -138,7 +141,14 @@ export async function streamText<
138
141
  onStepFinish: async (step) => {
139
142
  steps.push(step);
140
143
  const createPendingMessage = await willContinue(steps, args.stopWhen);
141
- await call.save({ step }, createPendingMessage);
144
+ if (!createPendingMessage && streamer) {
145
+ // This is the final step with streaming enabled.
146
+ // Defer saving until stream consumption completes for atomic finish (issue #181).
147
+ streamer.markFinishedExternally();
148
+ pendingFinalStep = step;
149
+ } else {
150
+ await call.save({ step }, createPendingMessage);
151
+ }
142
152
  return args.onStepFinish?.(step);
143
153
  },
144
154
  }) as StreamTextResult<TOOLS, OUTPUT>;
@@ -150,8 +160,31 @@ export async function streamText<
150
160
  !options.saveStreamDeltas.returnImmediately) ||
151
161
  options?.saveStreamDeltas === true
152
162
  ) {
153
- await stream;
154
- await result.consumeStream();
163
+ try {
164
+ await stream;
165
+ await result.consumeStream();
166
+ } catch (e) {
167
+ // If the stream errored (e.g. onStepFinish threw), the DeltaStreamer's
168
+ // finish() was never called, leaving the streaming message stuck in
169
+ // "streaming" state. Clean it up by marking it as aborted.
170
+ await streamer?.fail(e instanceof Error ? e.message : String(e));
171
+ // Save the deferred final step if it was already generated but not yet persisted
172
+ if (pendingFinalStep) {
173
+ try {
174
+ await call.save({ step: pendingFinalStep }, false);
175
+ } catch (saveError) {
176
+ console.error("Failed to save deferred final step:", saveError);
177
+ }
178
+ pendingFinalStep = undefined;
179
+ }
180
+ throw e;
181
+ }
182
+ }
183
+
184
+ // If we deferred the final step save, do it now with atomic stream finish.
185
+ if (pendingFinalStep && streamer) {
186
+ const finishStreamId = await streamer.getOrCreateStreamId();
187
+ await call.save({ step: pendingFinalStep }, false, finishStreamId);
155
188
  }
156
189
  const metadata: GenerationOutputMetadata = {
157
190
  promptMessageId,