@convex-dev/agent 0.6.0-alpha.0 → 0.6.0-alpha.1

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 (66) hide show
  1. package/MIGRATION.md +153 -0
  2. package/dist/client/createTool.d.ts +18 -21
  3. package/dist/client/createTool.d.ts.map +1 -1
  4. package/dist/client/createTool.js +3 -2
  5. package/dist/client/createTool.js.map +1 -1
  6. package/dist/client/definePlaygroundAPI.d.ts +48 -48
  7. package/dist/client/index.d.ts +109 -62
  8. package/dist/client/index.d.ts.map +1 -1
  9. package/dist/client/index.js +92 -1
  10. package/dist/client/index.js.map +1 -1
  11. package/dist/client/messages.d.ts +12 -12
  12. package/dist/client/mockModel.d.ts.map +1 -1
  13. package/dist/client/mockModel.js +9 -2
  14. package/dist/client/mockModel.js.map +1 -1
  15. package/dist/client/search.d.ts +12 -12
  16. package/dist/client/search.d.ts.map +1 -1
  17. package/dist/client/search.js +3 -3
  18. package/dist/client/search.js.map +1 -1
  19. package/dist/client/start.d.ts +1 -1
  20. package/dist/client/start.d.ts.map +1 -1
  21. package/dist/client/start.js +28 -14
  22. package/dist/client/start.js.map +1 -1
  23. package/dist/client/streamText.d.ts.map +1 -1
  24. package/dist/client/streamText.js +27 -3
  25. package/dist/client/streamText.js.map +1 -1
  26. package/dist/client/streaming.d.ts +105 -94
  27. package/dist/client/streaming.d.ts.map +1 -1
  28. package/dist/client/streaming.js +28 -8
  29. package/dist/client/streaming.js.map +1 -1
  30. package/dist/client/types.d.ts +13 -12
  31. package/dist/client/types.d.ts.map +1 -1
  32. package/dist/component/_generated/component.d.ts +1 -0
  33. package/dist/component/_generated/component.d.ts.map +1 -1
  34. package/dist/component/messages.d.ts +143 -142
  35. package/dist/component/messages.d.ts.map +1 -1
  36. package/dist/component/messages.js +13 -3
  37. package/dist/component/messages.js.map +1 -1
  38. package/dist/component/streams.d.ts +5 -5
  39. package/dist/component/threads.d.ts +19 -19
  40. package/dist/component/users.d.ts +3 -3
  41. package/dist/deltas.d.ts.map +1 -1
  42. package/dist/deltas.js +0 -1
  43. package/dist/deltas.js.map +1 -1
  44. package/dist/mapping.d.ts +19 -1
  45. package/dist/mapping.d.ts.map +1 -1
  46. package/dist/mapping.js +57 -6
  47. package/dist/mapping.js.map +1 -1
  48. package/dist/react/useDeltaStreams.d.ts.map +1 -1
  49. package/dist/react/useDeltaStreams.js +5 -0
  50. package/dist/react/useDeltaStreams.js.map +1 -1
  51. package/package.json +4 -2
  52. package/src/client/approval.test.ts +350 -0
  53. package/src/client/createTool.ts +50 -52
  54. package/src/client/index.ts +120 -1
  55. package/src/client/mockModel.ts +9 -2
  56. package/src/client/search.ts +7 -3
  57. package/src/client/start.ts +41 -24
  58. package/src/client/streamText.ts +27 -3
  59. package/src/client/streaming.ts +33 -11
  60. package/src/client/types.ts +14 -12
  61. package/src/component/_generated/component.ts +53 -64
  62. package/src/component/messages.ts +12 -2
  63. package/src/deltas.ts +0 -1
  64. package/src/mapping.test.ts +77 -0
  65. package/src/mapping.ts +62 -6
  66. package/src/react/useDeltaStreams.ts +6 -0
