@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
@@ -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,
@@ -241,16 +244,25 @@ export class DeltaStreamer<T> {
241
244
  this.abortController = new AbortController();
242
245
  if (config.abortSignal) {
243
246
  config.abortSignal.addEventListener("abort", async () => {
244
- if (this.abortController.signal.aborted) {
245
- return;
246
- }
247
- if (this.streamId) {
247
+ try {
248
+ if (this.abortController.signal.aborted) {
249
+ return;
250
+ }
248
251
  this.abortController.abort();
249
- await this.#ongoingWrite;
250
- await this.ctx.runMutation(this.component.streams.abort, {
251
- streamId: this.streamId,
252
- reason: "abortSignal",
253
- });
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.
254
266
  }
255
267
  });
256
268
  }
@@ -259,17 +271,16 @@ export class DeltaStreamer<T> {
259
271
  // Avoid race conditions by only creating once
260
272
  #creatingStreamIdPromise: Promise<string> | undefined;
261
273
  public async getStreamId() {
262
- if (this.streamId) {
263
- return this.streamId;
264
- }
265
- if (this.#creatingStreamIdPromise) {
266
- return this.#creatingStreamIdPromise;
274
+ if (!this.streamId) {
275
+ if (!this.#creatingStreamIdPromise) {
276
+ this.#creatingStreamIdPromise = this.ctx.runMutation(
277
+ this.component.streams.create,
278
+ this.metadata,
279
+ );
280
+ }
281
+ this.streamId = await this.#creatingStreamIdPromise;
267
282
  }
268
- this.#creatingStreamIdPromise = this.ctx.runMutation(
269
- this.component.streams.create,
270
- this.metadata,
271
- );
272
- this.streamId = await this.#creatingStreamIdPromise;
283
+ return this.streamId;
273
284
  }
274
285
 
275
286
  public async addParts(parts: T[]) {
@@ -290,7 +301,30 @@ export class DeltaStreamer<T> {
290
301
  for await (const chunk of stream) {
291
302
  await this.addParts([chunk]);
292
303
  }
293
- await this.finish();
304
+ // Skip finish if it will be handled externally (atomically with message save)
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) {
309
+ await this.finish();
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Mark the stream as being finished externally (e.g., atomically via addMessages).
315
+ * When called, consumeStream() will skip calling finish() since it will be
316
+ * handled elsewhere in the same mutation as message saving.
317
+ */
318
+ public markFinishedExternally(): void {
319
+ this.#finishedExternally = true;
320
+ }
321
+
322
+ /**
323
+ * Get the stream ID, waiting for it to be created if necessary.
324
+ * Useful for passing to addMessages for atomic finish.
325
+ */
326
+ public async getOrCreateStreamId(): Promise<string> {
327
+ return this.getStreamId();
294
328
  }
295
329
 
296
330
  async #sendDelta() {
@@ -317,7 +351,7 @@ export class DeltaStreamer<T> {
317
351
  e instanceof Error ? e.message : "unknown error",
318
352
  );
319
353
  this.abortController.abort();
320
- throw e;
354
+ return;
321
355
  }
322
356
  // Now that we've sent the delta, check if we need to send another one.
323
357
  if (
@@ -353,7 +387,10 @@ export class DeltaStreamer<T> {
353
387
  return;
354
388
  }
355
389
  await this.#ongoingWrite;
356
- await this.#sendDelta();
390
+ await this.#sendDelta(); // #sendDelta checks aborted internally
391
+ if (this.abortController.signal.aborted) {
392
+ return;
393
+ }
357
394
  await this.ctx.runMutation(this.component.streams.finish, {
358
395
  streamId: this.streamId,
359
396
  });
@@ -410,16 +447,15 @@ export function compressTextStreamParts(
410
447
  } else {
411
448
  compressed.push(part);
412
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
+ });
413
458
  } else {
414
- if (part.type === "file") {
415
- compressed.push({
416
- type: "file",
417
- file: {
418
- ...part.file,
419
- uint8Array: undefined as unknown as Uint8Array,
420
- },
421
- });
422
- }
423
459
  compressed.push(part);
424
460
  }
425
461
  }
@@ -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
  });