@copilotkit/runtime 1.55.0-next.7 → 1.55.0-next.9
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/CHANGELOG.md +18 -0
- package/dist/agent/index.cjs +184 -173
- package/dist/agent/index.cjs.map +1 -1
- package/dist/agent/index.d.cts.map +1 -1
- package/dist/agent/index.d.mts.map +1 -1
- package/dist/agent/index.mjs +184 -173
- package/dist/agent/index.mjs.map +1 -1
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/package.json +2 -2
- package/src/agent/__tests__/basic-agent.test.ts +455 -5
- package/src/agent/__tests__/test-helpers.ts +27 -12
- package/src/agent/index.ts +44 -20
- package/src/v2/runtime/__tests__/middleware-express.test.ts +24 -22
- package/src/v2/runtime/__tests__/middleware-single-express.test.ts +24 -22
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { BasicAgent, defineTool, type ToolDefinition } from "../index";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
EventType,
|
|
6
|
+
type BaseEvent,
|
|
7
|
+
type ReasoningStartEvent,
|
|
8
|
+
type RunAgentInput,
|
|
9
|
+
} from "@ag-ui/client";
|
|
5
10
|
import { streamText } from "ai";
|
|
6
11
|
import {
|
|
7
12
|
mockStreamTextResponse,
|
|
8
13
|
textStart,
|
|
9
14
|
textDelta,
|
|
10
15
|
finish,
|
|
16
|
+
abort,
|
|
17
|
+
error,
|
|
11
18
|
collectEvents,
|
|
12
19
|
toolCallStreamingStart,
|
|
13
20
|
toolCallDelta,
|
|
@@ -1047,10 +1054,11 @@ describe("BasicAgent", () => {
|
|
|
1047
1054
|
|
|
1048
1055
|
const events = await collectEvents(agent["run"](input));
|
|
1049
1056
|
|
|
1050
|
-
|
|
1057
|
+
// Empty delta must NOT be emitted — EventSchemas rejects delta: ""
|
|
1058
|
+
const contentEvents = events.filter(
|
|
1051
1059
|
(e: any) => e.type === EventType.REASONING_MESSAGE_CONTENT,
|
|
1052
1060
|
);
|
|
1053
|
-
expect(
|
|
1061
|
+
expect(contentEvents).toHaveLength(0);
|
|
1054
1062
|
|
|
1055
1063
|
// Full lifecycle should still complete
|
|
1056
1064
|
const eventTypes = events.map((e: any) => e.type);
|
|
@@ -1072,7 +1080,7 @@ describe("BasicAgent", () => {
|
|
|
1072
1080
|
reasoningDelta("Deep thought"),
|
|
1073
1081
|
reasoningEnd(),
|
|
1074
1082
|
finish(),
|
|
1075
|
-
])
|
|
1083
|
+
]),
|
|
1076
1084
|
);
|
|
1077
1085
|
|
|
1078
1086
|
const input: RunAgentInput = {
|
|
@@ -1102,6 +1110,448 @@ describe("BasicAgent", () => {
|
|
|
1102
1110
|
});
|
|
1103
1111
|
});
|
|
1104
1112
|
|
|
1113
|
+
it("should skip empty reasoning deltas and continue stream", async () => {
|
|
1114
|
+
const agent = new BasicAgent({
|
|
1115
|
+
model: "openai/gpt-4o",
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
vi.mocked(streamText).mockReturnValue(
|
|
1119
|
+
mockStreamTextResponse([
|
|
1120
|
+
reasoningStart(),
|
|
1121
|
+
reasoningDelta(""),
|
|
1122
|
+
reasoningEnd(),
|
|
1123
|
+
finish(),
|
|
1124
|
+
]),
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
const input: RunAgentInput = {
|
|
1128
|
+
threadId: "thread1",
|
|
1129
|
+
runId: "run1",
|
|
1130
|
+
messages: [],
|
|
1131
|
+
tools: [],
|
|
1132
|
+
context: [],
|
|
1133
|
+
state: {},
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
const events = await collectEvents(agent["run"](input));
|
|
1137
|
+
|
|
1138
|
+
// No REASONING_MESSAGE_CONTENT events — empty delta skipped
|
|
1139
|
+
const contentEvents = events.filter(
|
|
1140
|
+
(e) => e.type === EventType.REASONING_MESSAGE_CONTENT,
|
|
1141
|
+
);
|
|
1142
|
+
expect(contentEvents).toHaveLength(0);
|
|
1143
|
+
|
|
1144
|
+
// Stream still completes with RUN_FINISHED
|
|
1145
|
+
const eventTypes = events.map((e) => e.type);
|
|
1146
|
+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it("should auto-close reasoning when SDK omits reasoning-end before tool call", async () => {
|
|
1150
|
+
const agent = new BasicAgent({
|
|
1151
|
+
model: "openai/gpt-4o",
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
vi.mocked(streamText).mockReturnValue(
|
|
1155
|
+
mockStreamTextResponse([
|
|
1156
|
+
reasoningStart(),
|
|
1157
|
+
reasoningDelta("Thinking..."),
|
|
1158
|
+
// NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour
|
|
1159
|
+
toolCallStreamingStart("call1", "testTool"),
|
|
1160
|
+
toolCallDelta("call1", '{"arg":"val"}'),
|
|
1161
|
+
toolCall("call1", "testTool", { arg: "val" }),
|
|
1162
|
+
toolResult("call1", "testTool", { result: "success" }),
|
|
1163
|
+
finish(),
|
|
1164
|
+
]),
|
|
1165
|
+
);
|
|
1166
|
+
|
|
1167
|
+
const input: RunAgentInput = {
|
|
1168
|
+
threadId: "thread1",
|
|
1169
|
+
runId: "run1",
|
|
1170
|
+
messages: [],
|
|
1171
|
+
tools: [],
|
|
1172
|
+
context: [],
|
|
1173
|
+
state: {},
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
const events = await collectEvents(agent["run"](input));
|
|
1177
|
+
const eventTypes = events.map((e) => e.type);
|
|
1178
|
+
|
|
1179
|
+
// REASONING_MESSAGE_END must appear before REASONING_END, which must appear before TOOL_CALL_START
|
|
1180
|
+
const reasoningMsgEndIdx = eventTypes.indexOf(
|
|
1181
|
+
EventType.REASONING_MESSAGE_END,
|
|
1182
|
+
);
|
|
1183
|
+
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
|
|
1184
|
+
const toolCallStartIdx = eventTypes.indexOf(EventType.TOOL_CALL_START);
|
|
1185
|
+
expect(reasoningMsgEndIdx).toBeGreaterThan(0);
|
|
1186
|
+
expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx);
|
|
1187
|
+
expect(reasoningEndIdx).toBeLessThan(toolCallStartIdx);
|
|
1188
|
+
|
|
1189
|
+
// Each close event must appear exactly once (guard against double-emit)
|
|
1190
|
+
expect(
|
|
1191
|
+
eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
|
1192
|
+
).toHaveLength(1);
|
|
1193
|
+
expect(
|
|
1194
|
+
eventTypes.filter((t) => t === EventType.REASONING_END),
|
|
1195
|
+
).toHaveLength(1);
|
|
1196
|
+
|
|
1197
|
+
// Stream still completes with RUN_FINISHED
|
|
1198
|
+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
it("should auto-close reasoning when SDK omits reasoning-end before text", async () => {
|
|
1202
|
+
const agent = new BasicAgent({
|
|
1203
|
+
model: "openai/gpt-4o",
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
vi.mocked(streamText).mockReturnValue(
|
|
1207
|
+
mockStreamTextResponse([
|
|
1208
|
+
reasoningStart(),
|
|
1209
|
+
reasoningDelta("Let me think"),
|
|
1210
|
+
// NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour
|
|
1211
|
+
textStart(),
|
|
1212
|
+
textDelta("Answer"),
|
|
1213
|
+
finish(),
|
|
1214
|
+
]),
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
const input: RunAgentInput = {
|
|
1218
|
+
threadId: "thread1",
|
|
1219
|
+
runId: "run1",
|
|
1220
|
+
messages: [],
|
|
1221
|
+
tools: [],
|
|
1222
|
+
context: [],
|
|
1223
|
+
state: {},
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
const events = await collectEvents(agent["run"](input));
|
|
1227
|
+
const eventTypes = events.map((e) => e.type);
|
|
1228
|
+
|
|
1229
|
+
// REASONING_MESSAGE_END must appear before REASONING_END, which must appear before TEXT_MESSAGE_CHUNK
|
|
1230
|
+
const reasoningMsgEndIdx = eventTypes.indexOf(
|
|
1231
|
+
EventType.REASONING_MESSAGE_END,
|
|
1232
|
+
);
|
|
1233
|
+
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
|
|
1234
|
+
const textChunkIdx = eventTypes.indexOf(EventType.TEXT_MESSAGE_CHUNK);
|
|
1235
|
+
expect(reasoningMsgEndIdx).toBeGreaterThan(0);
|
|
1236
|
+
expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx);
|
|
1237
|
+
expect(reasoningEndIdx).toBeLessThan(textChunkIdx);
|
|
1238
|
+
|
|
1239
|
+
// Each close event must appear exactly once (guard against double-emit)
|
|
1240
|
+
expect(
|
|
1241
|
+
eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
|
1242
|
+
).toHaveLength(1);
|
|
1243
|
+
expect(
|
|
1244
|
+
eventTypes.filter((t) => t === EventType.REASONING_END),
|
|
1245
|
+
).toHaveLength(1);
|
|
1246
|
+
|
|
1247
|
+
// Stream still completes with RUN_FINISHED
|
|
1248
|
+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
it("should auto-close reasoning when SDK omits reasoning-end before finish", async () => {
|
|
1252
|
+
const agent = new BasicAgent({
|
|
1253
|
+
model: "openai/gpt-4o",
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
vi.mocked(streamText).mockReturnValue(
|
|
1257
|
+
mockStreamTextResponse([
|
|
1258
|
+
reasoningStart(),
|
|
1259
|
+
reasoningDelta("Deep thought"),
|
|
1260
|
+
// NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour
|
|
1261
|
+
finish(),
|
|
1262
|
+
]),
|
|
1263
|
+
);
|
|
1264
|
+
|
|
1265
|
+
const input: RunAgentInput = {
|
|
1266
|
+
threadId: "thread1",
|
|
1267
|
+
runId: "run1",
|
|
1268
|
+
messages: [],
|
|
1269
|
+
tools: [],
|
|
1270
|
+
context: [],
|
|
1271
|
+
state: {},
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
const events = await collectEvents(agent["run"](input));
|
|
1275
|
+
const eventTypes = events.map((e) => e.type);
|
|
1276
|
+
|
|
1277
|
+
// REASONING_MESSAGE_END must appear before REASONING_END (auto-closed by finish case)
|
|
1278
|
+
const reasoningMsgEndIdx = eventTypes.indexOf(
|
|
1279
|
+
EventType.REASONING_MESSAGE_END,
|
|
1280
|
+
);
|
|
1281
|
+
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
|
|
1282
|
+
expect(reasoningMsgEndIdx).toBeGreaterThan(0);
|
|
1283
|
+
expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx);
|
|
1284
|
+
|
|
1285
|
+
// Each close event must appear exactly once (guard against double-emit)
|
|
1286
|
+
expect(
|
|
1287
|
+
eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
|
1288
|
+
).toHaveLength(1);
|
|
1289
|
+
expect(
|
|
1290
|
+
eventTypes.filter((t) => t === EventType.REASONING_END),
|
|
1291
|
+
).toHaveLength(1);
|
|
1292
|
+
|
|
1293
|
+
// Stream still completes with RUN_FINISHED
|
|
1294
|
+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it("should auto-close reasoning when stream aborts mid-reasoning", async () => {
|
|
1298
|
+
const agent = new BasicAgent({
|
|
1299
|
+
model: "openai/gpt-4o",
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
vi.mocked(streamText).mockReturnValue(
|
|
1303
|
+
mockStreamTextResponse([
|
|
1304
|
+
reasoningStart(),
|
|
1305
|
+
reasoningDelta("Thinking..."),
|
|
1306
|
+
// NO reasoningEnd() — stream aborts before SDK can close reasoning
|
|
1307
|
+
abort(),
|
|
1308
|
+
]),
|
|
1309
|
+
);
|
|
1310
|
+
|
|
1311
|
+
const input: RunAgentInput = {
|
|
1312
|
+
threadId: "thread1",
|
|
1313
|
+
runId: "run1",
|
|
1314
|
+
messages: [],
|
|
1315
|
+
tools: [],
|
|
1316
|
+
context: [],
|
|
1317
|
+
state: {},
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
const events = await collectEvents(agent["run"](input));
|
|
1321
|
+
const eventTypes = events.map((e) => e.type);
|
|
1322
|
+
|
|
1323
|
+
// REASONING_MESSAGE_END must appear before REASONING_END, both before RUN_FINISHED
|
|
1324
|
+
const reasoningMsgEndIdx = eventTypes.indexOf(
|
|
1325
|
+
EventType.REASONING_MESSAGE_END,
|
|
1326
|
+
);
|
|
1327
|
+
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
|
|
1328
|
+
const runFinishedIdx = eventTypes.indexOf(EventType.RUN_FINISHED);
|
|
1329
|
+
expect(reasoningMsgEndIdx).toBeGreaterThan(0);
|
|
1330
|
+
expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx);
|
|
1331
|
+
expect(runFinishedIdx).toBeGreaterThan(reasoningEndIdx);
|
|
1332
|
+
|
|
1333
|
+
// Each close event must appear exactly once (guard against double-emit)
|
|
1334
|
+
expect(
|
|
1335
|
+
eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
|
1336
|
+
).toHaveLength(1);
|
|
1337
|
+
expect(
|
|
1338
|
+
eventTypes.filter((t) => t === EventType.REASONING_END),
|
|
1339
|
+
).toHaveLength(1);
|
|
1340
|
+
|
|
1341
|
+
// Stream still completes with RUN_FINISHED
|
|
1342
|
+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it("should auto-close reasoning when stream errors mid-reasoning", async () => {
|
|
1346
|
+
const agent = new BasicAgent({
|
|
1347
|
+
model: "openai/gpt-4o",
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
vi.mocked(streamText).mockReturnValue(
|
|
1351
|
+
mockStreamTextResponse([
|
|
1352
|
+
reasoningStart(),
|
|
1353
|
+
reasoningDelta("Thinking..."),
|
|
1354
|
+
// NO reasoningEnd() — stream errors before SDK can close reasoning
|
|
1355
|
+
error("stream failed"),
|
|
1356
|
+
]),
|
|
1357
|
+
);
|
|
1358
|
+
|
|
1359
|
+
const input: RunAgentInput = {
|
|
1360
|
+
threadId: "thread1",
|
|
1361
|
+
runId: "run1",
|
|
1362
|
+
messages: [],
|
|
1363
|
+
tools: [],
|
|
1364
|
+
context: [],
|
|
1365
|
+
state: {},
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
// subscriber.error() causes collectEvents to reject, so collect manually
|
|
1369
|
+
const events: BaseEvent[] = [];
|
|
1370
|
+
await new Promise<void>((resolve) => {
|
|
1371
|
+
agent["run"](input).subscribe({
|
|
1372
|
+
next: (e) => events.push(e),
|
|
1373
|
+
error: () => resolve(), // error is expected
|
|
1374
|
+
complete: () => resolve(),
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
const eventTypes = events.map((e) => e.type);
|
|
1379
|
+
|
|
1380
|
+
// REASONING_MESSAGE_END must appear before REASONING_END, both before RUN_ERROR
|
|
1381
|
+
const reasoningMsgEndIdx = eventTypes.indexOf(
|
|
1382
|
+
EventType.REASONING_MESSAGE_END,
|
|
1383
|
+
);
|
|
1384
|
+
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
|
|
1385
|
+
const runErrorIdx = eventTypes.indexOf(EventType.RUN_ERROR);
|
|
1386
|
+
expect(reasoningMsgEndIdx).toBeGreaterThan(0);
|
|
1387
|
+
expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx);
|
|
1388
|
+
expect(runErrorIdx).toBeGreaterThan(reasoningEndIdx);
|
|
1389
|
+
|
|
1390
|
+
// Each close event must appear exactly once (guard against double-emit)
|
|
1391
|
+
expect(
|
|
1392
|
+
eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
|
1393
|
+
).toHaveLength(1);
|
|
1394
|
+
expect(
|
|
1395
|
+
eventTypes.filter((t) => t === EventType.REASONING_END),
|
|
1396
|
+
).toHaveLength(1);
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it("should auto-close reasoning for consecutive blocks with no reasoning-end between them", async () => {
|
|
1400
|
+
const agent = new BasicAgent({
|
|
1401
|
+
model: "openai/gpt-4o",
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
vi.mocked(streamText).mockReturnValue(
|
|
1405
|
+
mockStreamTextResponse([
|
|
1406
|
+
reasoningStart(),
|
|
1407
|
+
reasoningDelta("First thought"),
|
|
1408
|
+
// NO reasoningEnd() — second block starts immediately
|
|
1409
|
+
reasoningStart(),
|
|
1410
|
+
reasoningDelta("Second thought"),
|
|
1411
|
+
reasoningEnd(),
|
|
1412
|
+
finish(),
|
|
1413
|
+
]),
|
|
1414
|
+
);
|
|
1415
|
+
|
|
1416
|
+
const input: RunAgentInput = {
|
|
1417
|
+
threadId: "thread1",
|
|
1418
|
+
runId: "run1",
|
|
1419
|
+
messages: [],
|
|
1420
|
+
tools: [],
|
|
1421
|
+
context: [],
|
|
1422
|
+
state: {},
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
const events = await collectEvents(agent["run"](input));
|
|
1426
|
+
const eventTypes = events.map((e) => e.type);
|
|
1427
|
+
|
|
1428
|
+
// Both reasoning blocks must be properly closed — two complete lifecycles
|
|
1429
|
+
expect(
|
|
1430
|
+
eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
|
1431
|
+
).toHaveLength(2);
|
|
1432
|
+
expect(
|
|
1433
|
+
eventTypes.filter((t) => t === EventType.REASONING_END),
|
|
1434
|
+
).toHaveLength(2);
|
|
1435
|
+
|
|
1436
|
+
// First block's REASONING_END must appear before second block's REASONING_START
|
|
1437
|
+
const firstReasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
|
|
1438
|
+
const secondReasoningStartIdx = eventTypes.lastIndexOf(
|
|
1439
|
+
EventType.REASONING_START,
|
|
1440
|
+
);
|
|
1441
|
+
expect(firstReasoningEndIdx).toBeLessThan(secondReasoningStartIdx);
|
|
1442
|
+
|
|
1443
|
+
// The two blocks must use distinct messageIds
|
|
1444
|
+
const startEvents = events.filter(
|
|
1445
|
+
(e): e is ReasoningStartEvent => e.type === EventType.REASONING_START,
|
|
1446
|
+
);
|
|
1447
|
+
expect(startEvents).toHaveLength(2);
|
|
1448
|
+
expect(startEvents[0].messageId).not.toBe(startEvents[1].messageId);
|
|
1449
|
+
|
|
1450
|
+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
it("should close reasoning when an exception is thrown mid-stream", async () => {
|
|
1454
|
+
const agent = new BasicAgent({
|
|
1455
|
+
model: "openai/gpt-4o",
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
// Simulate the fullStream generator throwing mid-iteration (not a stream error event)
|
|
1459
|
+
const throwingStream = {
|
|
1460
|
+
fullStream: (async function* () {
|
|
1461
|
+
yield reasoningStart();
|
|
1462
|
+
yield reasoningDelta("Thinking...");
|
|
1463
|
+
throw new Error("unexpected network failure");
|
|
1464
|
+
})(),
|
|
1465
|
+
};
|
|
1466
|
+
vi.mocked(streamText).mockReturnValue(
|
|
1467
|
+
throwingStream as unknown as ReturnType<typeof streamText>,
|
|
1468
|
+
);
|
|
1469
|
+
|
|
1470
|
+
const input: RunAgentInput = {
|
|
1471
|
+
threadId: "thread1",
|
|
1472
|
+
runId: "run1",
|
|
1473
|
+
messages: [],
|
|
1474
|
+
tools: [],
|
|
1475
|
+
context: [],
|
|
1476
|
+
state: {},
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
// subscriber.error() causes collectEvents to reject, so collect manually
|
|
1480
|
+
const events: BaseEvent[] = [];
|
|
1481
|
+
await new Promise<void>((resolve) => {
|
|
1482
|
+
agent["run"](input).subscribe({
|
|
1483
|
+
next: (e) => events.push(e),
|
|
1484
|
+
error: () => resolve(), // error is expected
|
|
1485
|
+
complete: () => resolve(),
|
|
1486
|
+
});
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
const eventTypes = events.map((e) => e.type);
|
|
1490
|
+
|
|
1491
|
+
// Reasoning must be closed before RUN_ERROR despite the exception path
|
|
1492
|
+
const reasoningMsgEndIdx = eventTypes.indexOf(
|
|
1493
|
+
EventType.REASONING_MESSAGE_END,
|
|
1494
|
+
);
|
|
1495
|
+
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
|
|
1496
|
+
const runErrorIdx = eventTypes.indexOf(EventType.RUN_ERROR);
|
|
1497
|
+
expect(reasoningMsgEndIdx).toBeGreaterThan(0);
|
|
1498
|
+
expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx);
|
|
1499
|
+
expect(runErrorIdx).toBeGreaterThan(reasoningEndIdx);
|
|
1500
|
+
|
|
1501
|
+
expect(
|
|
1502
|
+
eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
|
1503
|
+
).toHaveLength(1);
|
|
1504
|
+
expect(
|
|
1505
|
+
eventTypes.filter((t) => t === EventType.REASONING_END),
|
|
1506
|
+
).toHaveLength(1);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it("should close reasoning and emit RUN_FINISHED when stream exhausts without terminal event", async () => {
|
|
1510
|
+
const agent = new BasicAgent({
|
|
1511
|
+
model: "openai/gpt-4o",
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
// Stream ends with no finish/abort/error — exercises !terminalEventEmitted fallback
|
|
1515
|
+
vi.mocked(streamText).mockReturnValue(
|
|
1516
|
+
mockStreamTextResponse([
|
|
1517
|
+
reasoningStart(),
|
|
1518
|
+
reasoningDelta("Thinking..."),
|
|
1519
|
+
// deliberate: no finish(), no abort(), no error()
|
|
1520
|
+
]),
|
|
1521
|
+
);
|
|
1522
|
+
|
|
1523
|
+
const input: RunAgentInput = {
|
|
1524
|
+
threadId: "thread1",
|
|
1525
|
+
runId: "run1",
|
|
1526
|
+
messages: [],
|
|
1527
|
+
tools: [],
|
|
1528
|
+
context: [],
|
|
1529
|
+
state: {},
|
|
1530
|
+
};
|
|
1531
|
+
|
|
1532
|
+
const events = await collectEvents(agent["run"](input));
|
|
1533
|
+
const eventTypes = events.map((e) => e.type);
|
|
1534
|
+
|
|
1535
|
+
// Reasoning must be closed before RUN_FINISHED via fallback
|
|
1536
|
+
const reasoningMsgEndIdx = eventTypes.indexOf(
|
|
1537
|
+
EventType.REASONING_MESSAGE_END,
|
|
1538
|
+
);
|
|
1539
|
+
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
|
|
1540
|
+
const runFinishedIdx = eventTypes.indexOf(EventType.RUN_FINISHED);
|
|
1541
|
+
expect(reasoningMsgEndIdx).toBeGreaterThan(0);
|
|
1542
|
+
expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx);
|
|
1543
|
+
expect(runFinishedIdx).toBeGreaterThan(reasoningEndIdx);
|
|
1544
|
+
|
|
1545
|
+
expect(
|
|
1546
|
+
eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END),
|
|
1547
|
+
).toHaveLength(1);
|
|
1548
|
+
expect(
|
|
1549
|
+
eventTypes.filter((t) => t === EventType.REASONING_END),
|
|
1550
|
+
).toHaveLength(1);
|
|
1551
|
+
|
|
1552
|
+
expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED);
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1105
1555
|
it("should handle reasoning interleaved with tool calls", async () => {
|
|
1106
1556
|
const agent = new BasicAgent({
|
|
1107
1557
|
model: "openai/gpt-4o",
|
|
@@ -1130,7 +1580,7 @@ describe("BasicAgent", () => {
|
|
|
1130
1580
|
};
|
|
1131
1581
|
|
|
1132
1582
|
const events = await collectEvents(agent["run"](input));
|
|
1133
|
-
const eventTypes = events.map((e
|
|
1583
|
+
const eventTypes = events.map((e) => e.type);
|
|
1134
1584
|
|
|
1135
1585
|
// Reasoning events precede tool call events
|
|
1136
1586
|
const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END);
|
|
@@ -2,22 +2,28 @@
|
|
|
2
2
|
* Test helpers for mocking streamText responses
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { streamText } from "ai";
|
|
6
|
+
import type { Observable } from "rxjs";
|
|
7
|
+
import type { BaseEvent } from "@ag-ui/client";
|
|
8
|
+
|
|
5
9
|
export interface MockStreamEvent {
|
|
6
10
|
type: string;
|
|
7
|
-
[key: string]:
|
|
11
|
+
[key: string]: unknown;
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
|
-
* Creates a mock streamText response with controlled events
|
|
15
|
+
* Creates a mock streamText response with controlled events.
|
|
12
16
|
*/
|
|
13
|
-
export function mockStreamTextResponse(
|
|
17
|
+
export function mockStreamTextResponse(
|
|
18
|
+
events: MockStreamEvent[],
|
|
19
|
+
): ReturnType<typeof streamText> {
|
|
14
20
|
return {
|
|
15
21
|
fullStream: (async function* () {
|
|
16
22
|
for (const event of events) {
|
|
17
23
|
yield event;
|
|
18
24
|
}
|
|
19
25
|
})(),
|
|
20
|
-
}
|
|
26
|
+
} as unknown as ReturnType<typeof streamText>;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/**
|
|
@@ -93,7 +99,7 @@ export function toolCall(
|
|
|
93
99
|
export function toolResult(
|
|
94
100
|
toolCallId: string,
|
|
95
101
|
toolName: string,
|
|
96
|
-
output:
|
|
102
|
+
output: unknown,
|
|
97
103
|
): MockStreamEvent {
|
|
98
104
|
return {
|
|
99
105
|
type: "tool-result",
|
|
@@ -145,6 +151,15 @@ export function finish(): MockStreamEvent {
|
|
|
145
151
|
};
|
|
146
152
|
}
|
|
147
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Helper to create an abort event
|
|
156
|
+
*/
|
|
157
|
+
export function abort(): MockStreamEvent {
|
|
158
|
+
return {
|
|
159
|
+
type: "abort",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
148
163
|
/**
|
|
149
164
|
* Helper to create an error event
|
|
150
165
|
*/
|
|
@@ -156,16 +171,16 @@ export function error(errorMessage: string): MockStreamEvent {
|
|
|
156
171
|
}
|
|
157
172
|
|
|
158
173
|
/**
|
|
159
|
-
* Collects all events from an Observable into an array
|
|
174
|
+
* Collects all events from an Observable<BaseEvent> into an array.
|
|
160
175
|
*/
|
|
161
|
-
export async function collectEvents
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
export async function collectEvents(
|
|
177
|
+
observable: Observable<BaseEvent>,
|
|
178
|
+
): Promise<BaseEvent[]> {
|
|
164
179
|
return new Promise((resolve, reject) => {
|
|
165
|
-
const events:
|
|
180
|
+
const events: BaseEvent[] = [];
|
|
166
181
|
const subscription = observable.subscribe({
|
|
167
|
-
next: (event
|
|
168
|
-
error: (err:
|
|
182
|
+
next: (event) => events.push(event),
|
|
183
|
+
error: (err: unknown) => reject(err),
|
|
169
184
|
complete: () => resolve(events),
|
|
170
185
|
});
|
|
171
186
|
|