@convex-dev/agent 0.7.1 → 0.7.2
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/README.md +5 -8
- package/dist/client/messages.d.ts +10 -0
- package/dist/client/messages.d.ts.map +1 -1
- package/dist/client/messages.js +1 -0
- package/dist/client/messages.js.map +1 -1
- package/dist/component/_generated/component.d.ts +10 -8
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/messages.d.ts +340 -338
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +74 -14
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +726 -622
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/streams.d.ts +6 -1
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +14 -13
- package/dist/component/streams.js.map +1 -1
- package/dist/component/vector/index.d.ts +9 -9
- package/dist/component/vector/index.d.ts.map +1 -1
- package/dist/component/vector/tables.d.ts +5 -5
- package/dist/component/vector/tables.d.ts.map +1 -1
- package/dist/component/vector/tables.js +1 -1
- package/dist/component/vector/tables.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +106 -0
- package/dist/errors.js.map +1 -0
- package/dist/validators.d.ts +1952 -1952
- package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
- package/dist/vercel/client/messages.d.ts +50 -32
- package/dist/vercel/client/messages.d.ts.map +1 -1
- package/dist/vercel/client/messages.js +2 -0
- package/dist/vercel/client/messages.js.map +1 -1
- package/dist/vercel/client/search.d.ts +475 -32
- package/dist/vercel/client/search.d.ts.map +1 -1
- package/dist/vercel/client/search.js +34 -9
- package/dist/vercel/client/search.js.map +1 -1
- package/dist/vercel/client/start.d.ts.map +1 -1
- package/dist/vercel/client/start.js +1 -0
- package/dist/vercel/client/start.js.map +1 -1
- package/dist/vercel/client/streamText.d.ts +1 -2
- package/dist/vercel/client/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +47 -17
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +304 -301
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +22 -9
- package/dist/vercel/client/streaming.js.map +1 -1
- package/dist/vercel/client/types.d.ts +2 -0
- package/dist/vercel/client/types.d.ts.map +1 -1
- package/dist/vercel/client/types.js.map +1 -1
- package/dist/vercel/client/utils.d.ts +9 -5
- package/dist/vercel/client/utils.d.ts.map +1 -1
- package/dist/vercel/client/utils.js +13 -13
- package/dist/vercel/client/utils.js.map +1 -1
- package/dist/vercel/index.d.ts +91 -90
- package/dist/vercel/index.d.ts.map +1 -1
- package/dist/vercel/index.js +2 -0
- package/dist/vercel/index.js.map +1 -1
- package/package.json +16 -9
- package/src/client/messages.ts +12 -0
- package/src/component/_generated/component.ts +10 -0
- package/src/component/messages.test.ts +404 -0
- package/src/component/messages.ts +94 -12
- package/src/component/streams.ts +20 -15
- package/src/component/vector/tables.ts +1 -1
- package/src/errors.test.ts +76 -0
- package/src/errors.ts +113 -0
- package/src/vercel/client/index.test.ts +47 -0
- package/src/vercel/client/messages.ts +20 -0
- package/src/vercel/client/search.test.ts +183 -8
- package/src/vercel/client/search.ts +46 -12
- package/src/vercel/client/start.ts +1 -0
- package/src/vercel/client/streamText.test.ts +113 -17
- package/src/vercel/client/streamText.ts +52 -28
- package/src/vercel/client/streaming.test.ts +71 -7
- package/src/vercel/client/streaming.ts +27 -7
- package/src/vercel/client/types.ts +2 -0
- package/src/vercel/client/utils.test.ts +97 -0
- package/src/vercel/client/utils.ts +15 -15
- package/src/vercel/index.ts +2 -0
|
@@ -11,13 +11,27 @@ import {
|
|
|
11
11
|
import { v } from "convex/values";
|
|
12
12
|
import { components, initConvexTest } from "./setup.test.js";
|
|
13
13
|
import { mockModel } from "./mockModel.js";
|
|
14
|
-
import {
|
|
14
|
+
import { runStreamCleanup } from "./streamText.js";
|
|
15
|
+
import { errorToString } from "./utils.js";
|
|
15
16
|
|
|
16
17
|
const schema = defineSchema({});
|
|
17
18
|
type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
|
18
19
|
const action = actionGeneric as ActionBuilder<DataModel, "public">;
|
|
19
20
|
|
|
20
21
|
const FINAL_TEXT = "Hello from the model";
|
|
22
|
+
const PROVIDER_FAILURE_TEXT = "Mock provider failure";
|
|
23
|
+
const CLEANUP_FAILURE_TEXT = "finalizeMessage rejected";
|
|
24
|
+
|
|
25
|
+
function hasKeys(
|
|
26
|
+
value: unknown,
|
|
27
|
+
keys: string[],
|
|
28
|
+
): value is Record<string, unknown> {
|
|
29
|
+
return (
|
|
30
|
+
value !== null &&
|
|
31
|
+
typeof value === "object" &&
|
|
32
|
+
keys.every((key) => key in value)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
21
35
|
|
|
22
36
|
const agent = new Agent(components.agent, {
|
|
23
37
|
name: "stream-test",
|
|
@@ -34,6 +48,14 @@ const emptyAgent = new Agent(components.agent, {
|
|
|
34
48
|
}),
|
|
35
49
|
});
|
|
36
50
|
|
|
51
|
+
const failingAgent = new Agent(components.agent, {
|
|
52
|
+
name: "failing-stream-test",
|
|
53
|
+
languageModel: mockModel({
|
|
54
|
+
content: [{ type: "text", text: "partial response" }],
|
|
55
|
+
fail: { error: PROVIDER_FAILURE_TEXT },
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
37
59
|
// Action that exercises streamText with saveStreamDeltas.returnImmediately=true.
|
|
38
60
|
// It consumes the stream after streamText returns, simulating the HTTP response
|
|
39
61
|
// path described in issue #265.
|
|
@@ -91,11 +113,49 @@ export const streamTextEmptyReturnImmediately = action({
|
|
|
91
113
|
},
|
|
92
114
|
});
|
|
93
115
|
|
|
116
|
+
export const streamTextCleanupFailure = action({
|
|
117
|
+
args: { threadId: v.string() },
|
|
118
|
+
handler: async (ctx, { threadId }) => {
|
|
119
|
+
const providerErrors: string[] = [];
|
|
120
|
+
let aborts = 0;
|
|
121
|
+
const failingCtx = {
|
|
122
|
+
...ctx,
|
|
123
|
+
runMutation: (async (reference, args) => {
|
|
124
|
+
if (hasKeys(args, ["messageId", "result"])) {
|
|
125
|
+
throw new Error(CLEANUP_FAILURE_TEXT);
|
|
126
|
+
}
|
|
127
|
+
return ctx.runMutation(reference, args);
|
|
128
|
+
}) as typeof ctx.runMutation,
|
|
129
|
+
};
|
|
130
|
+
let caught: string | undefined;
|
|
131
|
+
try {
|
|
132
|
+
await failingAgent.streamText(
|
|
133
|
+
failingCtx,
|
|
134
|
+
{ threadId },
|
|
135
|
+
{
|
|
136
|
+
prompt: "Test",
|
|
137
|
+
onError: ({ error }) => {
|
|
138
|
+
providerErrors.push(errorToString(error));
|
|
139
|
+
},
|
|
140
|
+
onAbort: () => {
|
|
141
|
+
aborts += 1;
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{ saveStreamDeltas: { chunking: "word", throttleMs: 0 } },
|
|
145
|
+
);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
caught = errorToString(error);
|
|
148
|
+
}
|
|
149
|
+
return { providerErrors, aborts, caught };
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
94
153
|
const testApi: ApiFromModules<{
|
|
95
154
|
fns: {
|
|
96
155
|
streamTextReturnImmediately: typeof streamTextReturnImmediately;
|
|
97
156
|
streamTextEmptyAwaited: typeof streamTextEmptyAwaited;
|
|
98
157
|
streamTextEmptyReturnImmediately: typeof streamTextEmptyReturnImmediately;
|
|
158
|
+
streamTextCleanupFailure: typeof streamTextCleanupFailure;
|
|
99
159
|
};
|
|
100
160
|
}>["fns"] = anyApi["streamText.test"] as any;
|
|
101
161
|
|
|
@@ -147,28 +207,64 @@ describe("streamText with saveStreamDeltas.returnImmediately (issue #265)", () =
|
|
|
147
207
|
});
|
|
148
208
|
|
|
149
209
|
describe("streamText abort cleanup", () => {
|
|
150
|
-
test("
|
|
210
|
+
test("finishes durable cleanup before invoking onAbort", async () => {
|
|
151
211
|
const calls: string[] = [];
|
|
152
|
-
|
|
212
|
+
let resolveStreamer!: () => void;
|
|
213
|
+
const syncFailure = new Error("synchronous pending message cleanup");
|
|
153
214
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
215
|
+
const cleanup = runStreamCleanup({
|
|
216
|
+
failCall: () => {
|
|
217
|
+
calls.push("call.fail");
|
|
218
|
+
throw syncFailure;
|
|
219
|
+
},
|
|
220
|
+
failStreamer: () =>
|
|
221
|
+
new Promise<void>((resolve) => {
|
|
161
222
|
calls.push("streamer.fail");
|
|
162
|
-
|
|
163
|
-
},
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
223
|
+
resolveStreamer = resolve;
|
|
224
|
+
}),
|
|
225
|
+
onAbort: () => {
|
|
226
|
+
calls.push("user.onAbort");
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await Promise.resolve();
|
|
231
|
+
expect(calls).toEqual(["call.fail", "streamer.fail"]);
|
|
232
|
+
resolveStreamer();
|
|
233
|
+
await expect(cleanup).rejects.toBe(syncFailure);
|
|
169
234
|
|
|
170
235
|
expect(calls).toEqual(["call.fail", "streamer.fail", "user.onAbort"]);
|
|
171
236
|
});
|
|
237
|
+
|
|
238
|
+
test("surfaces a cleanup failure without hiding the provider error", async () => {
|
|
239
|
+
const t = initConvexTest(schema);
|
|
240
|
+
const threadId = await t.run(async (ctx) =>
|
|
241
|
+
createThread(ctx, components.agent, { userId: "u1" }),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const { providerErrors, aborts, caught } = await t.action(
|
|
245
|
+
testApi.streamTextCleanupFailure,
|
|
246
|
+
{ threadId },
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(providerErrors).toEqual([PROVIDER_FAILURE_TEXT]);
|
|
250
|
+
expect(aborts).toBe(0);
|
|
251
|
+
expect(caught).toBe(CLEANUP_FAILURE_TEXT);
|
|
252
|
+
|
|
253
|
+
const streaming = await t.run(async (ctx) =>
|
|
254
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
255
|
+
threadId,
|
|
256
|
+
statuses: ["streaming"],
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
const aborted = await t.run(async (ctx) =>
|
|
260
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
261
|
+
threadId,
|
|
262
|
+
statuses: ["aborted"],
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
expect(streaming).toHaveLength(0);
|
|
266
|
+
expect(aborted).toHaveLength(1);
|
|
267
|
+
});
|
|
172
268
|
});
|
|
173
269
|
|
|
174
270
|
describe("streamText with an empty final step (issue #274)", () => {
|
|
@@ -27,19 +27,22 @@ import { getModelName, getProviderName } from "../../shared.js";
|
|
|
27
27
|
import { errorToString, willContinue } from "./utils.js";
|
|
28
28
|
import { materializeUIMessageChunkFiles } from "../fileMaterialization.js";
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
export async function runAbortCleanup(cleanup: {
|
|
30
|
+
export async function runStreamCleanup(cleanup: {
|
|
32
31
|
failCall: () => Promise<void>;
|
|
33
32
|
failStreamer: () => Promise<void>;
|
|
34
33
|
onAbort?: () => PromiseLike<void> | void;
|
|
35
34
|
}): Promise<void> {
|
|
36
35
|
const results = await Promise.allSettled([
|
|
37
|
-
cleanup.failCall(),
|
|
38
|
-
cleanup.failStreamer(),
|
|
36
|
+
Promise.resolve().then(() => cleanup.failCall()),
|
|
37
|
+
Promise.resolve().then(() => cleanup.failStreamer()),
|
|
39
38
|
]);
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
const [abortResult] = await Promise.allSettled([
|
|
40
|
+
Promise.resolve().then(() => cleanup.onAbort?.()),
|
|
41
|
+
]);
|
|
42
|
+
const failure = [...results, abortResult].find(
|
|
43
|
+
(result) => result.status === "rejected",
|
|
44
|
+
);
|
|
45
|
+
if (failure?.status === "rejected") throw failure.reason;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/**
|
|
@@ -103,15 +106,11 @@ export async function streamText<
|
|
|
103
106
|
Tools,
|
|
104
107
|
object,
|
|
105
108
|
RUNTIME_CONTEXT
|
|
106
|
-
>(
|
|
107
|
-
ctx,
|
|
108
|
-
component,
|
|
109
|
-
streamTextArgs,
|
|
110
|
-
options,
|
|
111
|
-
"streamText",
|
|
112
|
-
);
|
|
109
|
+
>(ctx, component, streamTextArgs, options, "streamText");
|
|
113
110
|
|
|
114
111
|
const steps: StepResult<Tools, RUNTIME_CONTEXT>[] = [];
|
|
112
|
+
let firstStreamError: string | undefined;
|
|
113
|
+
let streamCleanupFailure: { error: unknown } | undefined;
|
|
115
114
|
let initialResponseMessages: ModelMessage[] = [];
|
|
116
115
|
let initialResponseMessagesSaved = false;
|
|
117
116
|
const responseMessagesForStep = (
|
|
@@ -180,15 +179,26 @@ export async function streamText<
|
|
|
180
179
|
),
|
|
181
180
|
onError: async (error) => {
|
|
182
181
|
console.error("onError", error);
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
const reason = (firstStreamError ??= errorToString(error.error));
|
|
183
|
+
try {
|
|
184
|
+
await runStreamCleanup({
|
|
185
|
+
failCall: () => call.fail(reason),
|
|
186
|
+
failStreamer: async () => streamer?.fail(reason),
|
|
187
|
+
});
|
|
188
|
+
} catch (cleanupError) {
|
|
189
|
+
streamCleanupFailure ??= { error: cleanupError };
|
|
190
|
+
console.error("Failed to clean up errored stream:", cleanupError);
|
|
191
|
+
}
|
|
185
192
|
return streamTextArgs.onError?.(error);
|
|
186
193
|
},
|
|
187
194
|
onAbort: async (event) => {
|
|
195
|
+
const providerTriggeredAbort =
|
|
196
|
+
firstStreamError !== undefined && !args.abortSignal?.aborted;
|
|
197
|
+
if (providerTriggeredAbort) return;
|
|
188
198
|
const reason = args.abortSignal?.reason
|
|
189
199
|
? errorToString(args.abortSignal.reason)
|
|
190
200
|
: "streamText aborted";
|
|
191
|
-
await
|
|
201
|
+
await runStreamCleanup({
|
|
192
202
|
failCall: () => call.fail(reason),
|
|
193
203
|
failStreamer: async () => streamer?.fail(reason),
|
|
194
204
|
onAbort: () => streamTextArgs.onAbort?.(event),
|
|
@@ -227,13 +237,17 @@ export async function streamText<
|
|
|
227
237
|
// returnImmediately path: streamText is about to return without
|
|
228
238
|
// awaiting consumption, so the deferred-save block below won't
|
|
229
239
|
// see this step. Save inline now (issue #265).
|
|
230
|
-
const finishStreamId = await streamer.getOrCreateStreamId(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
240
|
+
const finishStreamId = await streamer.getOrCreateStreamId({
|
|
241
|
+
ifAborted: "returnUndefined",
|
|
242
|
+
});
|
|
243
|
+
if (finishStreamId) {
|
|
244
|
+
await call.save(
|
|
245
|
+
{ step, responseMessages: responseMessagesForStep(step) },
|
|
246
|
+
false,
|
|
247
|
+
finishStreamId,
|
|
248
|
+
);
|
|
249
|
+
initialResponseMessagesSaved = true;
|
|
250
|
+
}
|
|
237
251
|
}
|
|
238
252
|
} else {
|
|
239
253
|
await call.save(
|
|
@@ -258,8 +272,11 @@ export async function streamText<
|
|
|
258
272
|
// If the stream errored (e.g. onStepFinish threw), the DeltaStreamer's
|
|
259
273
|
// finish() was never called, leaving the streaming message stuck in
|
|
260
274
|
// "streaming" state. Clean it up by marking it as aborted.
|
|
261
|
-
|
|
262
|
-
|
|
275
|
+
try {
|
|
276
|
+
await streamer?.fail(errorToString(e));
|
|
277
|
+
} catch (cleanupError) {
|
|
278
|
+
streamCleanupFailure ??= { error: cleanupError };
|
|
279
|
+
}
|
|
263
280
|
if (pendingFinalStep) {
|
|
264
281
|
try {
|
|
265
282
|
await call.save(pendingFinalStep, false);
|
|
@@ -272,10 +289,17 @@ export async function streamText<
|
|
|
272
289
|
}
|
|
273
290
|
}
|
|
274
291
|
|
|
292
|
+
if (streamCleanupFailure) throw streamCleanupFailure.error;
|
|
293
|
+
|
|
275
294
|
// If we deferred the final step save, do it now with atomic stream finish.
|
|
276
295
|
if (pendingFinalStep && streamer) {
|
|
277
|
-
const finishStreamId = await streamer.getOrCreateStreamId(
|
|
278
|
-
|
|
296
|
+
const finishStreamId = await streamer.getOrCreateStreamId({
|
|
297
|
+
ifAborted: "returnUndefined",
|
|
298
|
+
});
|
|
299
|
+
if (finishStreamId) {
|
|
300
|
+
await call.save(pendingFinalStep, false, finishStreamId);
|
|
301
|
+
}
|
|
302
|
+
pendingFinalStep = undefined;
|
|
279
303
|
}
|
|
280
304
|
const metadata: GenerationOutputMetadata = {
|
|
281
305
|
promptMessageId,
|
|
@@ -5,10 +5,7 @@ import type { GenericSchema, SchemaDefinition } from "convex/server";
|
|
|
5
5
|
import { streamText } from "ai";
|
|
6
6
|
import { components, initConvexTest } from "./setup.test.js";
|
|
7
7
|
import { mockModel } from "./mockModel.js";
|
|
8
|
-
import {
|
|
9
|
-
compressUIMessageChunks,
|
|
10
|
-
DeltaStreamer,
|
|
11
|
-
} from "./streaming.js";
|
|
8
|
+
import { compressUIMessageChunks, DeltaStreamer } from "./streaming.js";
|
|
12
9
|
import { getParts } from "../deltas.js";
|
|
13
10
|
import type { TestConvex } from "convex-test";
|
|
14
11
|
|
|
@@ -200,12 +197,73 @@ describe("DeltaStreamer", () => {
|
|
|
200
197
|
expect(streamer.abortController.signal.aborted).toBe(true);
|
|
201
198
|
await streamer.addParts(["ignored"]);
|
|
202
199
|
expect(streamer.streamId).toBeUndefined();
|
|
200
|
+
await expect(
|
|
201
|
+
streamer.getOrCreateStreamId({ ifAborted: "returnUndefined" }),
|
|
202
|
+
).resolves.toBeUndefined();
|
|
203
203
|
await expect(streamer.getOrCreateStreamId()).rejects.toThrow(
|
|
204
204
|
"Cannot create a stream after it has been aborted",
|
|
205
205
|
);
|
|
206
|
+
await expect(streamer.getStreamId()).rejects.toThrow(
|
|
207
|
+
"Cannot create a stream after it has been aborted",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("preserves the public throwing behavior after an existing stream aborts", async () => {
|
|
213
|
+
await t.run(async (ctx) => {
|
|
214
|
+
const streamer = new DeltaStreamer<string>(
|
|
215
|
+
components.agent,
|
|
216
|
+
ctx,
|
|
217
|
+
{ ...defaultTestOptions },
|
|
218
|
+
{ ...testMetadata, threadId },
|
|
219
|
+
);
|
|
220
|
+
const streamId = await streamer.getStreamId();
|
|
221
|
+
|
|
222
|
+
await streamer.fail("provider error");
|
|
223
|
+
|
|
224
|
+
expect(streamer.streamId).toBe(streamId);
|
|
225
|
+
await expect(streamer.getStreamId()).rejects.toThrow(
|
|
226
|
+
"Cannot create a stream after it has been aborted",
|
|
227
|
+
);
|
|
228
|
+
await expect(streamer.getOrCreateStreamId()).rejects.toThrow(
|
|
229
|
+
"Cannot create a stream after it has been aborted",
|
|
230
|
+
);
|
|
231
|
+
await expect(
|
|
232
|
+
streamer.getOrCreateStreamId({ ifAborted: "returnUndefined" }),
|
|
233
|
+
).resolves.toBeUndefined();
|
|
206
234
|
});
|
|
207
235
|
});
|
|
208
236
|
|
|
237
|
+
test("does not return an ID when abort wins during stream creation", async () => {
|
|
238
|
+
let resolveCreate!: (streamId: string) => void;
|
|
239
|
+
const creatingStream = new Promise<string>((resolve) => {
|
|
240
|
+
resolveCreate = resolve;
|
|
241
|
+
});
|
|
242
|
+
const runMutation = vi
|
|
243
|
+
.fn()
|
|
244
|
+
.mockImplementationOnce(() => creatingStream)
|
|
245
|
+
.mockResolvedValueOnce(true);
|
|
246
|
+
const streamer = new DeltaStreamer<string>(
|
|
247
|
+
components.agent,
|
|
248
|
+
{ runMutation } as unknown as MutationCtx,
|
|
249
|
+
{ ...defaultTestOptions },
|
|
250
|
+
{ ...testMetadata, threadId },
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const getting = streamer.getOrCreateStreamId({
|
|
254
|
+
ifAborted: "returnUndefined",
|
|
255
|
+
});
|
|
256
|
+
const failing = streamer.fail("provider error");
|
|
257
|
+
resolveCreate("stream-1");
|
|
258
|
+
|
|
259
|
+
await failing;
|
|
260
|
+
await expect(getting).resolves.toBeUndefined();
|
|
261
|
+
expect(streamer.streamId).toBe("stream-1");
|
|
262
|
+
await expect(streamer.getStreamId()).rejects.toThrow(
|
|
263
|
+
"Cannot create a stream after it has been aborted",
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
209
267
|
test("shares signal and fail cleanup while stream creation is in flight", async () => {
|
|
210
268
|
let resolveCreate!: (streamId: string) => void;
|
|
211
269
|
const creatingStream = new Promise<string>((resolve) => {
|
|
@@ -293,10 +351,13 @@ describe("DeltaStreamer", () => {
|
|
|
293
351
|
});
|
|
294
352
|
|
|
295
353
|
test("aborts the component stream when a delta write fails", async () => {
|
|
354
|
+
const deltaFailure = {
|
|
355
|
+
error: { code: "provider_disconnected", message: "Provider dropped" },
|
|
356
|
+
};
|
|
296
357
|
const runMutation = vi
|
|
297
358
|
.fn()
|
|
298
359
|
.mockResolvedValueOnce("stream-1")
|
|
299
|
-
.mockRejectedValueOnce(
|
|
360
|
+
.mockRejectedValueOnce(deltaFailure)
|
|
300
361
|
.mockResolvedValueOnce(undefined);
|
|
301
362
|
let abortReason: string | undefined;
|
|
302
363
|
const streamer = new DeltaStreamer<string>(
|
|
@@ -314,11 +375,14 @@ describe("DeltaStreamer", () => {
|
|
|
314
375
|
await streamer.addParts(["A"]);
|
|
315
376
|
await streamer.finish();
|
|
316
377
|
|
|
317
|
-
expect(abortReason).toBe("
|
|
378
|
+
expect(abortReason).toBe("provider_disconnected: Provider dropped");
|
|
318
379
|
expect(runMutation).toHaveBeenNthCalledWith(
|
|
319
380
|
3,
|
|
320
381
|
components.agent.streams.abort,
|
|
321
|
-
{
|
|
382
|
+
{
|
|
383
|
+
streamId: "stream-1",
|
|
384
|
+
reason: "provider_disconnected: Provider dropped",
|
|
385
|
+
},
|
|
322
386
|
);
|
|
323
387
|
});
|
|
324
388
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type UIMessageChunk,
|
|
9
9
|
} from "ai";
|
|
10
10
|
import { v } from "convex/values";
|
|
11
|
+
import { errorToString } from "../../errors.js";
|
|
11
12
|
import {
|
|
12
13
|
vMessageDoc,
|
|
13
14
|
vPaginationResult,
|
|
@@ -300,7 +301,10 @@ export class DeltaStreamer<T> {
|
|
|
300
301
|
if (this.#finishedExternally) {
|
|
301
302
|
return;
|
|
302
303
|
}
|
|
303
|
-
await this.
|
|
304
|
+
const streamId = await this.getOrCreateStreamId({
|
|
305
|
+
ifAborted: "returnUndefined",
|
|
306
|
+
});
|
|
307
|
+
if (!streamId) return;
|
|
304
308
|
this.#nextParts.push(...parts);
|
|
305
309
|
if (
|
|
306
310
|
!this.#ongoingWrite &&
|
|
@@ -319,9 +323,7 @@ export class DeltaStreamer<T> {
|
|
|
319
323
|
// A provider can throw while responding to an abort. Join the durable
|
|
320
324
|
// abort transition here, outside the active delta writer, before
|
|
321
325
|
// preserving the provider error for the caller.
|
|
322
|
-
await this.#abort(
|
|
323
|
-
error instanceof Error ? error.message : "stream consumption failed",
|
|
324
|
-
).catch(() => {});
|
|
326
|
+
await this.#abort(errorToString(error)).catch(() => {});
|
|
325
327
|
throw error;
|
|
326
328
|
}
|
|
327
329
|
// Skip finish if it will be handled externally (atomically with message save)
|
|
@@ -348,8 +350,26 @@ export class DeltaStreamer<T> {
|
|
|
348
350
|
* Get the stream ID, waiting for it to be created if necessary.
|
|
349
351
|
* Useful for passing to addMessages for atomic finish.
|
|
350
352
|
*/
|
|
351
|
-
public async getOrCreateStreamId(): Promise<string
|
|
352
|
-
|
|
353
|
+
public async getOrCreateStreamId(): Promise<string>;
|
|
354
|
+
public async getOrCreateStreamId(options: {
|
|
355
|
+
ifAborted: "returnUndefined";
|
|
356
|
+
}): Promise<string | undefined>;
|
|
357
|
+
public async getOrCreateStreamId(options?: {
|
|
358
|
+
ifAborted?: "returnUndefined";
|
|
359
|
+
}): Promise<string | undefined> {
|
|
360
|
+
if (options?.ifAborted !== "returnUndefined") {
|
|
361
|
+
return this.getStreamId();
|
|
362
|
+
}
|
|
363
|
+
if (this.abortController.signal.aborted) {
|
|
364
|
+
await this.#abortPromise;
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
const streamId = await this.getStreamId();
|
|
368
|
+
if (this.abortController.signal.aborted) {
|
|
369
|
+
await this.#abortPromise;
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
return streamId;
|
|
353
373
|
}
|
|
354
374
|
|
|
355
375
|
async #sendDelta() {
|
|
@@ -368,7 +388,7 @@ export class DeltaStreamer<T> {
|
|
|
368
388
|
delta,
|
|
369
389
|
);
|
|
370
390
|
} catch (e) {
|
|
371
|
-
await this.#abortDelta(e
|
|
391
|
+
await this.#abortDelta(errorToString(e));
|
|
372
392
|
return;
|
|
373
393
|
}
|
|
374
394
|
if (!success) {
|
|
@@ -203,6 +203,8 @@ export type ContextOptions = {
|
|
|
203
203
|
/**
|
|
204
204
|
* How many recent messages to include. These are added after the search
|
|
205
205
|
* messages, and do not count against the search limit.
|
|
206
|
+
* If the limit splits an order, additional messages are included to complete
|
|
207
|
+
* that order. If it cannot be completed, the incomplete order is omitted.
|
|
206
208
|
* Default: 100
|
|
207
209
|
*/
|
|
208
210
|
recentMessages?: number;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { StepResult } from "ai";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { hasSuccessfulToolCall, willContinue } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
// Minimal StepResult builder — only the fields willContinue and
|
|
6
|
+
// hasSuccessfulToolCall actually read. Loosely typed on purpose so test
|
|
7
|
+
// fixtures can be terse; cast at the boundary.
|
|
8
|
+
type StepFixture = {
|
|
9
|
+
finishReason?: string;
|
|
10
|
+
content?: Array<{ type: string; toolName?: string }>;
|
|
11
|
+
toolCalls?: Array<{ toolCallId: string; toolName: string }>;
|
|
12
|
+
toolResults?: Array<{ toolCallId: string; toolName: string }>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function makeStep(partial: StepFixture): StepResult<any> {
|
|
16
|
+
return {
|
|
17
|
+
finishReason: "tool-calls",
|
|
18
|
+
content: [],
|
|
19
|
+
toolCalls: [],
|
|
20
|
+
toolResults: [],
|
|
21
|
+
...partial,
|
|
22
|
+
} as unknown as StepResult<any>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("hasSuccessfulToolCall", () => {
|
|
26
|
+
test("returns true when last step has a tool-result for the named tool", () => {
|
|
27
|
+
const step = makeStep({
|
|
28
|
+
content: [{ type: "tool-result", toolName: "search" }],
|
|
29
|
+
});
|
|
30
|
+
expect(hasSuccessfulToolCall("search")({ steps: [step] })).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns false when only a tool-error is present for the named tool", () => {
|
|
34
|
+
const step = makeStep({
|
|
35
|
+
content: [{ type: "tool-error", toolName: "search" }],
|
|
36
|
+
});
|
|
37
|
+
expect(hasSuccessfulToolCall("search")({ steps: [step] })).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns false when the matching tool name is missing", () => {
|
|
41
|
+
const step = makeStep({
|
|
42
|
+
content: [{ type: "tool-result", toolName: "other" }],
|
|
43
|
+
});
|
|
44
|
+
expect(hasSuccessfulToolCall("search")({ steps: [step] })).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("only inspects the last step", () => {
|
|
48
|
+
const earlier = makeStep({
|
|
49
|
+
content: [{ type: "tool-result", toolName: "search" }],
|
|
50
|
+
});
|
|
51
|
+
const last = makeStep({
|
|
52
|
+
content: [{ type: "tool-error", toolName: "search" }],
|
|
53
|
+
});
|
|
54
|
+
expect(hasSuccessfulToolCall("search")({ steps: [earlier, last] })).toBe(
|
|
55
|
+
false,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns false when steps is empty", () => {
|
|
60
|
+
expect(hasSuccessfulToolCall("search")({ steps: [] })).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("willContinue", () => {
|
|
65
|
+
test("does not stop when a tool-error fills in for a missing tool-result", async () => {
|
|
66
|
+
// Two tool calls; one returns a result, the other errors.
|
|
67
|
+
const step = makeStep({
|
|
68
|
+
toolCalls: [
|
|
69
|
+
{ toolCallId: "1", toolName: "a" },
|
|
70
|
+
{ toolCallId: "2", toolName: "b" },
|
|
71
|
+
],
|
|
72
|
+
toolResults: [{ toolCallId: "1", toolName: "a" }],
|
|
73
|
+
content: [
|
|
74
|
+
{ type: "tool-result", toolName: "a" },
|
|
75
|
+
{ type: "tool-error", toolName: "b" },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
// No stopWhen → returns false (no further stop conditions). The point
|
|
79
|
+
// is the function progresses past the early `toolCalls > completed`
|
|
80
|
+
// bail; pre-fix it returned early because tool-error wasn't counted.
|
|
81
|
+
expect(await willContinue([step], undefined)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("stops when a tool call has neither a result nor an error yet", async () => {
|
|
85
|
+
const step = makeStep({
|
|
86
|
+
toolCalls: [{ toolCallId: "1", toolName: "a" }],
|
|
87
|
+
toolResults: [],
|
|
88
|
+
content: [],
|
|
89
|
+
});
|
|
90
|
+
expect(await willContinue([step], undefined)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("stops when finishReason is not tool-calls", async () => {
|
|
94
|
+
const step = makeStep({ finishReason: "stop" });
|
|
95
|
+
expect(await willContinue([step], undefined)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import type { Context } from "@ai-sdk/provider-utils";
|
|
2
2
|
import type { StepResult, StopCondition, ToolSet } from "ai";
|
|
3
3
|
|
|
4
|
+
export { errorToString } from "../../errors.js";
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
|
-
* A stop condition that only matches tool calls which
|
|
6
|
-
* successfully
|
|
7
|
+
* A stop condition that only matches tool calls of the given name which
|
|
8
|
+
* completed successfully — i.e. produced a `tool-result` content part.
|
|
9
|
+
* Failed tool calls (which surface as `tool-error` parts under AI SDK v6)
|
|
10
|
+
* do not match.
|
|
7
11
|
*
|
|
8
|
-
* Use this instead of the AI SDK's `hasToolCall` when you want the
|
|
9
|
-
*
|
|
12
|
+
* Use this instead of the AI SDK's `hasToolCall` when you want the agent
|
|
13
|
+
* to retry on argument-validation or runtime tool failures rather than
|
|
14
|
+
* stopping. Evaluated only against the last step (consistent with how
|
|
15
|
+
* `stopWhen` is applied after each step).
|
|
10
16
|
*/
|
|
11
17
|
export function hasSuccessfulToolCall(toolName: string): StopCondition<any> {
|
|
12
18
|
return ({ steps }) =>
|
|
13
|
-
steps[steps.length - 1]?.
|
|
14
|
-
(
|
|
19
|
+
steps[steps.length - 1]?.content?.some(
|
|
20
|
+
(p) => p.type === "tool-result" && p.toolName === toolName,
|
|
15
21
|
) ?? false;
|
|
16
22
|
}
|
|
17
23
|
|
|
@@ -31,12 +37,13 @@ export async function willContinue<
|
|
|
31
37
|
if (step.finishReason !== "tool-calls") return false;
|
|
32
38
|
// Count both successful results and errors as completed outputs.
|
|
33
39
|
// Failed tool calls are represented as tool-error content parts, so only
|
|
34
|
-
// checking toolResults misses them.
|
|
40
|
+
// checking toolResults misses them. The fallback to step.toolResults.length
|
|
41
|
+
// is for callers whose steps lack content (mocks); the optional chain is
|
|
42
|
+
// defensive rather than load-bearing.
|
|
35
43
|
const completedOutputs =
|
|
36
44
|
step.content?.filter(
|
|
37
45
|
(p) => p.type === "tool-result" || p.type === "tool-error",
|
|
38
46
|
).length ?? step.toolResults.length;
|
|
39
|
-
// we don't have a tool result, so we'll wait for more
|
|
40
47
|
if (step.toolCalls.length > completedOutputs) return false;
|
|
41
48
|
if (Array.isArray(stopWhen)) {
|
|
42
49
|
return (await Promise.all(stopWhen.map(async (s) => s({ steps })))).every(
|
|
@@ -45,10 +52,3 @@ export async function willContinue<
|
|
|
45
52
|
}
|
|
46
53
|
return !!stopWhen && !(await stopWhen({ steps }));
|
|
47
54
|
}
|
|
48
|
-
|
|
49
|
-
export function errorToString(error: unknown): string {
|
|
50
|
-
if (error instanceof Error) {
|
|
51
|
-
return error.message;
|
|
52
|
-
}
|
|
53
|
-
return String(error);
|
|
54
|
-
}
|