@@ -7,6 +7,7 @@ import {
7
7
  toModelMessage,
8
8
  serializeContent,
9
9
  toModelMessageContent,
10
+ mergeApprovalResponseMessages,
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,80 @@ describe("mapping", () => {
257
258
  expect(content).toHaveLength(1);
258
259
  expect((content as unknown[])[0]).toMatchObject(approvalResponse);
259
260
  });
261
+
262
+ test("mergeApprovalResponseMessages merges consecutive tool approval messages", () => {
263
+ const messages = [
264
+ { role: "user" as const, content: "hello" },
265
+ {
266
+ role: "assistant" as const,
267
+ content: [
268
+ { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
269
+ { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
270
+ { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
271
+ { type: "tool-approval-request", approvalId: "ap2", toolCallId: "tc2" },
272
+ ],
273
+ },
274
+ {
275
+ role: "tool" as const,
276
+ content: [
277
+ { type: "tool-approval-response", approvalId: "ap1", approved: true },
278
+ ],
279
+ },
280
+ {
281
+ role: "tool" as const,
282
+ content: [
283
+ { type: "tool-approval-response", approvalId: "ap2", approved: false, reason: "denied" },
284
+ ],
285
+ },
286
+ ] as any;
287
+
288
+ const merged = mergeApprovalResponseMessages(messages);
289
+ expect(merged).toHaveLength(3); // user, assistant, single tool
290
+ expect(merged[2].role).toBe("tool");
291
+ const toolContent = merged[2].content as Array<{ type: string; approvalId: string }>;
292
+ expect(toolContent).toHaveLength(2);
293
+ expect(toolContent[0].approvalId).toBe("ap1");
294
+ expect(toolContent[1].approvalId).toBe("ap2");
295
+ });
296
+
297
+ test("mergeApprovalResponseMessages does not mutate original message content arrays", () => {
298
+ const msg1Content = [
299
+ { type: "tool-approval-response", approvalId: "ap1", approved: true },
300
+ ];
301
+ const msg2Content = [
302
+ { type: "tool-approval-response", approvalId: "ap2", approved: false, reason: "denied" },
303
+ ];
304
+ const messages = [
305
+ { role: "tool" as const, content: msg1Content },
306
+ { role: "tool" as const, content: msg2Content },
307
+ ] as any;
308
+
309
+ const merged = mergeApprovalResponseMessages(messages);
310
+ // Merged result should combine both
311
+ expect(merged).toHaveLength(1);
312
+ expect((merged[0].content as any[]).length).toBe(2);
313
+ // Original arrays must be untouched
314
+ expect(msg1Content).toHaveLength(1);
315
+ expect(msg2Content).toHaveLength(1);
316
+ });
317
+
318
+ test("mergeApprovalResponseMessages does not merge non-approval tool messages", () => {
319
+ const messages = [
320
+ {
321
+ role: "tool" as const,
322
+ content: [
323
+ { type: "tool-result", toolCallId: "tc1", toolName: "a", output: { type: "text", value: "ok" } },
324
+ ],
325
+ },
326
+ {
327
+ role: "tool" as const,
328
+ content: [
329
+ { type: "tool-approval-response", approvalId: "ap1", approved: true },
330
+ ],
331
+ },
332
+ ] as any;
333
+
334
+ const merged = mergeApprovalResponseMessages(messages);
335
+ expect(merged).toHaveLength(2); // not merged since first has tool-result
336
+ });
260
337
  });
package/src/mapping.ts CHANGED
@@ -139,6 +139,44 @@ export function docsToModelMessages(messages: MessageDoc[]): ModelMessage[] {
139
139
  .map(toModelMessage);
140
140
  }
141
141
 
142
+ /**
143
+ * Merge consecutive tool messages that contain `tool-approval-response` parts
144
+ * into a single tool message. The AI SDK's `collectToolApprovals` only examines
145
+ * the last tool message, so when multiple approvals are saved as separate
146
+ * messages (e.g. approve tool A, then deny tool B), they must be combined
147
+ * for the SDK to process them all.
148
+ */
149
+ export function mergeApprovalResponseMessages(
150
+ messages: ModelMessage[],
151
+ ): ModelMessage[] {
152
+ const result: ModelMessage[] = [];
153
+ for (const msg of messages) {
154
+ const prev = result.at(-1);
155
+ if (
156
+ msg.role === "tool" &&
157
+ prev?.role === "tool" &&
158
+ Array.isArray(msg.content) &&
159
+ Array.isArray(prev.content) &&
160
+ hasApprovalResponse(msg.content) &&
161
+ hasApprovalResponse(prev.content)
162
+ ) {
163
+ // Clone before merging to avoid mutating the original message's content array
164
+ const cloned = { ...prev, content: [...(prev.content as any[])] };
165
+ result[result.length - 1] = cloned;
166
+ (cloned.content as any[]).push(...(msg.content as any[]));
167
+ } else {
168
+ result.push(msg);
169
+ }
170
+ }
171
+ return result;
172
+ }
173
+
174
+ function hasApprovalResponse(content: any[]): boolean {
175
+ return content.some(
176
+ (p: any) => p.type === "tool-approval-response",
177
+ );
178
+ }
179
+
142
180
  export function serializeUsage(usage: LanguageModelUsage): Usage {
143
181
  return {
144
182
  promptTokens: usage.inputTokens ?? 0,
@@ -211,6 +249,16 @@ export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
211
249
  component: AgentComponent,
212
250
  step: StepResult<TOOLS>,
213
251
  model: ModelOrMetadata | undefined,
252
+ /**
253
+ * If provided, these are the new response messages for this step
254
+ * (pre-sliced by the caller). When not provided, falls back to the
255
+ * existing heuristic of slicing the last 1-2 messages.
256
+ *
257
+ * This is needed for tool approval flows where the SDK adds extra
258
+ * messages (e.g. approval tool-results) at the beginning of
259
+ * responseMessages that the old slice(-1/-2) logic would miss.
260
+ */
261
+ newResponseMessages?: ModelMessage[],
214
262
  ): Promise<{ messages: MessageWithMetadata[] }> {
215
263
  // If there are tool results, there's another message with the tool results
216
264
  // ref: https://github.com/vercel/ai/blob/main/packages/ai/src/generate-text/to-response-messages.ts#L120
@@ -228,13 +276,21 @@ export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
228
276
  sources: hasToolMessage ? undefined : step.sources,
229
277
  } satisfies Omit<MessageWithMetadata, "message" | "text" | "fileIds">;
230
278
  const toolFields = { sources: step.sources };
279
+
280
+ // Determine which messages to serialize for this step
281
+ let messagesToSerialize: ModelMessage[];
282
+ if (newResponseMessages) {
283
+ messagesToSerialize = newResponseMessages;
284
+ } else if (hasToolMessage) {
285
+ messagesToSerialize = step.response.messages.slice(-2);
286
+ } else if (step.content.length) {
287
+ messagesToSerialize = step.response.messages.slice(-1);
288
+ } else {
289
+ messagesToSerialize = [{ role: "assistant" as const, content: [] }];
290
+ }
291
+
231
292
  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> => {
293
+ messagesToSerialize.map(async (msg): Promise<MessageWithMetadata> => {
238
294
  const { message, fileIds } = await serializeMessage(ctx, component, msg);
239
295
  return parse(vMessageWithMetadata, {
240
296
  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,