@convex-dev/agent 0.6.0-alpha.1 → 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 (44) hide show
  1. package/dist/UIMessages.d.ts.map +1 -1
  2. package/dist/UIMessages.js +88 -0
  3. package/dist/UIMessages.js.map +1 -1
  4. package/dist/client/definePlaygroundAPI.d.ts +17 -17
  5. package/dist/client/index.d.ts +44 -44
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +54 -20
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/client/messages.d.ts +3 -3
  10. package/dist/client/search.d.ts +3 -3
  11. package/dist/client/search.d.ts.map +1 -1
  12. package/dist/client/search.js +14 -4
  13. package/dist/client/search.js.map +1 -1
  14. package/dist/client/start.js +2 -2
  15. package/dist/client/start.js.map +1 -1
  16. package/dist/client/streamText.d.ts.map +1 -1
  17. package/dist/client/streamText.js +10 -0
  18. package/dist/client/streamText.js.map +1 -1
  19. package/dist/client/streaming.d.ts +47 -47
  20. package/dist/client/streaming.d.ts.map +1 -1
  21. package/dist/client/streaming.js +37 -21
  22. package/dist/client/streaming.js.map +1 -1
  23. package/dist/component/messages.d.ts +47 -47
  24. package/dist/component/schema.d.ts +40 -40
  25. package/dist/component/streams.d.ts +2 -2
  26. package/dist/component/threads.d.ts +6 -6
  27. package/dist/component/vector/index.d.ts +1 -1
  28. package/dist/mapping.d.ts +19 -15
  29. package/dist/mapping.d.ts.map +1 -1
  30. package/dist/mapping.js +90 -47
  31. package/dist/mapping.js.map +1 -1
  32. package/dist/validators.d.ts +13 -13
  33. package/package.json +1 -1
  34. package/src/UIMessages.ts +126 -0
  35. package/src/client/approval.test.ts +144 -0
  36. package/src/client/index.ts +73 -23
  37. package/src/client/search.test.ts +4 -5
  38. package/src/client/search.ts +16 -4
  39. package/src/client/start.ts +2 -2
  40. package/src/client/streamText.ts +9 -0
  41. package/src/client/streaming.integration.test.ts +1206 -0
  42. package/src/client/streaming.ts +35 -21
  43. package/src/mapping.test.ts +136 -71
  44. package/src/mapping.ts +119 -50
@@ -244,16 +244,25 @@ export class DeltaStreamer<T> {
244
244
  this.abortController = new AbortController();
245
245
  if (config.abortSignal) {
246
246
  config.abortSignal.addEventListener("abort", async () => {
247
- if (this.abortController.signal.aborted) {
248
- return;
249
- }
250
- if (this.streamId) {
247
+ try {
248
+ if (this.abortController.signal.aborted) {
249
+ return;
250
+ }
251
251
  this.abortController.abort();
252
- await this.#ongoingWrite;
253
- await this.ctx.runMutation(this.component.streams.abort, {
254
- streamId: this.streamId,
255
- reason: "abortSignal",
256
- });
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.
257
266
  }
258
267
  });
259
268
  }
@@ -293,7 +302,10 @@ export class DeltaStreamer<T> {
293
302
  await this.addParts([chunk]);
294
303
  }
295
304
  // Skip finish if it will be handled externally (atomically with message save)
296
- if (!this.#finishedExternally) {
305
+ // or if the stream was aborted (e.g., due to a failed delta write).
306
+ // Aborted streams are cleaned up via streams.abort (called by the abort
307
+ // signal handler), so we don't need to call finish() for them.
308
+ if (!this.#finishedExternally && !this.abortController.signal.aborted) {
297
309
  await this.finish();
298
310
  }
299
311
  }
@@ -339,7 +351,7 @@ export class DeltaStreamer<T> {
339
351
  e instanceof Error ? e.message : "unknown error",
340
352
  );
341
353
  this.abortController.abort();
342
- throw e;
354
+ return;
343
355
  }
344
356
  // Now that we've sent the delta, check if we need to send another one.
345
357
  if (
@@ -375,7 +387,10 @@ export class DeltaStreamer<T> {
375
387
  return;
376
388
  }
377
389
  await this.#ongoingWrite;
378
- await this.#sendDelta();
390
+ await this.#sendDelta(); // #sendDelta checks aborted internally
391
+ if (this.abortController.signal.aborted) {
392
+ return;
393
+ }
379
394
  await this.ctx.runMutation(this.component.streams.finish, {
380
395
  streamId: this.streamId,
381
396
  });
@@ -432,16 +447,15 @@ export function compressTextStreamParts(
432
447
  } else {
433
448
  compressed.push(part);
434
449
  }
450
+ } else if (part.type === "file") {
451
+ compressed.push({
452
+ type: "file",
453
+ file: {
454
+ ...part.file,
455
+ uint8Array: undefined as unknown as Uint8Array,
456
+ },
457
+ });
435
458
  } else {
436
- if (part.type === "file") {
437
- compressed.push({
438
- type: "file",
439
- file: {
440
- ...part.file,
441
- uint8Array: undefined as unknown as Uint8Array,
442
- },
443
- });
444
- }
445
459
  compressed.push(part);
446
460
  }
447
461
  }
