@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.
- package/MIGRATION.md +153 -0
- package/dist/UIMessages.d.ts.map +1 -1
- package/dist/UIMessages.js +88 -0
- package/dist/UIMessages.js.map +1 -1
- package/dist/client/createTool.d.ts +18 -21
- package/dist/client/createTool.d.ts.map +1 -1
- package/dist/client/createTool.js +3 -2
- package/dist/client/createTool.js.map +1 -1
- package/dist/client/definePlaygroundAPI.d.ts +31 -31
- package/dist/client/index.d.ts +70 -23
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +126 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/messages.d.ts +9 -9
- package/dist/client/mockModel.d.ts.map +1 -1
- package/dist/client/mockModel.js +9 -2
- package/dist/client/mockModel.js.map +1 -1
- package/dist/client/search.d.ts +9 -9
- package/dist/client/search.d.ts.map +1 -1
- package/dist/client/search.js +12 -2
- package/dist/client/search.js.map +1 -1
- package/dist/client/start.d.ts +1 -1
- package/dist/client/start.d.ts.map +1 -1
- package/dist/client/start.js +29 -15
- package/dist/client/start.js.map +1 -1
- package/dist/client/streamText.d.ts.map +1 -1
- package/dist/client/streamText.js +37 -3
- package/dist/client/streamText.js.map +1 -1
- package/dist/client/streaming.d.ts +78 -67
- package/dist/client/streaming.d.ts.map +1 -1
- package/dist/client/streaming.js +64 -28
- package/dist/client/streaming.js.map +1 -1
- package/dist/client/types.d.ts +13 -12
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +1 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/messages.d.ts +107 -106
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +13 -3
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +40 -40
- package/dist/component/streams.d.ts +4 -4
- package/dist/component/threads.d.ts +17 -17
- package/dist/component/users.d.ts +3 -3
- package/dist/component/vector/index.d.ts +1 -1
- package/dist/deltas.d.ts.map +1 -1
- package/dist/deltas.js +0 -1
- package/dist/deltas.js.map +1 -1
- package/dist/mapping.d.ts +22 -0
- package/dist/mapping.d.ts.map +1 -1
- package/dist/mapping.js +99 -5
- package/dist/mapping.js.map +1 -1
- package/dist/react/useDeltaStreams.d.ts.map +1 -1
- package/dist/react/useDeltaStreams.js +5 -0
- package/dist/react/useDeltaStreams.js.map +1 -1
- package/dist/validators.d.ts +13 -13
- package/package.json +4 -2
- package/src/UIMessages.ts +126 -0
- package/src/client/approval.test.ts +494 -0
- package/src/client/createTool.ts +50 -52
- package/src/client/index.ts +170 -1
- package/src/client/mockModel.ts +9 -2
- package/src/client/search.test.ts +4 -5
- package/src/client/search.ts +18 -2
- package/src/client/start.ts +42 -25
- package/src/client/streamText.ts +36 -3
- package/src/client/streaming.integration.test.ts +1206 -0
- package/src/client/streaming.ts +67 -31
- package/src/client/types.ts +14 -12
- package/src/component/_generated/component.ts +53 -64
- package/src/component/messages.ts +12 -2
- package/src/deltas.ts +0 -1
- package/src/mapping.test.ts +143 -1
- package/src/mapping.ts +131 -6
- package/src/react/useDeltaStreams.ts +6 -0
package/src/client/streaming.ts
CHANGED
|
@@ -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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
try {
|
|
248
|
+
if (this.abortController.signal.aborted) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
248
251
|
this.abortController.abort();
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/client/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
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
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
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
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
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(
|
package/src/mapping.test.ts
CHANGED
|
@@ -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
|
});
|