@@ -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,7 +7,7 @@ import {
7
7
  toModelMessage,
8
8
  serializeContent,
9
9
  toModelMessageContent,
10
- mergeApprovalResponseMessages,
10
+ autoDenyUnresolvedApprovals,
11
11
  } from "./mapping.js";
12
12
  import { api } from "./component/_generated/api.js";
13
13
  import type { AgentComponent, ActionCtx } from "./client/types.js";
@@ -259,79 +259,144 @@ describe("mapping", () => {
259
259
  expect((content as unknown[])[0]).toMatchObject(approvalResponse);
260
260
  });
261
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;
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;
287
280
 
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
- });
281
+ const result = autoDenyUnresolvedApprovals(messages);
282
+ expect(result).toBe(messages); // same reference, no changes
283
+ });
296
284
 
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;
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;
308
297
 
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
- });
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
+ });
317
312
 
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;
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);
333
391
 
334
- const merged = mergeApprovalResponseMessages(messages);
335
- expect(merged).toHaveLength(2); // not merged since first has tool-result
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
+ });
336
401
  });
337
402
  });
package/src/mapping.ts CHANGED
@@ -140,41 +140,94 @@ export function docsToModelMessages(messages: MessageDoc[]): ModelMessage[] {
140
140
  }
141
141
 
142
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.
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.
148
150
  */
149
- export function mergeApprovalResponseMessages(
151
+ export function autoDenyUnresolvedApprovals(
150
152
  messages: ModelMessage[],
151
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
152
213
  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);
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
+ });
169
227
  }
170
228
  }
171
- return result;
172
- }
173
229
 
174
- function hasApprovalResponse(content: any[]): boolean {
175
- return content.some(
176
- (p: any) => p.type === "tool-approval-response",
177
- );
230
+ return result;
178
231
  }
179
232
 
180
233
  export function serializeUsage(usage: LanguageModelUsage): Usage {
@@ -244,21 +297,49 @@ export function toModelMessageWarnings(
244
297
  }) as any;
245
298
  }
246
299
 
300
+ /**
301
+ * Serialize explicitly provided response messages for a step.
302
+ * Used by the streaming/generation loop where the caller tracks which
303
+ * messages are new via slicing.
304
+ */
305
+ export async function serializeResponseMessages<TOOLS extends ToolSet>(
306
+ ctx: ActionCtx,
307
+ component: AgentComponent,
308
+ step: StepResult<TOOLS>,
309
+ model: ModelOrMetadata | undefined,
310
+ responseMessages: ModelMessage[],
311
+ ): Promise<{ messages: MessageWithMetadata[] }> {
312
+ return serializeStepMessages(ctx, component, step, model, responseMessages);
313
+ }
314
+
315
+ /**
316
+ * Serialize the new messages from a step using a heuristic to determine
317
+ * which response messages are new (last 1-2 messages).
318
+ */
247
319
  export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
248
320
  ctx: ActionCtx,
249
321
  component: AgentComponent,
250
322
  step: StepResult<TOOLS>,
251
323
  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[],
324
+ ): Promise<{ messages: MessageWithMetadata[] }> {
325
+ const hasToolMessage = step.response.messages.at(-1)?.role === "tool";
326
+ let messagesToSerialize: ModelMessage[];
327
+ if (hasToolMessage) {
328
+ messagesToSerialize = step.response.messages.slice(-2);
329
+ } else if (step.content.length) {
330
+ messagesToSerialize = step.response.messages.slice(-1);
331
+ } else {
332
+ messagesToSerialize = [{ role: "assistant" as const, content: [] }];
333
+ }
334
+ return serializeStepMessages(ctx, component, step, model, messagesToSerialize);
335
+ }
336
+
337
+ async function serializeStepMessages<TOOLS extends ToolSet>(
338
+ ctx: ActionCtx,
339
+ component: AgentComponent,
340
+ step: StepResult<TOOLS>,
341
+ model: ModelOrMetadata | undefined,
342
+ messagesToSerialize: ModelMessage[],
262
343
  ): Promise<{ messages: MessageWithMetadata[] }> {
263
344
  // If there are tool results, there's another message with the tool results
264
345
  // ref: https://github.com/vercel/ai/blob/main/packages/ai/src/generate-text/to-response-messages.ts#L120
@@ -277,18 +358,6 @@ export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
277
358
  } satisfies Omit<MessageWithMetadata, "message" | "text" | "fileIds">;
278
359
  const toolFields = { sources: step.sources };
279
360
 
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
-
292
361
  const messages: MessageWithMetadata[] = await Promise.all(
293
362
  messagesToSerialize.map(async (msg): Promise<MessageWithMetadata> => {
294
363
  const { message, fileIds } = await serializeMessage(ctx, component, msg);