@frockbot/kernel-agent-loop 0.0.0 → 0.1.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/package.json +26 -6
- package/src/agent.ts +172 -0
- package/src/index.test.ts +2891 -0
- package/src/index.ts +1219 -0
- package/tsconfig.json +13 -0
- package/README.md +0 -3
|
@@ -0,0 +1,2891 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeSessionEvent,
|
|
4
|
+
LlmEffectNotStartedError,
|
|
5
|
+
type LlmReconciliationOutcome,
|
|
6
|
+
type LlmProvider,
|
|
7
|
+
type LlmStreamEvent,
|
|
8
|
+
type PersistSessionEvents,
|
|
9
|
+
type SessionEvent,
|
|
10
|
+
SessionStore,
|
|
11
|
+
type ToolDefinition,
|
|
12
|
+
type ToolExecutionContext,
|
|
13
|
+
} from "@frockbot/kernel-contracts";
|
|
14
|
+
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
15
|
+
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
16
|
+
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
17
|
+
import { AgentRegistry, type AgentOptions } from "./agent.js";
|
|
18
|
+
import { Context, type Plugin } from "cordis";
|
|
19
|
+
import { AgentLoop } from "./index.js";
|
|
20
|
+
|
|
21
|
+
const roots: Context[] = [];
|
|
22
|
+
const allowEffect = () => Promise.resolve(true);
|
|
23
|
+
const allowEffectOptions = { admitEffect: allowEffect };
|
|
24
|
+
|
|
25
|
+
type RecoverableToolDefinition = ToolDefinition & {
|
|
26
|
+
reconcile(
|
|
27
|
+
input: unknown,
|
|
28
|
+
context: ToolExecutionContext & { effectId: string },
|
|
29
|
+
): Promise<
|
|
30
|
+
| { status: "recovered"; result: { content: string; isError: boolean } }
|
|
31
|
+
| { status: "unavailable"; reason: string }
|
|
32
|
+
>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function recovered(
|
|
36
|
+
...events: LlmStreamEvent[]
|
|
37
|
+
): Promise<LlmReconciliationOutcome> {
|
|
38
|
+
return Promise.resolve({
|
|
39
|
+
status: "recovered",
|
|
40
|
+
events,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const TEST_COMPOSITION = {
|
|
45
|
+
generationId: "1970-01-01T00:00:00.000Z:0123456789abcdef",
|
|
46
|
+
artifactSetHash: "a".repeat(64),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
async function mountRuntime(
|
|
50
|
+
provider: LlmProvider,
|
|
51
|
+
tool?: ToolDefinition | ToolDefinition[],
|
|
52
|
+
persistEvents?: PersistSessionEvents,
|
|
53
|
+
initialSessions?: Record<string, SessionEvent[]>,
|
|
54
|
+
): Promise<Context> {
|
|
55
|
+
const root = new Context();
|
|
56
|
+
roots.push(root);
|
|
57
|
+
await root.plugin(SessionStore, { persistEvents, initialSessions });
|
|
58
|
+
await root.plugin(SystemPromptRegistry);
|
|
59
|
+
await root.plugin(LlmRegistry);
|
|
60
|
+
await root.plugin(ToolRegistry);
|
|
61
|
+
await root.plugin(AgentRegistry);
|
|
62
|
+
|
|
63
|
+
const promptPlugin: Plugin.Function = (ctx) =>
|
|
64
|
+
ctx.systemPrompt.register({
|
|
65
|
+
id: "identity",
|
|
66
|
+
render: () => "You are the FrockBot test agent.",
|
|
67
|
+
});
|
|
68
|
+
promptPlugin.inject = ["systemPrompt"];
|
|
69
|
+
const providerPlugin: Plugin.Function = (ctx) => ctx.llm.register(provider);
|
|
70
|
+
providerPlugin.inject = ["llm"];
|
|
71
|
+
await root.plugin(promptPlugin);
|
|
72
|
+
await root.plugin(providerPlugin);
|
|
73
|
+
|
|
74
|
+
if (tool) {
|
|
75
|
+
const tools = Array.isArray(tool) ? tool : [tool];
|
|
76
|
+
const toolPlugin: Plugin.Function = (ctx) => {
|
|
77
|
+
for (const definition of tools) ctx.tools.register(definition);
|
|
78
|
+
};
|
|
79
|
+
toolPlugin.inject = ["tools"];
|
|
80
|
+
await root.plugin(toolPlugin);
|
|
81
|
+
}
|
|
82
|
+
await root.plugin(AgentLoop, {
|
|
83
|
+
maxSteps: 4,
|
|
84
|
+
composition: TEST_COMPOSITION,
|
|
85
|
+
});
|
|
86
|
+
return root;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function openToolSessionEvents(
|
|
90
|
+
provider: string,
|
|
91
|
+
toolName: string,
|
|
92
|
+
): SessionEvent[] {
|
|
93
|
+
const timestamp = "2026-08-30T00:00:00.000Z";
|
|
94
|
+
return [
|
|
95
|
+
{ type: "session/created", createdAt: timestamp },
|
|
96
|
+
{ type: "turn/start", turn: 1 },
|
|
97
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
98
|
+
{
|
|
99
|
+
type: "model/request",
|
|
100
|
+
turn: 1,
|
|
101
|
+
step: 1,
|
|
102
|
+
request: {
|
|
103
|
+
requestId: "tool-model-request",
|
|
104
|
+
provider,
|
|
105
|
+
model: "test-model",
|
|
106
|
+
system: "",
|
|
107
|
+
messages: [],
|
|
108
|
+
tools: [],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: "assistant/message",
|
|
113
|
+
turn: 1,
|
|
114
|
+
step: 1,
|
|
115
|
+
requestId: "tool-model-request",
|
|
116
|
+
text: "",
|
|
117
|
+
toolCalls: [{ id: "provider-call", name: toolName, input: {} }],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: "tool/call",
|
|
121
|
+
turn: 1,
|
|
122
|
+
step: 1,
|
|
123
|
+
occurrenceId: "tool:1:1:0",
|
|
124
|
+
name: toolName,
|
|
125
|
+
input: {},
|
|
126
|
+
},
|
|
127
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function eventually(
|
|
131
|
+
assertion: () => void,
|
|
132
|
+
timeoutMs = 1_000,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const deadline = Date.now() + timeoutMs;
|
|
135
|
+
let latestError: unknown;
|
|
136
|
+
while (Date.now() < deadline) {
|
|
137
|
+
try {
|
|
138
|
+
assertion();
|
|
139
|
+
return;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
latestError = error;
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw latestError;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
afterEach(async () => {
|
|
149
|
+
await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("AgentLoop", () => {
|
|
153
|
+
test("fences a model after durable intent without invoking its provider", async () => {
|
|
154
|
+
let streams = 0;
|
|
155
|
+
const provider: LlmProvider = {
|
|
156
|
+
id: "fenced-model",
|
|
157
|
+
async *stream() {
|
|
158
|
+
streams += 1;
|
|
159
|
+
yield { type: "finish", reason: "completed" };
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
const root = await mountRuntime(provider);
|
|
163
|
+
const admissions: Array<{ kind: "model" | "tool"; effectId: string }> = [];
|
|
164
|
+
let intentWasDurable = false;
|
|
165
|
+
const fenceOptions = {
|
|
166
|
+
admitEffect: (effect: { kind: "model" | "tool"; effectId: string }) => {
|
|
167
|
+
admissions.push(effect);
|
|
168
|
+
intentWasDurable =
|
|
169
|
+
root.sessions
|
|
170
|
+
.get("fenced-model")
|
|
171
|
+
?.events.some(
|
|
172
|
+
(event) =>
|
|
173
|
+
event.type === "model/request" &&
|
|
174
|
+
event.request.requestId === effect.effectId,
|
|
175
|
+
) ?? false;
|
|
176
|
+
return Promise.resolve(false);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const handle = await root.agents.create({
|
|
180
|
+
...fenceOptions,
|
|
181
|
+
botId: "bot-1",
|
|
182
|
+
sessionId: "fenced-model",
|
|
183
|
+
provider: "fenced-model",
|
|
184
|
+
model: "model-1",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
handle.agent.send("stop before dispatch");
|
|
188
|
+
await handle.agent.whenIdle();
|
|
189
|
+
|
|
190
|
+
const request = handle.agent.session.events.find(
|
|
191
|
+
(event) => event.type === "model/request",
|
|
192
|
+
);
|
|
193
|
+
if (request?.type !== "model/request") throw new Error("request missing");
|
|
194
|
+
expect(intentWasDurable).toBe(true);
|
|
195
|
+
expect(admissions).toEqual([
|
|
196
|
+
{ kind: "model", effectId: request.request.requestId },
|
|
197
|
+
]);
|
|
198
|
+
expect(streams).toBe(0);
|
|
199
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
200
|
+
expect.objectContaining({
|
|
201
|
+
type: "model/effect-not-started",
|
|
202
|
+
requestId: request.request.requestId,
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
206
|
+
type: "turn/end",
|
|
207
|
+
outcome: "cancelled",
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("fences a tool after durable intent without execution or another model", async () => {
|
|
212
|
+
let streams = 0;
|
|
213
|
+
let executions = 0;
|
|
214
|
+
const provider: LlmProvider = {
|
|
215
|
+
id: "fenced-tool",
|
|
216
|
+
async *stream() {
|
|
217
|
+
streams += 1;
|
|
218
|
+
yield {
|
|
219
|
+
type: "tool-call",
|
|
220
|
+
call: { id: "provider-call", name: "effect", input: {} },
|
|
221
|
+
};
|
|
222
|
+
yield { type: "finish", reason: "completed" };
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
const tool: ToolDefinition = {
|
|
226
|
+
name: "effect",
|
|
227
|
+
description: "Must be fenced.",
|
|
228
|
+
inputSchema: { type: "object" },
|
|
229
|
+
execute() {
|
|
230
|
+
executions += 1;
|
|
231
|
+
return Promise.resolve({ content: "executed", isError: false });
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
const root = await mountRuntime(provider, tool);
|
|
235
|
+
const admissions: Array<{ kind: "model" | "tool"; effectId: string }> = [];
|
|
236
|
+
let toolIntentWasDurable = false;
|
|
237
|
+
const fenceOptions = {
|
|
238
|
+
admitEffect: (effect: { kind: "model" | "tool"; effectId: string }) => {
|
|
239
|
+
admissions.push(effect);
|
|
240
|
+
if (effect.kind === "tool") {
|
|
241
|
+
toolIntentWasDurable =
|
|
242
|
+
root.sessions
|
|
243
|
+
.get("fenced-tool")
|
|
244
|
+
?.events.some(
|
|
245
|
+
(event) =>
|
|
246
|
+
event.type === "tool/call" &&
|
|
247
|
+
event.occurrenceId === effect.effectId,
|
|
248
|
+
) ?? false;
|
|
249
|
+
return Promise.resolve(false);
|
|
250
|
+
}
|
|
251
|
+
return Promise.resolve(true);
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
const handle = await root.agents.create({
|
|
255
|
+
...fenceOptions,
|
|
256
|
+
botId: "bot-1",
|
|
257
|
+
sessionId: "fenced-tool",
|
|
258
|
+
provider: "fenced-tool",
|
|
259
|
+
model: "model-1",
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
handle.agent.send("stop before the tool");
|
|
263
|
+
await handle.agent.whenIdle();
|
|
264
|
+
|
|
265
|
+
const request = handle.agent.session.events.find(
|
|
266
|
+
(event) => event.type === "model/request",
|
|
267
|
+
);
|
|
268
|
+
const call = handle.agent.session.events.find(
|
|
269
|
+
(event) => event.type === "tool/call",
|
|
270
|
+
);
|
|
271
|
+
if (request?.type !== "model/request") throw new Error("request missing");
|
|
272
|
+
if (call?.type !== "tool/call") throw new Error("tool call missing");
|
|
273
|
+
expect(toolIntentWasDurable).toBe(true);
|
|
274
|
+
expect(admissions).toEqual([
|
|
275
|
+
{ kind: "model", effectId: request.request.requestId },
|
|
276
|
+
{ kind: "tool", effectId: call.occurrenceId },
|
|
277
|
+
]);
|
|
278
|
+
expect(streams).toBe(1);
|
|
279
|
+
expect(executions).toBe(0);
|
|
280
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
281
|
+
expect.objectContaining({
|
|
282
|
+
type: "tool/result",
|
|
283
|
+
occurrenceId: call.occurrenceId,
|
|
284
|
+
status: "interrupted",
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
288
|
+
type: "turn/end",
|
|
289
|
+
outcome: "cancelled",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("announces model settlement only after the outcome is durable", async () => {
|
|
294
|
+
let durableEvents: readonly SessionEvent[] = [];
|
|
295
|
+
const committed: Array<{ requestId: string; durable: boolean }> = [];
|
|
296
|
+
const provider: LlmProvider = {
|
|
297
|
+
id: "settlement-order",
|
|
298
|
+
async *stream() {
|
|
299
|
+
yield { type: "text-delta", text: "done" };
|
|
300
|
+
yield { type: "finish", reason: "completed" };
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
const root = await mountRuntime(
|
|
304
|
+
provider,
|
|
305
|
+
undefined,
|
|
306
|
+
(_sessionId, events) => {
|
|
307
|
+
durableEvents = [...events];
|
|
308
|
+
return Promise.resolve();
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
root.on("agent/model-outcome-committed", async (_agent, requestId) => {
|
|
312
|
+
committed.push({
|
|
313
|
+
requestId,
|
|
314
|
+
durable: durableEvents.some(
|
|
315
|
+
(event) =>
|
|
316
|
+
event.type === "assistant/message" && event.requestId === requestId,
|
|
317
|
+
),
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
const handle = await root.agents.create({
|
|
321
|
+
botId: "bot-1",
|
|
322
|
+
sessionId: "session-1",
|
|
323
|
+
provider: provider.id,
|
|
324
|
+
model: "test-model",
|
|
325
|
+
admitEffect: allowEffect,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
handle.agent.send("Run once");
|
|
329
|
+
await handle.agent.whenIdle();
|
|
330
|
+
|
|
331
|
+
expect(committed).toHaveLength(1);
|
|
332
|
+
expect(committed[0]?.durable).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("pins the Composition generation at turn start", async () => {
|
|
336
|
+
const provider: LlmProvider = {
|
|
337
|
+
id: "pinned",
|
|
338
|
+
async *stream() {
|
|
339
|
+
yield { type: "text-delta", text: "done" };
|
|
340
|
+
yield { type: "finish", reason: "completed" };
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
const durableTypes: string[] = [];
|
|
344
|
+
const root = await mountRuntime(
|
|
345
|
+
provider,
|
|
346
|
+
undefined,
|
|
347
|
+
(_sessionId, events) => {
|
|
348
|
+
durableTypes.push(...events.map((event) => event.type));
|
|
349
|
+
return Promise.resolve();
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
const handle = await root.agents.create({
|
|
353
|
+
botId: "bot-pinned",
|
|
354
|
+
sessionId: "pinned-session",
|
|
355
|
+
provider: provider.id,
|
|
356
|
+
model: "test-model",
|
|
357
|
+
admitEffect: allowEffect,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
handle.agent.send("Run once");
|
|
361
|
+
await handle.agent.whenIdle();
|
|
362
|
+
handle.agent.send("Run again");
|
|
363
|
+
await handle.agent.whenIdle();
|
|
364
|
+
|
|
365
|
+
const pins = handle.agent.session.events.filter(
|
|
366
|
+
(event) => event.type === "composition/pinned",
|
|
367
|
+
);
|
|
368
|
+
expect(pins).toEqual([
|
|
369
|
+
expect.objectContaining({
|
|
370
|
+
type: "composition/pinned",
|
|
371
|
+
turn: 1,
|
|
372
|
+
generationId: TEST_COMPOSITION.generationId,
|
|
373
|
+
artifactSetHash: TEST_COMPOSITION.artifactSetHash,
|
|
374
|
+
}),
|
|
375
|
+
expect.objectContaining({ type: "composition/pinned", turn: 2 }),
|
|
376
|
+
]);
|
|
377
|
+
const types = handle.agent.session.events.map((event) => event.type);
|
|
378
|
+
expect(types.indexOf("composition/pinned")).toBe(
|
|
379
|
+
types.indexOf("turn/start") + 1,
|
|
380
|
+
);
|
|
381
|
+
expect(durableTypes).toContain("composition/pinned");
|
|
382
|
+
expect(() => decodeSessionEvent(structuredClone(pins[0]))).not.toThrow();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("records the admitted turn type and trims the catalog it requests", async () => {
|
|
386
|
+
const provider: LlmProvider = {
|
|
387
|
+
id: "admission-catalog",
|
|
388
|
+
async *stream() {
|
|
389
|
+
yield { type: "text-delta", text: "done" };
|
|
390
|
+
yield { type: "finish", reason: "completed" };
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
const work: ToolDefinition = {
|
|
394
|
+
name: "work",
|
|
395
|
+
description: "A work tool.",
|
|
396
|
+
inputSchema: { type: "object" },
|
|
397
|
+
execute: () => Promise.resolve({ content: "worked", isError: false }),
|
|
398
|
+
};
|
|
399
|
+
const chatOnly: ToolDefinition = {
|
|
400
|
+
name: "send_to_user",
|
|
401
|
+
description: "The voice to the User.",
|
|
402
|
+
inputSchema: { type: "object" },
|
|
403
|
+
admission: { turnTypes: ["chat"] },
|
|
404
|
+
execute: () => Promise.resolve({ content: "sent", isError: false }),
|
|
405
|
+
};
|
|
406
|
+
const root = await mountRuntime(provider, [work, chatOnly]);
|
|
407
|
+
const handle = await root.agents.create({
|
|
408
|
+
botId: "bot-1",
|
|
409
|
+
sessionId: "admission-catalog",
|
|
410
|
+
provider: provider.id,
|
|
411
|
+
model: "model-1",
|
|
412
|
+
turnType: "automation",
|
|
413
|
+
admitEffect: allowEffect,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
handle.agent.send("Run the automation");
|
|
417
|
+
await handle.agent.whenIdle();
|
|
418
|
+
|
|
419
|
+
const types = handle.agent.session.events.map((event) => event.type);
|
|
420
|
+
expect(types.indexOf("turn/admission")).toBe(
|
|
421
|
+
types.indexOf("composition/pinned") + 1,
|
|
422
|
+
);
|
|
423
|
+
const admission = handle.agent.session.events.find(
|
|
424
|
+
(event) => event.type === "turn/admission",
|
|
425
|
+
);
|
|
426
|
+
expect(admission).toMatchObject({
|
|
427
|
+
type: "turn/admission",
|
|
428
|
+
turn: 1,
|
|
429
|
+
turnType: "automation",
|
|
430
|
+
});
|
|
431
|
+
expect(() => decodeSessionEvent(structuredClone(admission))).not.toThrow();
|
|
432
|
+
|
|
433
|
+
// The recorded request *is* the trimmed catalog, so the Turn stays
|
|
434
|
+
// reconstructable from the log alone.
|
|
435
|
+
const request = handle.agent.session.events.find(
|
|
436
|
+
(event) => event.type === "model/request",
|
|
437
|
+
);
|
|
438
|
+
if (request?.type !== "model/request") throw new Error("request missing");
|
|
439
|
+
expect(request.request.tools.map((schema) => schema.name)).toEqual([
|
|
440
|
+
"work",
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("replays a Turn with no admission event as a chat turn", async () => {
|
|
445
|
+
const provider: LlmProvider = {
|
|
446
|
+
id: "admission-default",
|
|
447
|
+
async *stream() {
|
|
448
|
+
yield { type: "text-delta", text: "done" };
|
|
449
|
+
yield { type: "finish", reason: "completed" };
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
const chatOnly: ToolDefinition = {
|
|
453
|
+
name: "send_to_user",
|
|
454
|
+
description: "The voice to the User.",
|
|
455
|
+
inputSchema: { type: "object" },
|
|
456
|
+
admission: { turnTypes: ["chat"] },
|
|
457
|
+
execute: () => Promise.resolve({ content: "sent", isError: false }),
|
|
458
|
+
};
|
|
459
|
+
const timestamp = "2026-08-30T00:00:00.000Z";
|
|
460
|
+
const initial = [
|
|
461
|
+
{ type: "session/created", createdAt: timestamp },
|
|
462
|
+
{ type: "turn/start", turn: 1 },
|
|
463
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
464
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
465
|
+
const root = await mountRuntime(provider, chatOnly, undefined, {
|
|
466
|
+
"admission-default": initial,
|
|
467
|
+
});
|
|
468
|
+
const handle = await root.agents.create({
|
|
469
|
+
botId: "bot-1",
|
|
470
|
+
sessionId: "admission-default",
|
|
471
|
+
provider: provider.id,
|
|
472
|
+
model: "model-1",
|
|
473
|
+
admitEffect: allowEffect,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
handle.agent.resume();
|
|
477
|
+
await handle.agent.whenIdle();
|
|
478
|
+
|
|
479
|
+
const request = handle.agent.session.events.find(
|
|
480
|
+
(event) => event.type === "model/request",
|
|
481
|
+
);
|
|
482
|
+
if (request?.type !== "model/request") throw new Error("request missing");
|
|
483
|
+
expect(request.request.tools.map((schema) => schema.name)).toEqual([
|
|
484
|
+
"send_to_user",
|
|
485
|
+
]);
|
|
486
|
+
expect(
|
|
487
|
+
handle.agent.session.events.some(
|
|
488
|
+
(event) => event.type === "turn/admission",
|
|
489
|
+
),
|
|
490
|
+
).toBe(false);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("denies an out-of-admission call instead of executing it", async () => {
|
|
494
|
+
let executions = 0;
|
|
495
|
+
const provider: LlmProvider = {
|
|
496
|
+
id: "admission-denial",
|
|
497
|
+
async *stream() {
|
|
498
|
+
yield {
|
|
499
|
+
type: "tool-call",
|
|
500
|
+
call: { id: "provider-call", name: "send_to_user", input: {} },
|
|
501
|
+
};
|
|
502
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
const chatOnly: ToolDefinition = {
|
|
506
|
+
name: "send_to_user",
|
|
507
|
+
description: "The voice to the User.",
|
|
508
|
+
inputSchema: { type: "object" },
|
|
509
|
+
admission: { turnTypes: ["chat"] },
|
|
510
|
+
execute: () => {
|
|
511
|
+
executions += 1;
|
|
512
|
+
return Promise.resolve({ content: "sent", isError: false });
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
const root = await mountRuntime(provider, chatOnly);
|
|
516
|
+
const handle = await root.agents.create({
|
|
517
|
+
botId: "bot-1",
|
|
518
|
+
sessionId: "admission-denial",
|
|
519
|
+
provider: provider.id,
|
|
520
|
+
model: "model-1",
|
|
521
|
+
turnType: "automation",
|
|
522
|
+
admitEffect: allowEffect,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
handle.agent.send("Try the chat tool");
|
|
526
|
+
await handle.agent.whenIdle();
|
|
527
|
+
|
|
528
|
+
expect(executions).toBe(0);
|
|
529
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
530
|
+
expect.objectContaining({
|
|
531
|
+
type: "tool/result",
|
|
532
|
+
name: "send_to_user",
|
|
533
|
+
isError: true,
|
|
534
|
+
}),
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("ends the Turn on a result that declares it, with no further request", async () => {
|
|
539
|
+
let streams = 0;
|
|
540
|
+
const provider: LlmProvider = {
|
|
541
|
+
id: "ends-turn",
|
|
542
|
+
async *stream() {
|
|
543
|
+
streams += 1;
|
|
544
|
+
yield {
|
|
545
|
+
type: "tool-call",
|
|
546
|
+
call: { id: "provider-call", name: "hand_off", input: {} },
|
|
547
|
+
};
|
|
548
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
const handOff: ToolDefinition = {
|
|
552
|
+
name: "hand_off",
|
|
553
|
+
description: "Hands the Turn back.",
|
|
554
|
+
inputSchema: { type: "object" },
|
|
555
|
+
execute: () =>
|
|
556
|
+
Promise.resolve({
|
|
557
|
+
content: "handed off",
|
|
558
|
+
isError: false,
|
|
559
|
+
endsTurn: true,
|
|
560
|
+
}),
|
|
561
|
+
};
|
|
562
|
+
const root = await mountRuntime(provider, handOff);
|
|
563
|
+
const handle = await root.agents.create({
|
|
564
|
+
botId: "bot-1",
|
|
565
|
+
sessionId: "ends-turn",
|
|
566
|
+
provider: provider.id,
|
|
567
|
+
model: "model-1",
|
|
568
|
+
admitEffect: allowEffect,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
handle.agent.send("Hand off");
|
|
572
|
+
await handle.agent.whenIdle();
|
|
573
|
+
|
|
574
|
+
expect(streams).toBe(1);
|
|
575
|
+
expect(
|
|
576
|
+
handle.agent.session.events.filter(
|
|
577
|
+
(event) => event.type === "model/request",
|
|
578
|
+
),
|
|
579
|
+
).toHaveLength(1);
|
|
580
|
+
const types = handle.agent.session.events.map((event) => event.type);
|
|
581
|
+
expect(types.at(-1)).toBe("turn/end");
|
|
582
|
+
expect(types.at(-2)).toBe("step/end");
|
|
583
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
584
|
+
type: "turn/end",
|
|
585
|
+
outcome: "completed",
|
|
586
|
+
});
|
|
587
|
+
expect(handle.agent.session.events.at(-2)).toMatchObject({
|
|
588
|
+
type: "step/end",
|
|
589
|
+
step: 1,
|
|
590
|
+
outcome: "completed",
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("honours a turn-ending result on the resume path", async () => {
|
|
595
|
+
let streams = 0;
|
|
596
|
+
const provider: LlmProvider = {
|
|
597
|
+
id: "ends-turn-resume",
|
|
598
|
+
async *stream() {
|
|
599
|
+
streams += 1;
|
|
600
|
+
yield { type: "text-delta", text: "unreachable" };
|
|
601
|
+
yield { type: "finish", reason: "completed" };
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
const handOff: ToolDefinition = {
|
|
605
|
+
name: "hand_off",
|
|
606
|
+
description: "Hands the Turn back.",
|
|
607
|
+
inputSchema: { type: "object" },
|
|
608
|
+
execute: () =>
|
|
609
|
+
Promise.resolve({
|
|
610
|
+
content: "handed off",
|
|
611
|
+
isError: false,
|
|
612
|
+
endsTurn: true,
|
|
613
|
+
}),
|
|
614
|
+
};
|
|
615
|
+
const timestamp = "2026-08-30T00:00:00.000Z";
|
|
616
|
+
const initial = [
|
|
617
|
+
{ type: "session/created", createdAt: timestamp },
|
|
618
|
+
{ type: "turn/start", turn: 1 },
|
|
619
|
+
{ type: "turn/admission", turn: 1, turnType: "automation" },
|
|
620
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
621
|
+
{
|
|
622
|
+
type: "model/request",
|
|
623
|
+
turn: 1,
|
|
624
|
+
step: 1,
|
|
625
|
+
request: {
|
|
626
|
+
requestId: "hand-off-request",
|
|
627
|
+
provider: provider.id,
|
|
628
|
+
model: "model-1",
|
|
629
|
+
system: "",
|
|
630
|
+
messages: [],
|
|
631
|
+
tools: [],
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
type: "assistant/message",
|
|
636
|
+
turn: 1,
|
|
637
|
+
step: 1,
|
|
638
|
+
requestId: "hand-off-request",
|
|
639
|
+
text: "",
|
|
640
|
+
toolCalls: [{ id: "provider-call", name: "hand_off", input: {} }],
|
|
641
|
+
},
|
|
642
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
643
|
+
const root = await mountRuntime(provider, handOff, undefined, {
|
|
644
|
+
"ends-turn-resume": initial,
|
|
645
|
+
});
|
|
646
|
+
const handle = await root.agents.create({
|
|
647
|
+
botId: "bot-1",
|
|
648
|
+
sessionId: "ends-turn-resume",
|
|
649
|
+
provider: provider.id,
|
|
650
|
+
model: "model-1",
|
|
651
|
+
admitEffect: allowEffect,
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
handle.agent.resume();
|
|
655
|
+
await handle.agent.whenIdle();
|
|
656
|
+
|
|
657
|
+
expect(streams).toBe(0);
|
|
658
|
+
expect(
|
|
659
|
+
handle.agent.session.events.filter(
|
|
660
|
+
(event) => event.type === "model/request",
|
|
661
|
+
),
|
|
662
|
+
).toHaveLength(1);
|
|
663
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
664
|
+
type: "turn/end",
|
|
665
|
+
outcome: "completed",
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("keeps a durable settlement failure resumable", async () => {
|
|
670
|
+
let attempts = 0;
|
|
671
|
+
const provider: LlmProvider = {
|
|
672
|
+
id: "settlement-retry",
|
|
673
|
+
async *stream() {
|
|
674
|
+
yield { type: "text-delta", text: "done" };
|
|
675
|
+
yield { type: "finish", reason: "completed" };
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
const root = await mountRuntime(provider);
|
|
679
|
+
root.on("agent/model-outcome-committed", async () => {
|
|
680
|
+
attempts += 1;
|
|
681
|
+
if (attempts === 1) throw new Error("settlement unavailable");
|
|
682
|
+
});
|
|
683
|
+
const handle = await root.agents.create({
|
|
684
|
+
botId: "bot-1",
|
|
685
|
+
sessionId: "settlement-retry",
|
|
686
|
+
provider: provider.id,
|
|
687
|
+
model: "model-1",
|
|
688
|
+
admitEffect: allowEffect,
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
handle.agent.send("Run once");
|
|
692
|
+
await handle.agent.whenIdle();
|
|
693
|
+
|
|
694
|
+
expect(attempts).toBe(1);
|
|
695
|
+
expect(
|
|
696
|
+
handle.agent.session.events.some((event) => event.type === "turn/end"),
|
|
697
|
+
).toBe(false);
|
|
698
|
+
|
|
699
|
+
handle.agent.resume();
|
|
700
|
+
await handle.agent.whenIdle();
|
|
701
|
+
|
|
702
|
+
expect(attempts).toBe(2);
|
|
703
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
704
|
+
type: "turn/end",
|
|
705
|
+
outcome: "completed",
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("reannounces a durable assistant outcome during recovery", async () => {
|
|
710
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
711
|
+
const initial = [
|
|
712
|
+
{ type: "session/created", createdAt: timestamp },
|
|
713
|
+
{ type: "turn/start", turn: 1 },
|
|
714
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
715
|
+
{
|
|
716
|
+
type: "model/request",
|
|
717
|
+
turn: 1,
|
|
718
|
+
step: 1,
|
|
719
|
+
request: {
|
|
720
|
+
requestId: "durable-assistant-request",
|
|
721
|
+
provider: "recovered-provider",
|
|
722
|
+
model: "model-1",
|
|
723
|
+
system: "",
|
|
724
|
+
messages: [],
|
|
725
|
+
tools: [],
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
type: "assistant/message",
|
|
730
|
+
turn: 1,
|
|
731
|
+
step: 1,
|
|
732
|
+
requestId: "durable-assistant-request",
|
|
733
|
+
text: "Durable response",
|
|
734
|
+
toolCalls: [],
|
|
735
|
+
},
|
|
736
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
737
|
+
const committed: string[] = [];
|
|
738
|
+
const provider: LlmProvider = {
|
|
739
|
+
id: "recovered-provider",
|
|
740
|
+
async *stream() {
|
|
741
|
+
throw new Error("stream must not run");
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
745
|
+
"durable-assistant": initial,
|
|
746
|
+
});
|
|
747
|
+
root.on("agent/model-outcome-committed", async (_agent, requestId) => {
|
|
748
|
+
committed.push(requestId);
|
|
749
|
+
});
|
|
750
|
+
const handle = await root.agents.create({
|
|
751
|
+
botId: "bot-1",
|
|
752
|
+
sessionId: "durable-assistant",
|
|
753
|
+
provider: provider.id,
|
|
754
|
+
model: "model-1",
|
|
755
|
+
admitEffect: allowEffect,
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
handle.agent.resume();
|
|
759
|
+
await handle.agent.whenIdle();
|
|
760
|
+
|
|
761
|
+
expect(committed).toEqual(["durable-assistant-request"]);
|
|
762
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
763
|
+
type: "turn/end",
|
|
764
|
+
outcome: "completed",
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("reconciles an admitted model request by its durable id", async () => {
|
|
769
|
+
let streams = 0;
|
|
770
|
+
const reconciled: string[] = [];
|
|
771
|
+
const provider: LlmProvider = {
|
|
772
|
+
id: "recoverable",
|
|
773
|
+
async *stream() {
|
|
774
|
+
streams += 1;
|
|
775
|
+
yield { type: "finish", reason: "completed" };
|
|
776
|
+
},
|
|
777
|
+
reconciliation: {
|
|
778
|
+
retrieve(effect) {
|
|
779
|
+
reconciled.push(effect.providerEffectId);
|
|
780
|
+
return recovered(
|
|
781
|
+
{ type: "text-delta", text: "Recovered response" },
|
|
782
|
+
{ type: "finish", reason: "completed" },
|
|
783
|
+
);
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
const initial = [
|
|
788
|
+
{
|
|
789
|
+
type: "session/created" as const,
|
|
790
|
+
createdAt: "2026-08-28T00:00:00.000Z",
|
|
791
|
+
seq: 0,
|
|
792
|
+
timestamp: "2026-08-28T00:00:00.000Z",
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
type: "turn/start" as const,
|
|
796
|
+
turn: 1,
|
|
797
|
+
seq: 1,
|
|
798
|
+
timestamp: "2026-08-28T00:00:01.000Z",
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
type: "step/start" as const,
|
|
802
|
+
turn: 1,
|
|
803
|
+
step: 1,
|
|
804
|
+
seq: 2,
|
|
805
|
+
timestamp: "2026-08-28T00:00:01.000Z",
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
type: "model/request" as const,
|
|
809
|
+
turn: 1,
|
|
810
|
+
step: 1,
|
|
811
|
+
request: {
|
|
812
|
+
requestId: "durable-request-1",
|
|
813
|
+
provider: "recoverable",
|
|
814
|
+
model: "model-1",
|
|
815
|
+
system: "",
|
|
816
|
+
messages: [],
|
|
817
|
+
tools: [],
|
|
818
|
+
},
|
|
819
|
+
seq: 3,
|
|
820
|
+
timestamp: "2026-08-28T00:00:01.000Z",
|
|
821
|
+
},
|
|
822
|
+
] satisfies SessionEvent[];
|
|
823
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
824
|
+
recovering: initial,
|
|
825
|
+
});
|
|
826
|
+
const handle = await root.agents.create({
|
|
827
|
+
...allowEffectOptions,
|
|
828
|
+
botId: "bot-1",
|
|
829
|
+
sessionId: "recovering",
|
|
830
|
+
provider: "recoverable",
|
|
831
|
+
model: "model-1",
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
handle.agent.resume();
|
|
835
|
+
await handle.agent.whenIdle();
|
|
836
|
+
|
|
837
|
+
expect(streams).toBe(0);
|
|
838
|
+
expect(reconciled).toEqual(["durable-request-1"]);
|
|
839
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
840
|
+
expect.objectContaining({
|
|
841
|
+
type: "assistant/message",
|
|
842
|
+
requestId: "durable-request-1",
|
|
843
|
+
text: "Recovered response",
|
|
844
|
+
}),
|
|
845
|
+
);
|
|
846
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
847
|
+
type: "turn/end",
|
|
848
|
+
outcome: "completed",
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("reconciles a mixed stream and journals only its unseen text suffix", async () => {
|
|
853
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
854
|
+
const initial = [
|
|
855
|
+
{ type: "session/created", createdAt: timestamp },
|
|
856
|
+
{ type: "turn/start", turn: 1 },
|
|
857
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
858
|
+
{
|
|
859
|
+
type: "model/request",
|
|
860
|
+
turn: 1,
|
|
861
|
+
step: 1,
|
|
862
|
+
request: {
|
|
863
|
+
requestId: "partial-request",
|
|
864
|
+
provider: "partial-provider",
|
|
865
|
+
model: "model-1",
|
|
866
|
+
system: "",
|
|
867
|
+
messages: [],
|
|
868
|
+
tools: [],
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
type: "assistant/chunk",
|
|
873
|
+
turn: 1,
|
|
874
|
+
step: 1,
|
|
875
|
+
requestId: "partial-request",
|
|
876
|
+
text: "A",
|
|
877
|
+
},
|
|
878
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
879
|
+
let streamedFollowUp = 0;
|
|
880
|
+
let toolExecutions = 0;
|
|
881
|
+
const provider: LlmProvider = {
|
|
882
|
+
id: "partial-provider",
|
|
883
|
+
async *stream() {
|
|
884
|
+
streamedFollowUp += 1;
|
|
885
|
+
yield { type: "text-delta", text: "Finished." };
|
|
886
|
+
yield { type: "finish", reason: "completed" };
|
|
887
|
+
},
|
|
888
|
+
reconciliation: {
|
|
889
|
+
retrieve: () =>
|
|
890
|
+
recovered(
|
|
891
|
+
{ type: "text-delta", text: "A" },
|
|
892
|
+
{
|
|
893
|
+
type: "tool-call",
|
|
894
|
+
call: {
|
|
895
|
+
id: "recovered-call",
|
|
896
|
+
name: "echo",
|
|
897
|
+
input: { value: "mixed" },
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
{ type: "text-delta", text: "B" },
|
|
901
|
+
{ type: "finish", reason: "tool-calls" },
|
|
902
|
+
),
|
|
903
|
+
},
|
|
904
|
+
};
|
|
905
|
+
const tool: ToolDefinition = {
|
|
906
|
+
name: "echo",
|
|
907
|
+
description: "Return a supplied value.",
|
|
908
|
+
inputSchema: {
|
|
909
|
+
type: "object",
|
|
910
|
+
properties: { value: { type: "string" } },
|
|
911
|
+
required: ["value"],
|
|
912
|
+
},
|
|
913
|
+
validate: (input) =>
|
|
914
|
+
typeof input === "object" &&
|
|
915
|
+
input !== null &&
|
|
916
|
+
typeof (input as { value?: unknown }).value === "string",
|
|
917
|
+
execute(input) {
|
|
918
|
+
toolExecutions += 1;
|
|
919
|
+
return Promise.resolve({
|
|
920
|
+
content: (input as { value: string }).value,
|
|
921
|
+
isError: false,
|
|
922
|
+
});
|
|
923
|
+
},
|
|
924
|
+
};
|
|
925
|
+
const root = await mountRuntime(provider, tool, undefined, {
|
|
926
|
+
partial: initial,
|
|
927
|
+
});
|
|
928
|
+
const handle = await root.agents.create({
|
|
929
|
+
...allowEffectOptions,
|
|
930
|
+
botId: "partial-bot",
|
|
931
|
+
sessionId: "partial",
|
|
932
|
+
provider: "partial-provider",
|
|
933
|
+
model: "model-1",
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
handle.agent.resume();
|
|
937
|
+
await handle.agent.whenIdle();
|
|
938
|
+
|
|
939
|
+
expect(
|
|
940
|
+
handle.agent.session.events.flatMap((event) =>
|
|
941
|
+
event.type === "assistant/chunk" &&
|
|
942
|
+
event.requestId === "partial-request"
|
|
943
|
+
? [event.text]
|
|
944
|
+
: [],
|
|
945
|
+
),
|
|
946
|
+
).toEqual(["A", "B"]);
|
|
947
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
948
|
+
expect.objectContaining({
|
|
949
|
+
type: "assistant/message",
|
|
950
|
+
requestId: "partial-request",
|
|
951
|
+
text: "AB",
|
|
952
|
+
toolCalls: [
|
|
953
|
+
{
|
|
954
|
+
id: "recovered-call",
|
|
955
|
+
name: "echo",
|
|
956
|
+
input: { value: "mixed" },
|
|
957
|
+
},
|
|
958
|
+
],
|
|
959
|
+
}),
|
|
960
|
+
);
|
|
961
|
+
expect(toolExecutions).toBe(1);
|
|
962
|
+
expect(streamedFollowUp).toBe(1);
|
|
963
|
+
expect(
|
|
964
|
+
handle.agent.session.events.filter(
|
|
965
|
+
(event) =>
|
|
966
|
+
event.type === "tool/call" &&
|
|
967
|
+
event.occurrenceId === "tool:1:1:0" &&
|
|
968
|
+
event.name === "echo",
|
|
969
|
+
),
|
|
970
|
+
).toHaveLength(1);
|
|
971
|
+
expect(
|
|
972
|
+
handle.agent.session.events.filter(
|
|
973
|
+
(event) =>
|
|
974
|
+
event.type === "tool/result" &&
|
|
975
|
+
event.occurrenceId === "tool:1:1:0" &&
|
|
976
|
+
event.content === "mixed",
|
|
977
|
+
),
|
|
978
|
+
).toHaveLength(1);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("fails closed when retrieval diverges from a durable partial stream", async () => {
|
|
982
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
983
|
+
const initial = [
|
|
984
|
+
{ type: "session/created", createdAt: timestamp },
|
|
985
|
+
{ type: "turn/start", turn: 1 },
|
|
986
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
987
|
+
{
|
|
988
|
+
type: "model/request",
|
|
989
|
+
turn: 1,
|
|
990
|
+
step: 1,
|
|
991
|
+
request: {
|
|
992
|
+
requestId: "divergent-request",
|
|
993
|
+
provider: "divergent-provider",
|
|
994
|
+
model: "model-1",
|
|
995
|
+
system: "",
|
|
996
|
+
messages: [],
|
|
997
|
+
tools: [],
|
|
998
|
+
},
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
type: "assistant/chunk",
|
|
1002
|
+
turn: 1,
|
|
1003
|
+
step: 1,
|
|
1004
|
+
requestId: "divergent-request",
|
|
1005
|
+
text: "A",
|
|
1006
|
+
},
|
|
1007
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
1008
|
+
const provider: LlmProvider = {
|
|
1009
|
+
id: "divergent-provider",
|
|
1010
|
+
async *stream() {
|
|
1011
|
+
throw new Error("recovery must not dispatch another request");
|
|
1012
|
+
},
|
|
1013
|
+
reconciliation: {
|
|
1014
|
+
retrieve: () =>
|
|
1015
|
+
recovered(
|
|
1016
|
+
{ type: "text-delta", text: "X" },
|
|
1017
|
+
{ type: "text-delta", text: "B" },
|
|
1018
|
+
{ type: "finish", reason: "completed" },
|
|
1019
|
+
),
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
1023
|
+
divergent: initial,
|
|
1024
|
+
});
|
|
1025
|
+
const handle = await root.agents.create({
|
|
1026
|
+
...allowEffectOptions,
|
|
1027
|
+
botId: "divergent-bot",
|
|
1028
|
+
sessionId: "divergent",
|
|
1029
|
+
provider: "divergent-provider",
|
|
1030
|
+
model: "model-1",
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
handle.agent.resume();
|
|
1034
|
+
await handle.agent.whenIdle();
|
|
1035
|
+
|
|
1036
|
+
expect(
|
|
1037
|
+
handle.agent.session.events.flatMap((event) =>
|
|
1038
|
+
event.type === "assistant/chunk" ? [event.text] : [],
|
|
1039
|
+
),
|
|
1040
|
+
).toEqual(["A"]);
|
|
1041
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
1042
|
+
expect.objectContaining({
|
|
1043
|
+
type: "model/reconciliation-required",
|
|
1044
|
+
requestId: "divergent-request",
|
|
1045
|
+
reason:
|
|
1046
|
+
'Provider-bound retrieval diverged from durable response prefix for request "divergent-request"',
|
|
1047
|
+
}),
|
|
1048
|
+
);
|
|
1049
|
+
expect(
|
|
1050
|
+
handle.agent.session.events.some(
|
|
1051
|
+
(event) =>
|
|
1052
|
+
event.type === "assistant/message" || event.type === "turn/end",
|
|
1053
|
+
),
|
|
1054
|
+
).toBe(false);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test("fails closed when a mixed recovered stream continues after finish", async () => {
|
|
1058
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
1059
|
+
const initial = [
|
|
1060
|
+
{ type: "session/created", createdAt: timestamp },
|
|
1061
|
+
{ type: "turn/start", turn: 1 },
|
|
1062
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
1063
|
+
{
|
|
1064
|
+
type: "model/request",
|
|
1065
|
+
turn: 1,
|
|
1066
|
+
step: 1,
|
|
1067
|
+
request: {
|
|
1068
|
+
requestId: "structural-request",
|
|
1069
|
+
provider: "structural-provider",
|
|
1070
|
+
model: "model-1",
|
|
1071
|
+
system: "",
|
|
1072
|
+
messages: [],
|
|
1073
|
+
tools: [],
|
|
1074
|
+
},
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
type: "assistant/chunk",
|
|
1078
|
+
turn: 1,
|
|
1079
|
+
step: 1,
|
|
1080
|
+
requestId: "structural-request",
|
|
1081
|
+
text: "A",
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
type: "assistant/chunk",
|
|
1085
|
+
turn: 1,
|
|
1086
|
+
step: 1,
|
|
1087
|
+
requestId: "structural-request",
|
|
1088
|
+
text: "B",
|
|
1089
|
+
},
|
|
1090
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
1091
|
+
const provider: LlmProvider = {
|
|
1092
|
+
id: "structural-provider",
|
|
1093
|
+
async *stream() {
|
|
1094
|
+
throw new Error("recovery must not dispatch another request");
|
|
1095
|
+
},
|
|
1096
|
+
reconciliation: {
|
|
1097
|
+
retrieve: () =>
|
|
1098
|
+
recovered(
|
|
1099
|
+
{ type: "text-delta", text: "A" },
|
|
1100
|
+
{
|
|
1101
|
+
type: "tool-call",
|
|
1102
|
+
call: {
|
|
1103
|
+
id: "invalid-call",
|
|
1104
|
+
name: "echo",
|
|
1105
|
+
input: { value: "mixed" },
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
{ type: "finish", reason: "tool-calls" },
|
|
1109
|
+
{ type: "text-delta", text: "B" },
|
|
1110
|
+
),
|
|
1111
|
+
},
|
|
1112
|
+
};
|
|
1113
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
1114
|
+
structural: initial,
|
|
1115
|
+
});
|
|
1116
|
+
const handle = await root.agents.create({
|
|
1117
|
+
...allowEffectOptions,
|
|
1118
|
+
botId: "structural-bot",
|
|
1119
|
+
sessionId: "structural",
|
|
1120
|
+
provider: "structural-provider",
|
|
1121
|
+
model: "model-1",
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
handle.agent.resume();
|
|
1125
|
+
await handle.agent.whenIdle();
|
|
1126
|
+
|
|
1127
|
+
expect(
|
|
1128
|
+
handle.agent.session.events.flatMap((event) =>
|
|
1129
|
+
event.type === "assistant/chunk" ? [event.text] : [],
|
|
1130
|
+
),
|
|
1131
|
+
).toEqual(["A", "B"]);
|
|
1132
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
1133
|
+
expect.objectContaining({
|
|
1134
|
+
type: "model/reconciliation-required",
|
|
1135
|
+
requestId: "structural-request",
|
|
1136
|
+
reason:
|
|
1137
|
+
'Provider-bound retrieval returned an invalid event structure for request "structural-request"',
|
|
1138
|
+
}),
|
|
1139
|
+
);
|
|
1140
|
+
expect(
|
|
1141
|
+
handle.agent.session.events.some(
|
|
1142
|
+
(event) =>
|
|
1143
|
+
event.type === "assistant/message" || event.type === "tool/call",
|
|
1144
|
+
),
|
|
1145
|
+
).toBe(false);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test("keeps an unretrievable provider effect open without repeating it", async () => {
|
|
1149
|
+
let streams = 0;
|
|
1150
|
+
const provider: LlmProvider = {
|
|
1151
|
+
id: "unretrievable",
|
|
1152
|
+
async *stream() {
|
|
1153
|
+
streams += 1;
|
|
1154
|
+
yield { type: "finish", reason: "completed" };
|
|
1155
|
+
},
|
|
1156
|
+
};
|
|
1157
|
+
const initial = [
|
|
1158
|
+
{
|
|
1159
|
+
type: "session/created" as const,
|
|
1160
|
+
createdAt: "2026-08-28T00:00:00.000Z",
|
|
1161
|
+
seq: 0,
|
|
1162
|
+
timestamp: "2026-08-28T00:00:00.000Z",
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
type: "turn/start" as const,
|
|
1166
|
+
turn: 1,
|
|
1167
|
+
seq: 1,
|
|
1168
|
+
timestamp: "2026-08-28T00:00:01.000Z",
|
|
1169
|
+
},
|
|
1170
|
+
{
|
|
1171
|
+
type: "step/start" as const,
|
|
1172
|
+
turn: 1,
|
|
1173
|
+
step: 1,
|
|
1174
|
+
seq: 2,
|
|
1175
|
+
timestamp: "2026-08-28T00:00:01.000Z",
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
type: "model/request" as const,
|
|
1179
|
+
turn: 1,
|
|
1180
|
+
step: 1,
|
|
1181
|
+
request: {
|
|
1182
|
+
requestId: "unretrievable-effect-1",
|
|
1183
|
+
provider: "unretrievable",
|
|
1184
|
+
model: "model-1",
|
|
1185
|
+
system: "",
|
|
1186
|
+
messages: [],
|
|
1187
|
+
tools: [],
|
|
1188
|
+
},
|
|
1189
|
+
seq: 3,
|
|
1190
|
+
timestamp: "2026-08-28T00:00:01.000Z",
|
|
1191
|
+
},
|
|
1192
|
+
] satisfies SessionEvent[];
|
|
1193
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
1194
|
+
unretrievable: initial,
|
|
1195
|
+
});
|
|
1196
|
+
const handle = await root.agents.create({
|
|
1197
|
+
...allowEffectOptions,
|
|
1198
|
+
botId: "bot-1",
|
|
1199
|
+
sessionId: "unretrievable",
|
|
1200
|
+
provider: "unretrievable",
|
|
1201
|
+
model: "model-1",
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
handle.agent.resume();
|
|
1205
|
+
await handle.agent.whenIdle();
|
|
1206
|
+
handle.agent.resume();
|
|
1207
|
+
await handle.agent.whenIdle();
|
|
1208
|
+
|
|
1209
|
+
expect(streams).toBe(0);
|
|
1210
|
+
expect(
|
|
1211
|
+
handle.agent.session.events.filter(
|
|
1212
|
+
(event) => event.type === "model/reconciliation-required",
|
|
1213
|
+
),
|
|
1214
|
+
).toEqual([
|
|
1215
|
+
expect.objectContaining({
|
|
1216
|
+
requestId: "unretrievable-effect-1",
|
|
1217
|
+
reason:
|
|
1218
|
+
'LLM provider "unretrievable" does not support provider-bound retrieval',
|
|
1219
|
+
}),
|
|
1220
|
+
]);
|
|
1221
|
+
expect(
|
|
1222
|
+
handle.agent.session.events.some(
|
|
1223
|
+
(event) => event.type === "step/end" || event.type === "turn/end",
|
|
1224
|
+
),
|
|
1225
|
+
).toBe(false);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
test("keeps an ambiguous dispatched effect open for provider reconciliation", async () => {
|
|
1229
|
+
let streams = 0;
|
|
1230
|
+
const retrieved: string[] = [];
|
|
1231
|
+
const durableEventTypes: string[] = [];
|
|
1232
|
+
let dispatchSawDurableIntent = false;
|
|
1233
|
+
const provider: LlmProvider = {
|
|
1234
|
+
id: "lost-response",
|
|
1235
|
+
async *stream() {
|
|
1236
|
+
streams += 1;
|
|
1237
|
+
dispatchSawDurableIntent = durableEventTypes.at(-1) === "model/request";
|
|
1238
|
+
throw new Error("response lost after dispatch");
|
|
1239
|
+
},
|
|
1240
|
+
reconciliation: {
|
|
1241
|
+
retrieve(effect) {
|
|
1242
|
+
retrieved.push(effect.providerEffectId);
|
|
1243
|
+
return Promise.resolve({
|
|
1244
|
+
status: "unavailable",
|
|
1245
|
+
reason: "provider result is not retrievable yet",
|
|
1246
|
+
});
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
};
|
|
1250
|
+
const root = await mountRuntime(
|
|
1251
|
+
provider,
|
|
1252
|
+
undefined,
|
|
1253
|
+
(_sessionId, events) => {
|
|
1254
|
+
durableEventTypes.push(...events.map((event) => event.type));
|
|
1255
|
+
return Promise.resolve();
|
|
1256
|
+
},
|
|
1257
|
+
);
|
|
1258
|
+
const handle = await root.agents.create({
|
|
1259
|
+
...allowEffectOptions,
|
|
1260
|
+
botId: "bot-lost-response",
|
|
1261
|
+
sessionId: "lost-response",
|
|
1262
|
+
provider: "lost-response",
|
|
1263
|
+
model: "test-model",
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
handle.agent.send("Dispatch once.");
|
|
1267
|
+
await handle.agent.whenIdle();
|
|
1268
|
+
const request = handle.agent.session.events.find(
|
|
1269
|
+
(event) => event.type === "model/request",
|
|
1270
|
+
);
|
|
1271
|
+
if (request?.type !== "model/request") {
|
|
1272
|
+
throw new Error("model request was not recorded");
|
|
1273
|
+
}
|
|
1274
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
1275
|
+
expect.objectContaining({
|
|
1276
|
+
type: "model/reconciliation-required",
|
|
1277
|
+
requestId: request.request.requestId,
|
|
1278
|
+
reason:
|
|
1279
|
+
"Model response outcome is uncertain: response lost after dispatch",
|
|
1280
|
+
}),
|
|
1281
|
+
);
|
|
1282
|
+
expect(
|
|
1283
|
+
handle.agent.session.events.some(
|
|
1284
|
+
(event) => event.type === "step/end" || event.type === "turn/end",
|
|
1285
|
+
),
|
|
1286
|
+
).toBe(false);
|
|
1287
|
+
|
|
1288
|
+
handle.agent.resume();
|
|
1289
|
+
await handle.agent.whenIdle();
|
|
1290
|
+
|
|
1291
|
+
expect(streams).toBe(1);
|
|
1292
|
+
expect(dispatchSawDurableIntent).toBe(true);
|
|
1293
|
+
expect(durableEventTypes).toContain("model/reconciliation-required");
|
|
1294
|
+
expect(durableEventTypes).not.toContain("turn/end");
|
|
1295
|
+
expect(retrieved).toEqual([request.request.requestId]);
|
|
1296
|
+
expect(
|
|
1297
|
+
handle.agent.session.events.some(
|
|
1298
|
+
(event) => event.type === "step/end" || event.type === "turn/end",
|
|
1299
|
+
),
|
|
1300
|
+
).toBe(false);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test("terminally fails only an explicitly unstarted model effect", async () => {
|
|
1304
|
+
const durableEventTypes: string[] = [];
|
|
1305
|
+
let retryPolicySawDurableNoEffect = false;
|
|
1306
|
+
const provider: LlmProvider = {
|
|
1307
|
+
id: "pre-effect-failure",
|
|
1308
|
+
async *stream() {
|
|
1309
|
+
throw new LlmEffectNotStartedError(
|
|
1310
|
+
"provider rejected before effect creation",
|
|
1311
|
+
);
|
|
1312
|
+
},
|
|
1313
|
+
};
|
|
1314
|
+
const root = await mountRuntime(
|
|
1315
|
+
provider,
|
|
1316
|
+
undefined,
|
|
1317
|
+
(_sessionId, events) => {
|
|
1318
|
+
durableEventTypes.push(...events.map((event) => event.type));
|
|
1319
|
+
return Promise.resolve();
|
|
1320
|
+
},
|
|
1321
|
+
);
|
|
1322
|
+
root.on("agent/request-error", async (_agent, _error, _signal, next) => {
|
|
1323
|
+
retryPolicySawDurableNoEffect = durableEventTypes.includes(
|
|
1324
|
+
"model/effect-not-started",
|
|
1325
|
+
);
|
|
1326
|
+
return next();
|
|
1327
|
+
});
|
|
1328
|
+
const handle = await root.agents.create({
|
|
1329
|
+
...allowEffectOptions,
|
|
1330
|
+
botId: "bot-pre-effect-failure",
|
|
1331
|
+
sessionId: "pre-effect-failure",
|
|
1332
|
+
provider: "pre-effect-failure",
|
|
1333
|
+
model: "test-model",
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
handle.agent.send("Fail safely.");
|
|
1337
|
+
await handle.agent.whenIdle();
|
|
1338
|
+
|
|
1339
|
+
expect(
|
|
1340
|
+
handle.agent.session.events.some(
|
|
1341
|
+
(event) => event.type === "model/reconciliation-required",
|
|
1342
|
+
),
|
|
1343
|
+
).toBe(false);
|
|
1344
|
+
expect(durableEventTypes.indexOf("model/request")).toBeLessThan(
|
|
1345
|
+
durableEventTypes.indexOf("model/effect-not-started"),
|
|
1346
|
+
);
|
|
1347
|
+
expect(durableEventTypes.indexOf("model/effect-not-started")).toBeLessThan(
|
|
1348
|
+
durableEventTypes.indexOf("turn/end"),
|
|
1349
|
+
);
|
|
1350
|
+
expect(retryPolicySawDurableNoEffect).toBe(true);
|
|
1351
|
+
expect(handle.agent.session.events.at(-2)).toMatchObject({
|
|
1352
|
+
type: "step/end",
|
|
1353
|
+
outcome: "model-error",
|
|
1354
|
+
});
|
|
1355
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
1356
|
+
type: "turn/end",
|
|
1357
|
+
outcome: "model-error",
|
|
1358
|
+
});
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
test("recovers a durable no-effect outcome without provider reconciliation", async () => {
|
|
1362
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
1363
|
+
const initial = [
|
|
1364
|
+
{ type: "session/created", createdAt: timestamp },
|
|
1365
|
+
{ type: "turn/start", turn: 1 },
|
|
1366
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
1367
|
+
{
|
|
1368
|
+
type: "model/request",
|
|
1369
|
+
turn: 1,
|
|
1370
|
+
step: 1,
|
|
1371
|
+
request: {
|
|
1372
|
+
requestId: "no-effect-request",
|
|
1373
|
+
provider: "no-effect-provider",
|
|
1374
|
+
model: "model-1",
|
|
1375
|
+
system: "",
|
|
1376
|
+
messages: [],
|
|
1377
|
+
tools: [],
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
{
|
|
1381
|
+
type: "model/effect-not-started",
|
|
1382
|
+
turn: 1,
|
|
1383
|
+
step: 1,
|
|
1384
|
+
requestId: "no-effect-request",
|
|
1385
|
+
reason: "provider rejected before dispatch",
|
|
1386
|
+
},
|
|
1387
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
1388
|
+
let streams = 0;
|
|
1389
|
+
let retrievals = 0;
|
|
1390
|
+
const provider: LlmProvider = {
|
|
1391
|
+
id: "no-effect-provider",
|
|
1392
|
+
async *stream() {
|
|
1393
|
+
streams += 1;
|
|
1394
|
+
yield { type: "finish", reason: "completed" };
|
|
1395
|
+
},
|
|
1396
|
+
reconciliation: {
|
|
1397
|
+
retrieve: () => {
|
|
1398
|
+
retrievals += 1;
|
|
1399
|
+
return recovered({ type: "finish", reason: "completed" });
|
|
1400
|
+
},
|
|
1401
|
+
},
|
|
1402
|
+
};
|
|
1403
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
1404
|
+
"no-effect": initial,
|
|
1405
|
+
});
|
|
1406
|
+
const handle = await root.agents.create({
|
|
1407
|
+
...allowEffectOptions,
|
|
1408
|
+
botId: "no-effect-bot",
|
|
1409
|
+
sessionId: "no-effect",
|
|
1410
|
+
provider: "no-effect-provider",
|
|
1411
|
+
model: "model-1",
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
handle.agent.resume();
|
|
1415
|
+
await handle.agent.whenIdle();
|
|
1416
|
+
|
|
1417
|
+
expect(streams).toBe(0);
|
|
1418
|
+
expect(retrievals).toBe(0);
|
|
1419
|
+
expect(handle.agent.session.events.at(-2)).toMatchObject({
|
|
1420
|
+
type: "step/end",
|
|
1421
|
+
outcome: "model-error",
|
|
1422
|
+
});
|
|
1423
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
1424
|
+
type: "turn/end",
|
|
1425
|
+
outcome: "model-error",
|
|
1426
|
+
});
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
test("streams, journals a tool before execution, and repeats the model step", async () => {
|
|
1430
|
+
const requests: string[] = [];
|
|
1431
|
+
let toolWasJournaled = false;
|
|
1432
|
+
let modelIntentWasDurable = false;
|
|
1433
|
+
let toolIntentWasDurable = false;
|
|
1434
|
+
const durableEventTypes: string[] = [];
|
|
1435
|
+
let turnStoppingSawCompletedJournal = false;
|
|
1436
|
+
let observedPromptSessionId: string | undefined;
|
|
1437
|
+
let observedToolIdentity:
|
|
1438
|
+
| {
|
|
1439
|
+
botId: string;
|
|
1440
|
+
agentId: string;
|
|
1441
|
+
sessionId: string;
|
|
1442
|
+
compositionGenerationId: string;
|
|
1443
|
+
}
|
|
1444
|
+
| undefined;
|
|
1445
|
+
let root: Context;
|
|
1446
|
+
const provider: LlmProvider = {
|
|
1447
|
+
id: "scripted",
|
|
1448
|
+
async *stream(request) {
|
|
1449
|
+
modelIntentWasDurable = durableEventTypes.at(-1) === "model/request";
|
|
1450
|
+
requests.push(request.requestId);
|
|
1451
|
+
if (requests.length === 1) {
|
|
1452
|
+
yield { type: "text-delta", text: "Checking. " };
|
|
1453
|
+
yield {
|
|
1454
|
+
type: "tool-call",
|
|
1455
|
+
call: { id: "call-1", name: "echo", input: { value: "hello" } },
|
|
1456
|
+
};
|
|
1457
|
+
yield {
|
|
1458
|
+
type: "tool-call",
|
|
1459
|
+
call: {
|
|
1460
|
+
id: "call-1",
|
|
1461
|
+
name: "echo",
|
|
1462
|
+
input: { value: "goodbye" },
|
|
1463
|
+
},
|
|
1464
|
+
};
|
|
1465
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
expect(
|
|
1469
|
+
request.messages.flatMap((message) =>
|
|
1470
|
+
message.role === "tool"
|
|
1471
|
+
? [{ callId: message.callId, content: message.content }]
|
|
1472
|
+
: [],
|
|
1473
|
+
),
|
|
1474
|
+
).toEqual([
|
|
1475
|
+
{ callId: "call-1", content: "hello" },
|
|
1476
|
+
{ callId: "call-1", content: "goodbye" },
|
|
1477
|
+
]);
|
|
1478
|
+
const result = request.messages.findLast(
|
|
1479
|
+
(message) => message.role === "tool",
|
|
1480
|
+
);
|
|
1481
|
+
yield {
|
|
1482
|
+
type: "text-delta",
|
|
1483
|
+
text: `Tool returned ${result?.role === "tool" ? result.content : "nothing"}.`,
|
|
1484
|
+
};
|
|
1485
|
+
yield { type: "finish", reason: "completed" };
|
|
1486
|
+
},
|
|
1487
|
+
};
|
|
1488
|
+
const tool: ToolDefinition = {
|
|
1489
|
+
name: "echo",
|
|
1490
|
+
description: "Return a supplied value.",
|
|
1491
|
+
inputSchema: {
|
|
1492
|
+
type: "object",
|
|
1493
|
+
properties: { value: { type: "string" } },
|
|
1494
|
+
required: ["value"],
|
|
1495
|
+
},
|
|
1496
|
+
validate: (input) =>
|
|
1497
|
+
typeof input === "object" &&
|
|
1498
|
+
input !== null &&
|
|
1499
|
+
typeof (input as { value?: unknown }).value === "string",
|
|
1500
|
+
async execute(input, context) {
|
|
1501
|
+
const session = root.agents.get("general")?.session;
|
|
1502
|
+
toolWasJournaled = session?.events.at(-1)?.type === "tool/call";
|
|
1503
|
+
toolIntentWasDurable = durableEventTypes.at(-1) === "tool/call";
|
|
1504
|
+
const identifiedContext = context as typeof context & {
|
|
1505
|
+
agentId: string;
|
|
1506
|
+
};
|
|
1507
|
+
observedToolIdentity = {
|
|
1508
|
+
botId: context.botId,
|
|
1509
|
+
agentId: identifiedContext.agentId,
|
|
1510
|
+
sessionId: context.sessionId,
|
|
1511
|
+
compositionGenerationId: context.compositionGenerationId,
|
|
1512
|
+
};
|
|
1513
|
+
return {
|
|
1514
|
+
content: (input as { value: string }).value,
|
|
1515
|
+
isError: false,
|
|
1516
|
+
};
|
|
1517
|
+
},
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
root = await mountRuntime(provider, tool, (_sessionId, events) => {
|
|
1521
|
+
durableEventTypes.push(...events.map((event) => event.type));
|
|
1522
|
+
return Promise.resolve();
|
|
1523
|
+
});
|
|
1524
|
+
root.systemPrompt.register({
|
|
1525
|
+
id: "session-observer",
|
|
1526
|
+
render: (context) => {
|
|
1527
|
+
observedPromptSessionId = context.sessionId;
|
|
1528
|
+
return "";
|
|
1529
|
+
},
|
|
1530
|
+
});
|
|
1531
|
+
root.on("agent/turn-stopping", (agent) => {
|
|
1532
|
+
turnStoppingSawCompletedJournal =
|
|
1533
|
+
agent.session.events.at(-1)?.type === "turn/end";
|
|
1534
|
+
return Promise.resolve();
|
|
1535
|
+
});
|
|
1536
|
+
const agentOptions: AgentOptions & { agentId: string } = {
|
|
1537
|
+
botId: "general-bot",
|
|
1538
|
+
agentId: "general",
|
|
1539
|
+
sessionId: "owner:general:conversation-1",
|
|
1540
|
+
provider: "scripted",
|
|
1541
|
+
model: "test-model",
|
|
1542
|
+
...allowEffectOptions,
|
|
1543
|
+
};
|
|
1544
|
+
const handle = await root.agents.create(agentOptions);
|
|
1545
|
+
handle.agent.send("Use the echo tool.");
|
|
1546
|
+
await handle.agent.whenIdle();
|
|
1547
|
+
|
|
1548
|
+
const events = handle.agent.session.events;
|
|
1549
|
+
expect(requests).toHaveLength(2);
|
|
1550
|
+
expect(toolWasJournaled).toBe(true);
|
|
1551
|
+
expect(modelIntentWasDurable).toBe(true);
|
|
1552
|
+
expect(toolIntentWasDurable).toBe(true);
|
|
1553
|
+
expect(turnStoppingSawCompletedJournal).toBe(true);
|
|
1554
|
+
expect(handle.agent.id).toBe("general");
|
|
1555
|
+
expect(handle.agent.botId).toBe("general-bot");
|
|
1556
|
+
expect(handle.agent.session.id).toBe("owner:general:conversation-1");
|
|
1557
|
+
expect(observedPromptSessionId).toBe("owner:general:conversation-1");
|
|
1558
|
+
expect(observedToolIdentity).toEqual({
|
|
1559
|
+
botId: "general-bot",
|
|
1560
|
+
agentId: "general",
|
|
1561
|
+
sessionId: "owner:general:conversation-1",
|
|
1562
|
+
compositionGenerationId: TEST_COMPOSITION.generationId,
|
|
1563
|
+
});
|
|
1564
|
+
expect(events.filter((event) => event.type === "step/start")).toHaveLength(
|
|
1565
|
+
2,
|
|
1566
|
+
);
|
|
1567
|
+
expect(events.filter((event) => event.type === "step/end")).toHaveLength(2);
|
|
1568
|
+
expect(events.filter((event) => event.type === "turn/start")).toHaveLength(
|
|
1569
|
+
1,
|
|
1570
|
+
);
|
|
1571
|
+
expect(events.filter((event) => event.type === "turn/end")).toHaveLength(1);
|
|
1572
|
+
expect(
|
|
1573
|
+
events.flatMap((event) =>
|
|
1574
|
+
event.type === "tool/call" || event.type === "tool/result"
|
|
1575
|
+
? [event.occurrenceId]
|
|
1576
|
+
: [],
|
|
1577
|
+
),
|
|
1578
|
+
).toEqual(["tool:1:1:0", "tool:1:1:0", "tool:1:1:1", "tool:1:1:1"]);
|
|
1579
|
+
expect(
|
|
1580
|
+
JSON.stringify(
|
|
1581
|
+
events.filter(
|
|
1582
|
+
(event) => event.type === "tool/call" || event.type === "tool/result",
|
|
1583
|
+
),
|
|
1584
|
+
),
|
|
1585
|
+
).not.toContain("call-1");
|
|
1586
|
+
expect(
|
|
1587
|
+
events.find((event) => event.type === "model/request"),
|
|
1588
|
+
).toMatchObject({
|
|
1589
|
+
request: {
|
|
1590
|
+
provider: "scripted",
|
|
1591
|
+
model: "test-model",
|
|
1592
|
+
system: "You are the FrockBot test agent.",
|
|
1593
|
+
tools: [{ name: "echo" }],
|
|
1594
|
+
},
|
|
1595
|
+
});
|
|
1596
|
+
expect(events.at(-1)).toMatchObject({
|
|
1597
|
+
type: "turn/end",
|
|
1598
|
+
outcome: "completed",
|
|
1599
|
+
});
|
|
1600
|
+
expect(handle.agent.session.deriveMessages().at(-1)).toMatchObject({
|
|
1601
|
+
role: "assistant",
|
|
1602
|
+
content: "Tool returned goodbye.",
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
const session = handle.agent.session;
|
|
1606
|
+
await handle.dispose();
|
|
1607
|
+
expect(root.agents.list()).toEqual([]);
|
|
1608
|
+
expect(session.events.at(-1)?.type).toBe("session/disposed");
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
test("keeps an aborted durable model request open for reconciliation", async () => {
|
|
1612
|
+
const provider: LlmProvider = {
|
|
1613
|
+
id: "blocking",
|
|
1614
|
+
async *stream(_request, signal) {
|
|
1615
|
+
await new Promise<void>((_resolve, reject) => {
|
|
1616
|
+
if (signal.aborted) {
|
|
1617
|
+
reject(signal.reason);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
signal.addEventListener("abort", () => reject(signal.reason), {
|
|
1621
|
+
once: true,
|
|
1622
|
+
});
|
|
1623
|
+
});
|
|
1624
|
+
yield { type: "finish", reason: "completed" };
|
|
1625
|
+
},
|
|
1626
|
+
};
|
|
1627
|
+
const root = await mountRuntime(provider);
|
|
1628
|
+
const handle = await root.agents.create({
|
|
1629
|
+
...allowEffectOptions,
|
|
1630
|
+
botId: "bot-2",
|
|
1631
|
+
sessionId: "agent-2",
|
|
1632
|
+
provider: "blocking",
|
|
1633
|
+
model: "test-model",
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
handle.agent.send("Wait forever.");
|
|
1637
|
+
await eventually(() =>
|
|
1638
|
+
expect(
|
|
1639
|
+
handle.agent.session.events.some(
|
|
1640
|
+
(event) => event.type === "model/request",
|
|
1641
|
+
),
|
|
1642
|
+
).toBe(true),
|
|
1643
|
+
);
|
|
1644
|
+
handle.agent.cancel();
|
|
1645
|
+
await handle.agent.whenIdle();
|
|
1646
|
+
|
|
1647
|
+
expect(
|
|
1648
|
+
handle.agent.session.events.some(
|
|
1649
|
+
(event) => event.type === "step/end" || event.type === "turn/end",
|
|
1650
|
+
),
|
|
1651
|
+
).toBe(false);
|
|
1652
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
1653
|
+
expect.objectContaining({
|
|
1654
|
+
type: "model/reconciliation-required",
|
|
1655
|
+
reason: expect.stringContaining("uncertain after cancellation"),
|
|
1656
|
+
}),
|
|
1657
|
+
);
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
test("cancels after a recovered assistant response is durably flushed", async () => {
|
|
1661
|
+
const timestamp = "2026-08-30T00:00:00.000Z";
|
|
1662
|
+
const initial = [
|
|
1663
|
+
{ type: "session/created", createdAt: timestamp },
|
|
1664
|
+
{ type: "turn/start", turn: 1 },
|
|
1665
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
1666
|
+
{
|
|
1667
|
+
type: "model/request",
|
|
1668
|
+
turn: 1,
|
|
1669
|
+
step: 1,
|
|
1670
|
+
request: {
|
|
1671
|
+
requestId: "flush-request",
|
|
1672
|
+
provider: "flush-cancellation",
|
|
1673
|
+
model: "test-model",
|
|
1674
|
+
system: "",
|
|
1675
|
+
messages: [],
|
|
1676
|
+
tools: [],
|
|
1677
|
+
},
|
|
1678
|
+
},
|
|
1679
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
1680
|
+
const provider: LlmProvider = {
|
|
1681
|
+
id: "flush-cancellation",
|
|
1682
|
+
async *stream() {
|
|
1683
|
+
throw new Error("recovery must not dispatch another model request");
|
|
1684
|
+
},
|
|
1685
|
+
reconciliation: {
|
|
1686
|
+
retrieve: () =>
|
|
1687
|
+
recovered(
|
|
1688
|
+
{ type: "text-delta", text: "Recovered answer" },
|
|
1689
|
+
{ type: "finish", reason: "completed" },
|
|
1690
|
+
),
|
|
1691
|
+
},
|
|
1692
|
+
};
|
|
1693
|
+
let cancel = () => {};
|
|
1694
|
+
const root = await mountRuntime(
|
|
1695
|
+
provider,
|
|
1696
|
+
undefined,
|
|
1697
|
+
(_sessionId, events) => {
|
|
1698
|
+
if (events.some((event) => event.type === "assistant/message"))
|
|
1699
|
+
cancel();
|
|
1700
|
+
return Promise.resolve();
|
|
1701
|
+
},
|
|
1702
|
+
{ "agent-flush-cancel": initial },
|
|
1703
|
+
);
|
|
1704
|
+
const handle = await root.agents.create({
|
|
1705
|
+
...allowEffectOptions,
|
|
1706
|
+
botId: "bot-flush-cancel",
|
|
1707
|
+
sessionId: "agent-flush-cancel",
|
|
1708
|
+
provider: "flush-cancellation",
|
|
1709
|
+
model: "test-model",
|
|
1710
|
+
});
|
|
1711
|
+
cancel = () => handle.agent.cancel();
|
|
1712
|
+
|
|
1713
|
+
handle.agent.resume();
|
|
1714
|
+
await handle.agent.whenIdle();
|
|
1715
|
+
|
|
1716
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
1717
|
+
expect.objectContaining({
|
|
1718
|
+
type: "assistant/message",
|
|
1719
|
+
text: "Recovered answer",
|
|
1720
|
+
}),
|
|
1721
|
+
);
|
|
1722
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
1723
|
+
type: "turn/end",
|
|
1724
|
+
outcome: "cancelled",
|
|
1725
|
+
});
|
|
1726
|
+
expect(
|
|
1727
|
+
handle.agent.session.events.filter(
|
|
1728
|
+
(event) => event.type === "turn/end" && event.outcome === "completed",
|
|
1729
|
+
),
|
|
1730
|
+
).toEqual([]);
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
test("keeps a cancelled non-idempotent tool effect open", async () => {
|
|
1734
|
+
const provider: LlmProvider = {
|
|
1735
|
+
id: "tool-cancellation",
|
|
1736
|
+
async *stream() {
|
|
1737
|
+
yield {
|
|
1738
|
+
type: "tool-call",
|
|
1739
|
+
call: { id: "external", name: "external", input: {} },
|
|
1740
|
+
};
|
|
1741
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
1742
|
+
},
|
|
1743
|
+
};
|
|
1744
|
+
let effectId: string | undefined;
|
|
1745
|
+
let executions = 0;
|
|
1746
|
+
const reconciliations: string[] = [];
|
|
1747
|
+
const tool: RecoverableToolDefinition = {
|
|
1748
|
+
name: "external",
|
|
1749
|
+
description: "Potentially non-idempotent external effect.",
|
|
1750
|
+
inputSchema: { type: "object" },
|
|
1751
|
+
execute(_input, context) {
|
|
1752
|
+
executions += 1;
|
|
1753
|
+
effectId = context.effectId;
|
|
1754
|
+
return new Promise((_resolve, reject) => {
|
|
1755
|
+
context.signal.addEventListener(
|
|
1756
|
+
"abort",
|
|
1757
|
+
() => reject(context.signal.reason),
|
|
1758
|
+
{ once: true },
|
|
1759
|
+
);
|
|
1760
|
+
});
|
|
1761
|
+
},
|
|
1762
|
+
reconcile(_input, context) {
|
|
1763
|
+
reconciliations.push(context.effectId);
|
|
1764
|
+
return Promise.resolve({
|
|
1765
|
+
status: "unavailable",
|
|
1766
|
+
reason: "provider result is not retrievable yet",
|
|
1767
|
+
});
|
|
1768
|
+
},
|
|
1769
|
+
};
|
|
1770
|
+
const root = await mountRuntime(provider, tool);
|
|
1771
|
+
const handle = await root.agents.create({
|
|
1772
|
+
...allowEffectOptions,
|
|
1773
|
+
botId: "bot-tool-cancel",
|
|
1774
|
+
sessionId: "agent-tool-cancel",
|
|
1775
|
+
provider: "tool-cancellation",
|
|
1776
|
+
model: "test-model",
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
handle.agent.send("Start an external effect.");
|
|
1780
|
+
await eventually(() =>
|
|
1781
|
+
expect(
|
|
1782
|
+
handle.agent.session.events.some((event) => event.type === "tool/call"),
|
|
1783
|
+
).toBe(true),
|
|
1784
|
+
);
|
|
1785
|
+
handle.agent.cancel();
|
|
1786
|
+
await handle.agent.whenIdle();
|
|
1787
|
+
|
|
1788
|
+
expect(effectId).toBe("tool:1:1:0");
|
|
1789
|
+
expect(
|
|
1790
|
+
handle.agent.session.events.some(
|
|
1791
|
+
(event) => event.type === "tool/result" || event.type === "turn/end",
|
|
1792
|
+
),
|
|
1793
|
+
).toBe(false);
|
|
1794
|
+
|
|
1795
|
+
handle.agent.resume();
|
|
1796
|
+
await handle.agent.whenIdle();
|
|
1797
|
+
expect(executions).toBe(1);
|
|
1798
|
+
expect(reconciliations).toEqual(["tool:1:1:0"]);
|
|
1799
|
+
expect(
|
|
1800
|
+
handle.agent.session.events.some(
|
|
1801
|
+
(event) => event.type === "tool/result" || event.type === "turn/end",
|
|
1802
|
+
),
|
|
1803
|
+
).toBe(false);
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
test("recovers a non-idempotent open tool without executing it again", async () => {
|
|
1807
|
+
let modelRequests = 0;
|
|
1808
|
+
let executions = 0;
|
|
1809
|
+
const reconciled: string[] = [];
|
|
1810
|
+
const provider: LlmProvider = {
|
|
1811
|
+
id: "non-idempotent-tool-recovery",
|
|
1812
|
+
async *stream() {
|
|
1813
|
+
modelRequests += 1;
|
|
1814
|
+
yield { type: "text-delta", text: "Recovered safely." };
|
|
1815
|
+
yield { type: "finish", reason: "completed" };
|
|
1816
|
+
},
|
|
1817
|
+
};
|
|
1818
|
+
const tool: RecoverableToolDefinition = {
|
|
1819
|
+
name: "external",
|
|
1820
|
+
description: "Recoverable non-idempotent effect.",
|
|
1821
|
+
inputSchema: { type: "object" },
|
|
1822
|
+
execute() {
|
|
1823
|
+
executions += 1;
|
|
1824
|
+
return Promise.resolve({ content: "duplicated", isError: false });
|
|
1825
|
+
},
|
|
1826
|
+
reconcile(_input, context) {
|
|
1827
|
+
reconciled.push(context.effectId);
|
|
1828
|
+
return Promise.resolve({
|
|
1829
|
+
status: "recovered",
|
|
1830
|
+
result: { content: "original result", isError: false },
|
|
1831
|
+
});
|
|
1832
|
+
},
|
|
1833
|
+
};
|
|
1834
|
+
const root = await mountRuntime(provider, tool, undefined, {
|
|
1835
|
+
"recovered-tool-session": openToolSessionEvents(provider.id, tool.name),
|
|
1836
|
+
});
|
|
1837
|
+
const handle = await root.agents.create({
|
|
1838
|
+
...allowEffectOptions,
|
|
1839
|
+
botId: "recovered-tool-bot",
|
|
1840
|
+
sessionId: "recovered-tool-session",
|
|
1841
|
+
provider: provider.id,
|
|
1842
|
+
model: "test-model",
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
expect(handle.agent.session.reconcileForResume()).toEqual([]);
|
|
1846
|
+
handle.agent.resume();
|
|
1847
|
+
await handle.agent.whenIdle();
|
|
1848
|
+
|
|
1849
|
+
expect(executions).toBe(0);
|
|
1850
|
+
expect(reconciled).toEqual(["tool:1:1:0"]);
|
|
1851
|
+
expect(modelRequests).toBe(1);
|
|
1852
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
1853
|
+
expect.objectContaining({
|
|
1854
|
+
type: "tool/result",
|
|
1855
|
+
occurrenceId: "tool:1:1:0",
|
|
1856
|
+
content: "original result",
|
|
1857
|
+
status: "completed",
|
|
1858
|
+
}),
|
|
1859
|
+
);
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
test("reconciles an open idempotent tool with its durable effect id", async () => {
|
|
1863
|
+
const timestamp = "2026-08-30T00:00:00.000Z";
|
|
1864
|
+
const initial = [
|
|
1865
|
+
{ type: "session/created", createdAt: timestamp },
|
|
1866
|
+
{ type: "turn/start", turn: 1 },
|
|
1867
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
1868
|
+
{
|
|
1869
|
+
type: "model/request",
|
|
1870
|
+
turn: 1,
|
|
1871
|
+
step: 1,
|
|
1872
|
+
request: {
|
|
1873
|
+
requestId: "tool-model-request",
|
|
1874
|
+
provider: "idempotent-tool-recovery",
|
|
1875
|
+
model: "test-model",
|
|
1876
|
+
system: "",
|
|
1877
|
+
messages: [],
|
|
1878
|
+
tools: [],
|
|
1879
|
+
},
|
|
1880
|
+
},
|
|
1881
|
+
{
|
|
1882
|
+
type: "assistant/message",
|
|
1883
|
+
turn: 1,
|
|
1884
|
+
step: 1,
|
|
1885
|
+
requestId: "tool-model-request",
|
|
1886
|
+
text: "",
|
|
1887
|
+
toolCalls: [{ id: "provider-call", name: "safe", input: {} }],
|
|
1888
|
+
},
|
|
1889
|
+
{
|
|
1890
|
+
type: "tool/call",
|
|
1891
|
+
turn: 1,
|
|
1892
|
+
step: 1,
|
|
1893
|
+
occurrenceId: "tool:1:1:0",
|
|
1894
|
+
name: "safe",
|
|
1895
|
+
input: {},
|
|
1896
|
+
},
|
|
1897
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
1898
|
+
const effects: (string | undefined)[] = [];
|
|
1899
|
+
const provider: LlmProvider = {
|
|
1900
|
+
id: "idempotent-tool-recovery",
|
|
1901
|
+
async *stream() {
|
|
1902
|
+
yield { type: "text-delta", text: "Recovered safely." };
|
|
1903
|
+
yield { type: "finish", reason: "completed" };
|
|
1904
|
+
},
|
|
1905
|
+
};
|
|
1906
|
+
const tool: ToolDefinition = {
|
|
1907
|
+
name: "safe",
|
|
1908
|
+
description: "Idempotent effect.",
|
|
1909
|
+
inputSchema: { type: "object" },
|
|
1910
|
+
idempotent: true,
|
|
1911
|
+
execute(_input, context) {
|
|
1912
|
+
effects.push(
|
|
1913
|
+
(context as typeof context & { effectId?: string }).effectId,
|
|
1914
|
+
);
|
|
1915
|
+
return Promise.resolve({ content: "settled", isError: false });
|
|
1916
|
+
},
|
|
1917
|
+
};
|
|
1918
|
+
const root = await mountRuntime(provider, tool, undefined, {
|
|
1919
|
+
"idempotent-session": initial,
|
|
1920
|
+
});
|
|
1921
|
+
const handle = await root.agents.create({
|
|
1922
|
+
...allowEffectOptions,
|
|
1923
|
+
botId: "idempotent-bot",
|
|
1924
|
+
sessionId: "idempotent-session",
|
|
1925
|
+
provider: "idempotent-tool-recovery",
|
|
1926
|
+
model: "test-model",
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
handle.agent.resume();
|
|
1930
|
+
await handle.agent.whenIdle();
|
|
1931
|
+
|
|
1932
|
+
expect(effects).toEqual(["tool:1:1:0"]);
|
|
1933
|
+
expect(
|
|
1934
|
+
handle.agent.session.events.filter(
|
|
1935
|
+
(event) =>
|
|
1936
|
+
event.type === "tool/call" && event.occurrenceId === "tool:1:1:0",
|
|
1937
|
+
),
|
|
1938
|
+
).toHaveLength(1);
|
|
1939
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
1940
|
+
expect.objectContaining({
|
|
1941
|
+
type: "tool/result",
|
|
1942
|
+
occurrenceId: "tool:1:1:0",
|
|
1943
|
+
status: "completed",
|
|
1944
|
+
}),
|
|
1945
|
+
);
|
|
1946
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
1947
|
+
type: "turn/end",
|
|
1948
|
+
outcome: "completed",
|
|
1949
|
+
});
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
test("settles remaining tool occurrences before closing a cancelled turn", async () => {
|
|
1953
|
+
let requests = 0;
|
|
1954
|
+
const provider: LlmProvider = {
|
|
1955
|
+
id: "multi-tool-cancellation",
|
|
1956
|
+
async *stream() {
|
|
1957
|
+
requests += 1;
|
|
1958
|
+
if (requests === 1) {
|
|
1959
|
+
yield {
|
|
1960
|
+
type: "tool-call",
|
|
1961
|
+
call: { id: "first", name: "echo", input: { value: "first" } },
|
|
1962
|
+
};
|
|
1963
|
+
yield {
|
|
1964
|
+
type: "tool-call",
|
|
1965
|
+
call: {
|
|
1966
|
+
id: "second",
|
|
1967
|
+
name: "echo",
|
|
1968
|
+
input: { value: "second" },
|
|
1969
|
+
},
|
|
1970
|
+
};
|
|
1971
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
yield { type: "text-delta", text: "Next Turn completed." };
|
|
1975
|
+
yield { type: "finish", reason: "completed" };
|
|
1976
|
+
},
|
|
1977
|
+
};
|
|
1978
|
+
const executions: string[] = [];
|
|
1979
|
+
let cancel: () => void = () => {
|
|
1980
|
+
throw new Error("agent is not ready");
|
|
1981
|
+
};
|
|
1982
|
+
const tool: ToolDefinition = {
|
|
1983
|
+
name: "echo",
|
|
1984
|
+
description: "Return a supplied value.",
|
|
1985
|
+
inputSchema: {
|
|
1986
|
+
type: "object",
|
|
1987
|
+
properties: { value: { type: "string" } },
|
|
1988
|
+
required: ["value"],
|
|
1989
|
+
},
|
|
1990
|
+
execute(input) {
|
|
1991
|
+
const value = (input as { value: string }).value;
|
|
1992
|
+
executions.push(value);
|
|
1993
|
+
cancel();
|
|
1994
|
+
return Promise.resolve({ content: value, isError: false });
|
|
1995
|
+
},
|
|
1996
|
+
};
|
|
1997
|
+
const root = await mountRuntime(provider, tool);
|
|
1998
|
+
const handle = await root.agents.create({
|
|
1999
|
+
...allowEffectOptions,
|
|
2000
|
+
botId: "bot-cancel-tools",
|
|
2001
|
+
sessionId: "agent-cancel-tools",
|
|
2002
|
+
provider: "multi-tool-cancellation",
|
|
2003
|
+
model: "test-model",
|
|
2004
|
+
});
|
|
2005
|
+
cancel = () => handle.agent.cancel();
|
|
2006
|
+
|
|
2007
|
+
handle.agent.send("Run two tools.");
|
|
2008
|
+
await handle.agent.whenIdle();
|
|
2009
|
+
|
|
2010
|
+
expect(executions).toEqual(["first"]);
|
|
2011
|
+
expect(
|
|
2012
|
+
handle.agent.session.events.flatMap((event) =>
|
|
2013
|
+
(event.type === "tool/call" || event.type === "tool/result") &&
|
|
2014
|
+
event.turn === 1
|
|
2015
|
+
? [
|
|
2016
|
+
{
|
|
2017
|
+
type: event.type,
|
|
2018
|
+
occurrenceId: event.occurrenceId,
|
|
2019
|
+
...(event.type === "tool/result"
|
|
2020
|
+
? { status: event.status }
|
|
2021
|
+
: {}),
|
|
2022
|
+
},
|
|
2023
|
+
]
|
|
2024
|
+
: [],
|
|
2025
|
+
),
|
|
2026
|
+
).toEqual([
|
|
2027
|
+
{ type: "tool/call", occurrenceId: "tool:1:1:0" },
|
|
2028
|
+
{
|
|
2029
|
+
type: "tool/result",
|
|
2030
|
+
occurrenceId: "tool:1:1:0",
|
|
2031
|
+
status: "completed",
|
|
2032
|
+
},
|
|
2033
|
+
{ type: "tool/call", occurrenceId: "tool:1:1:1" },
|
|
2034
|
+
{
|
|
2035
|
+
type: "tool/result",
|
|
2036
|
+
occurrenceId: "tool:1:1:1",
|
|
2037
|
+
status: "interrupted",
|
|
2038
|
+
},
|
|
2039
|
+
]);
|
|
2040
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
2041
|
+
expect.objectContaining({
|
|
2042
|
+
type: "turn/end",
|
|
2043
|
+
turn: 1,
|
|
2044
|
+
outcome: "cancelled",
|
|
2045
|
+
}),
|
|
2046
|
+
);
|
|
2047
|
+
|
|
2048
|
+
handle.agent.send("Continue with another Turn.");
|
|
2049
|
+
await handle.agent.whenIdle();
|
|
2050
|
+
|
|
2051
|
+
expect(requests).toBe(2);
|
|
2052
|
+
expect(executions).toEqual(["first"]);
|
|
2053
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
2054
|
+
type: "turn/end",
|
|
2055
|
+
turn: 2,
|
|
2056
|
+
outcome: "completed",
|
|
2057
|
+
});
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
test("journals a failed prepared tool result before completing the turn", async () => {
|
|
2061
|
+
let requests = 0;
|
|
2062
|
+
const durableTypes: string[] = [];
|
|
2063
|
+
const provider: LlmProvider = {
|
|
2064
|
+
id: "tool-failure",
|
|
2065
|
+
async *stream(request) {
|
|
2066
|
+
requests += 1;
|
|
2067
|
+
if (requests === 1) {
|
|
2068
|
+
yield {
|
|
2069
|
+
type: "tool-call",
|
|
2070
|
+
call: { id: "call-failed", name: "fails", input: {} },
|
|
2071
|
+
};
|
|
2072
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
const result = request.messages.findLast(
|
|
2076
|
+
(message) => message.role === "tool",
|
|
2077
|
+
);
|
|
2078
|
+
yield {
|
|
2079
|
+
type: "text-delta",
|
|
2080
|
+
text:
|
|
2081
|
+
result?.role === "tool" && result.isError
|
|
2082
|
+
? "Recovered."
|
|
2083
|
+
: "Missing error.",
|
|
2084
|
+
};
|
|
2085
|
+
yield { type: "finish", reason: "completed" };
|
|
2086
|
+
},
|
|
2087
|
+
};
|
|
2088
|
+
const tool: ToolDefinition = {
|
|
2089
|
+
name: "fails",
|
|
2090
|
+
description: "Always fails.",
|
|
2091
|
+
inputSchema: { type: "object" },
|
|
2092
|
+
idempotent: true,
|
|
2093
|
+
validate: () => true,
|
|
2094
|
+
execute: () => Promise.reject(new Error("provider revoked")),
|
|
2095
|
+
};
|
|
2096
|
+
const root = await mountRuntime(provider, tool, (_sessionId, events) => {
|
|
2097
|
+
durableTypes.push(...events.map((event) => event.type));
|
|
2098
|
+
return Promise.resolve();
|
|
2099
|
+
});
|
|
2100
|
+
const handle = await root.agents.create({
|
|
2101
|
+
...allowEffectOptions,
|
|
2102
|
+
botId: "bot-failure",
|
|
2103
|
+
sessionId: "agent-failure",
|
|
2104
|
+
provider: "tool-failure",
|
|
2105
|
+
model: "test-model",
|
|
2106
|
+
});
|
|
2107
|
+
handle.agent.send("Use the failing tool.");
|
|
2108
|
+
await handle.agent.whenIdle();
|
|
2109
|
+
|
|
2110
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
2111
|
+
expect.objectContaining({
|
|
2112
|
+
type: "tool/result",
|
|
2113
|
+
occurrenceId: "tool:1:1:0",
|
|
2114
|
+
content: "provider revoked",
|
|
2115
|
+
isError: true,
|
|
2116
|
+
}),
|
|
2117
|
+
);
|
|
2118
|
+
expect(durableTypes.indexOf("tool/result")).toBeLessThan(
|
|
2119
|
+
durableTypes.lastIndexOf("turn/end"),
|
|
2120
|
+
);
|
|
2121
|
+
expect(handle.agent.session.deriveMessages().at(-1)).toMatchObject({
|
|
2122
|
+
role: "assistant",
|
|
2123
|
+
content: "Recovered.",
|
|
2124
|
+
});
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
test("resumes an explicitly reconciled turn without admitting input twice", async () => {
|
|
2128
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
2129
|
+
const initial = [
|
|
2130
|
+
{ type: "session/created", createdAt: timestamp },
|
|
2131
|
+
{ type: "input/queued", messageId: "message-1", text: "Continue once." },
|
|
2132
|
+
{ type: "turn/start", turn: 1 },
|
|
2133
|
+
{ type: "input/admitted", messageId: "message-1", turn: 1 },
|
|
2134
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
2135
|
+
{
|
|
2136
|
+
type: "user/message",
|
|
2137
|
+
turn: 1,
|
|
2138
|
+
step: 1,
|
|
2139
|
+
messageId: "message-1",
|
|
2140
|
+
text: "Continue once.",
|
|
2141
|
+
},
|
|
2142
|
+
{
|
|
2143
|
+
type: "model/request",
|
|
2144
|
+
turn: 1,
|
|
2145
|
+
step: 1,
|
|
2146
|
+
request: {
|
|
2147
|
+
requestId: "uncertain-request",
|
|
2148
|
+
provider: "resume-provider",
|
|
2149
|
+
model: "test-model",
|
|
2150
|
+
system: "",
|
|
2151
|
+
messages: [{ role: "user", content: "Continue once." }],
|
|
2152
|
+
tools: [],
|
|
2153
|
+
},
|
|
2154
|
+
},
|
|
2155
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
2156
|
+
const provider: LlmProvider = {
|
|
2157
|
+
id: "resume-provider",
|
|
2158
|
+
async *stream() {
|
|
2159
|
+
throw new Error("resume must not create a new model request");
|
|
2160
|
+
},
|
|
2161
|
+
reconciliation: {
|
|
2162
|
+
retrieve(effect) {
|
|
2163
|
+
expect(effect.providerEffectId).toBe("uncertain-request");
|
|
2164
|
+
return recovered(
|
|
2165
|
+
{ type: "text-delta", text: "Resumed safely." },
|
|
2166
|
+
{ type: "finish", reason: "completed" },
|
|
2167
|
+
);
|
|
2168
|
+
},
|
|
2169
|
+
},
|
|
2170
|
+
};
|
|
2171
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
2172
|
+
"resume-session": initial,
|
|
2173
|
+
});
|
|
2174
|
+
const handle = await root.agents.create({
|
|
2175
|
+
...allowEffectOptions,
|
|
2176
|
+
botId: "resume-bot",
|
|
2177
|
+
sessionId: "resume-session",
|
|
2178
|
+
provider: "resume-provider",
|
|
2179
|
+
model: "test-model",
|
|
2180
|
+
});
|
|
2181
|
+
handle.agent.session.reconcileForResume();
|
|
2182
|
+
handle.agent.resume();
|
|
2183
|
+
await handle.agent.whenIdle();
|
|
2184
|
+
|
|
2185
|
+
expect(
|
|
2186
|
+
handle.agent.session.events.filter(
|
|
2187
|
+
(event) => event.type === "input/admitted",
|
|
2188
|
+
),
|
|
2189
|
+
).toHaveLength(1);
|
|
2190
|
+
expect(
|
|
2191
|
+
handle.agent.session.events.filter(
|
|
2192
|
+
(event) => event.type === "model/request",
|
|
2193
|
+
),
|
|
2194
|
+
).toHaveLength(1);
|
|
2195
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
2196
|
+
expect.objectContaining({
|
|
2197
|
+
type: "assistant/message",
|
|
2198
|
+
requestId: "uncertain-request",
|
|
2199
|
+
}),
|
|
2200
|
+
);
|
|
2201
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
2202
|
+
type: "turn/end",
|
|
2203
|
+
outcome: "completed",
|
|
2204
|
+
});
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
test("resumes durable assistant tool calls that were not journaled", async () => {
|
|
2208
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
2209
|
+
const call = {
|
|
2210
|
+
id: "durable-call",
|
|
2211
|
+
name: "echo",
|
|
2212
|
+
input: { value: "resumed" },
|
|
2213
|
+
};
|
|
2214
|
+
const initial = [
|
|
2215
|
+
{ type: "session/created", createdAt: timestamp },
|
|
2216
|
+
{ type: "turn/start", turn: 1 },
|
|
2217
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
2218
|
+
{
|
|
2219
|
+
type: "model/request",
|
|
2220
|
+
turn: 1,
|
|
2221
|
+
step: 1,
|
|
2222
|
+
request: {
|
|
2223
|
+
requestId: "completed-request",
|
|
2224
|
+
provider: "resume-tools",
|
|
2225
|
+
model: "test-model",
|
|
2226
|
+
system: "",
|
|
2227
|
+
messages: [],
|
|
2228
|
+
tools: [],
|
|
2229
|
+
},
|
|
2230
|
+
},
|
|
2231
|
+
{
|
|
2232
|
+
type: "assistant/message",
|
|
2233
|
+
turn: 1,
|
|
2234
|
+
step: 1,
|
|
2235
|
+
requestId: "completed-request",
|
|
2236
|
+
text: "",
|
|
2237
|
+
toolCalls: [call],
|
|
2238
|
+
},
|
|
2239
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
2240
|
+
let toolExecutions = 0;
|
|
2241
|
+
let modelRequests = 0;
|
|
2242
|
+
const provider: LlmProvider = {
|
|
2243
|
+
id: "resume-tools",
|
|
2244
|
+
async *stream(request) {
|
|
2245
|
+
modelRequests += 1;
|
|
2246
|
+
expect(request.messages.at(-1)).toMatchObject({
|
|
2247
|
+
role: "tool",
|
|
2248
|
+
callId: "durable-call",
|
|
2249
|
+
content: "resumed",
|
|
2250
|
+
});
|
|
2251
|
+
yield { type: "text-delta", text: "Finished after recovery." };
|
|
2252
|
+
yield { type: "finish", reason: "completed" };
|
|
2253
|
+
},
|
|
2254
|
+
};
|
|
2255
|
+
const root = await mountRuntime(
|
|
2256
|
+
provider,
|
|
2257
|
+
{
|
|
2258
|
+
name: "echo",
|
|
2259
|
+
description: "Echo a value.",
|
|
2260
|
+
inputSchema: { type: "object" },
|
|
2261
|
+
execute: (input) => {
|
|
2262
|
+
toolExecutions += 1;
|
|
2263
|
+
return Promise.resolve({
|
|
2264
|
+
content: (input as { value: string }).value,
|
|
2265
|
+
isError: false,
|
|
2266
|
+
});
|
|
2267
|
+
},
|
|
2268
|
+
},
|
|
2269
|
+
undefined,
|
|
2270
|
+
{ "resume-tools": initial },
|
|
2271
|
+
);
|
|
2272
|
+
const handle = await root.agents.create({
|
|
2273
|
+
...allowEffectOptions,
|
|
2274
|
+
botId: "resume-bot",
|
|
2275
|
+
sessionId: "resume-tools",
|
|
2276
|
+
provider: "resume-tools",
|
|
2277
|
+
model: "test-model",
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
expect(handle.agent.session.reconcileForResume()).toEqual([]);
|
|
2281
|
+
handle.agent.resume();
|
|
2282
|
+
await handle.agent.whenIdle();
|
|
2283
|
+
|
|
2284
|
+
expect(toolExecutions).toBe(1);
|
|
2285
|
+
expect(modelRequests).toBe(1);
|
|
2286
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
2287
|
+
expect.objectContaining({
|
|
2288
|
+
type: "tool/call",
|
|
2289
|
+
occurrenceId: "tool:1:1:0",
|
|
2290
|
+
name: call.name,
|
|
2291
|
+
input: call.input,
|
|
2292
|
+
}),
|
|
2293
|
+
);
|
|
2294
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
2295
|
+
type: "turn/end",
|
|
2296
|
+
outcome: "completed",
|
|
2297
|
+
});
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
test("recovers duplicate provider call ids by durable occurrence", async () => {
|
|
2301
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
2302
|
+
const first = {
|
|
2303
|
+
id: "duplicate-provider-id",
|
|
2304
|
+
name: "echo",
|
|
2305
|
+
input: { value: "first" },
|
|
2306
|
+
};
|
|
2307
|
+
const second = {
|
|
2308
|
+
id: "duplicate-provider-id",
|
|
2309
|
+
name: "echo",
|
|
2310
|
+
input: { value: "second" },
|
|
2311
|
+
};
|
|
2312
|
+
const initial = [
|
|
2313
|
+
{ type: "session/created", createdAt: timestamp },
|
|
2314
|
+
{ type: "turn/start", turn: 1 },
|
|
2315
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
2316
|
+
{
|
|
2317
|
+
type: "model/request",
|
|
2318
|
+
turn: 1,
|
|
2319
|
+
step: 1,
|
|
2320
|
+
request: {
|
|
2321
|
+
requestId: "duplicate-request",
|
|
2322
|
+
provider: "duplicate-tools",
|
|
2323
|
+
model: "test-model",
|
|
2324
|
+
system: "",
|
|
2325
|
+
messages: [],
|
|
2326
|
+
tools: [],
|
|
2327
|
+
},
|
|
2328
|
+
},
|
|
2329
|
+
{
|
|
2330
|
+
type: "assistant/message",
|
|
2331
|
+
turn: 1,
|
|
2332
|
+
step: 1,
|
|
2333
|
+
requestId: "duplicate-request",
|
|
2334
|
+
text: "",
|
|
2335
|
+
toolCalls: [first, second],
|
|
2336
|
+
},
|
|
2337
|
+
{
|
|
2338
|
+
type: "tool/call",
|
|
2339
|
+
turn: 1,
|
|
2340
|
+
step: 1,
|
|
2341
|
+
occurrenceId: "tool:1:1:0",
|
|
2342
|
+
name: first.name,
|
|
2343
|
+
input: first.input,
|
|
2344
|
+
},
|
|
2345
|
+
{
|
|
2346
|
+
type: "tool/result",
|
|
2347
|
+
turn: 1,
|
|
2348
|
+
step: 1,
|
|
2349
|
+
occurrenceId: "tool:1:1:0",
|
|
2350
|
+
name: first.name,
|
|
2351
|
+
content: "first",
|
|
2352
|
+
isError: false,
|
|
2353
|
+
status: "completed",
|
|
2354
|
+
},
|
|
2355
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
2356
|
+
const executions: string[] = [];
|
|
2357
|
+
let followUpRequests = 0;
|
|
2358
|
+
const provider: LlmProvider = {
|
|
2359
|
+
id: "duplicate-tools",
|
|
2360
|
+
async *stream(request) {
|
|
2361
|
+
followUpRequests += 1;
|
|
2362
|
+
expect(
|
|
2363
|
+
request.messages.flatMap((message) =>
|
|
2364
|
+
message.role === "tool"
|
|
2365
|
+
? [{ callId: message.callId, content: message.content }]
|
|
2366
|
+
: [],
|
|
2367
|
+
),
|
|
2368
|
+
).toEqual([
|
|
2369
|
+
{ callId: "duplicate-provider-id", content: "first" },
|
|
2370
|
+
{ callId: "duplicate-provider-id", content: "second" },
|
|
2371
|
+
]);
|
|
2372
|
+
yield { type: "text-delta", text: "Both completed." };
|
|
2373
|
+
yield { type: "finish", reason: "completed" };
|
|
2374
|
+
},
|
|
2375
|
+
};
|
|
2376
|
+
const root = await mountRuntime(
|
|
2377
|
+
provider,
|
|
2378
|
+
{
|
|
2379
|
+
name: "echo",
|
|
2380
|
+
description: "Echo a value.",
|
|
2381
|
+
inputSchema: { type: "object" },
|
|
2382
|
+
execute(input) {
|
|
2383
|
+
const value = (input as { value: string }).value;
|
|
2384
|
+
executions.push(value);
|
|
2385
|
+
return Promise.resolve({ content: value, isError: false });
|
|
2386
|
+
},
|
|
2387
|
+
},
|
|
2388
|
+
undefined,
|
|
2389
|
+
{ "duplicate-tools": initial },
|
|
2390
|
+
);
|
|
2391
|
+
const handle = await root.agents.create({
|
|
2392
|
+
...allowEffectOptions,
|
|
2393
|
+
botId: "resume-bot",
|
|
2394
|
+
sessionId: "duplicate-tools",
|
|
2395
|
+
provider: "duplicate-tools",
|
|
2396
|
+
model: "test-model",
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
handle.agent.resume();
|
|
2400
|
+
await handle.agent.whenIdle();
|
|
2401
|
+
|
|
2402
|
+
expect(executions).toEqual(["second"]);
|
|
2403
|
+
expect(followUpRequests).toBe(1);
|
|
2404
|
+
const journal = handle.agent.session.events.filter(
|
|
2405
|
+
(event) => event.type === "tool/call" || event.type === "tool/result",
|
|
2406
|
+
);
|
|
2407
|
+
expect(journal.map((event) => event.occurrenceId)).toEqual([
|
|
2408
|
+
"tool:1:1:0",
|
|
2409
|
+
"tool:1:1:0",
|
|
2410
|
+
"tool:1:1:1",
|
|
2411
|
+
"tool:1:1:1",
|
|
2412
|
+
]);
|
|
2413
|
+
expect(JSON.stringify(journal)).not.toContain("duplicate-provider-id");
|
|
2414
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
2415
|
+
type: "turn/end",
|
|
2416
|
+
outcome: "completed",
|
|
2417
|
+
});
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
test("fails closed on a mismatched durable tool occurrence", async () => {
|
|
2421
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
2422
|
+
const call = {
|
|
2423
|
+
id: "provider-call",
|
|
2424
|
+
name: "echo",
|
|
2425
|
+
input: { value: "unsafe" },
|
|
2426
|
+
};
|
|
2427
|
+
const initial = [
|
|
2428
|
+
{ type: "session/created", createdAt: timestamp },
|
|
2429
|
+
{ type: "turn/start", turn: 1 },
|
|
2430
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
2431
|
+
{
|
|
2432
|
+
type: "model/request",
|
|
2433
|
+
turn: 1,
|
|
2434
|
+
step: 1,
|
|
2435
|
+
request: {
|
|
2436
|
+
requestId: "mismatched-request",
|
|
2437
|
+
provider: "mismatched-tools",
|
|
2438
|
+
model: "test-model",
|
|
2439
|
+
system: "",
|
|
2440
|
+
messages: [],
|
|
2441
|
+
tools: [],
|
|
2442
|
+
},
|
|
2443
|
+
},
|
|
2444
|
+
{
|
|
2445
|
+
type: "assistant/message",
|
|
2446
|
+
turn: 1,
|
|
2447
|
+
step: 1,
|
|
2448
|
+
requestId: "mismatched-request",
|
|
2449
|
+
text: "",
|
|
2450
|
+
toolCalls: [call],
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
type: "tool/call",
|
|
2454
|
+
turn: 1,
|
|
2455
|
+
step: 1,
|
|
2456
|
+
occurrenceId: "tool:1:1:1",
|
|
2457
|
+
name: call.name,
|
|
2458
|
+
input: call.input,
|
|
2459
|
+
},
|
|
2460
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
2461
|
+
let executions = 0;
|
|
2462
|
+
const provider: LlmProvider = {
|
|
2463
|
+
id: "mismatched-tools",
|
|
2464
|
+
async *stream() {
|
|
2465
|
+
throw new Error("structural mismatch must not request the model");
|
|
2466
|
+
},
|
|
2467
|
+
};
|
|
2468
|
+
const root = await mountRuntime(
|
|
2469
|
+
provider,
|
|
2470
|
+
{
|
|
2471
|
+
name: "echo",
|
|
2472
|
+
description: "Echo a value.",
|
|
2473
|
+
inputSchema: { type: "object" },
|
|
2474
|
+
execute() {
|
|
2475
|
+
executions += 1;
|
|
2476
|
+
return Promise.resolve({ content: "unsafe", isError: false });
|
|
2477
|
+
},
|
|
2478
|
+
},
|
|
2479
|
+
undefined,
|
|
2480
|
+
{ "mismatched-tools": initial },
|
|
2481
|
+
);
|
|
2482
|
+
const handle = await root.agents.create({
|
|
2483
|
+
...allowEffectOptions,
|
|
2484
|
+
botId: "resume-bot",
|
|
2485
|
+
sessionId: "mismatched-tools",
|
|
2486
|
+
provider: "mismatched-tools",
|
|
2487
|
+
model: "test-model",
|
|
2488
|
+
});
|
|
2489
|
+
|
|
2490
|
+
handle.agent.resume();
|
|
2491
|
+
await handle.agent.whenIdle();
|
|
2492
|
+
|
|
2493
|
+
expect(executions).toBe(0);
|
|
2494
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
2495
|
+
type: "turn/end",
|
|
2496
|
+
outcome: "model-error",
|
|
2497
|
+
});
|
|
2498
|
+
});
|
|
2499
|
+
|
|
2500
|
+
test("does not request another model after tool events cross step closure", async () => {
|
|
2501
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
2502
|
+
const call = {
|
|
2503
|
+
id: "provider-call",
|
|
2504
|
+
name: "echo",
|
|
2505
|
+
input: { value: "unsafe" },
|
|
2506
|
+
};
|
|
2507
|
+
const initial = [
|
|
2508
|
+
{ type: "session/created", createdAt: timestamp },
|
|
2509
|
+
{ type: "turn/start", turn: 1 },
|
|
2510
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
2511
|
+
{
|
|
2512
|
+
type: "model/request",
|
|
2513
|
+
turn: 1,
|
|
2514
|
+
step: 1,
|
|
2515
|
+
request: {
|
|
2516
|
+
requestId: "malformed-request",
|
|
2517
|
+
provider: "malformed-tools",
|
|
2518
|
+
model: "test-model",
|
|
2519
|
+
system: "",
|
|
2520
|
+
messages: [],
|
|
2521
|
+
tools: [],
|
|
2522
|
+
},
|
|
2523
|
+
},
|
|
2524
|
+
{
|
|
2525
|
+
type: "assistant/message",
|
|
2526
|
+
turn: 1,
|
|
2527
|
+
step: 1,
|
|
2528
|
+
requestId: "malformed-request",
|
|
2529
|
+
text: "",
|
|
2530
|
+
toolCalls: [call],
|
|
2531
|
+
},
|
|
2532
|
+
{
|
|
2533
|
+
type: "step/end",
|
|
2534
|
+
turn: 1,
|
|
2535
|
+
step: 1,
|
|
2536
|
+
outcome: "completed",
|
|
2537
|
+
},
|
|
2538
|
+
{
|
|
2539
|
+
type: "tool/call",
|
|
2540
|
+
turn: 1,
|
|
2541
|
+
step: 1,
|
|
2542
|
+
occurrenceId: "tool:1:1:0",
|
|
2543
|
+
name: call.name,
|
|
2544
|
+
input: call.input,
|
|
2545
|
+
},
|
|
2546
|
+
{
|
|
2547
|
+
type: "tool/result",
|
|
2548
|
+
turn: 1,
|
|
2549
|
+
step: 1,
|
|
2550
|
+
occurrenceId: "tool:1:1:0",
|
|
2551
|
+
name: call.name,
|
|
2552
|
+
content: "unsafe",
|
|
2553
|
+
isError: false,
|
|
2554
|
+
status: "completed",
|
|
2555
|
+
},
|
|
2556
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
2557
|
+
let modelRequests = 0;
|
|
2558
|
+
let toolExecutions = 0;
|
|
2559
|
+
const root = await mountRuntime(
|
|
2560
|
+
{
|
|
2561
|
+
id: "malformed-tools",
|
|
2562
|
+
async *stream() {
|
|
2563
|
+
modelRequests += 1;
|
|
2564
|
+
yield { type: "finish", reason: "completed" };
|
|
2565
|
+
},
|
|
2566
|
+
},
|
|
2567
|
+
{
|
|
2568
|
+
name: "echo",
|
|
2569
|
+
description: "Echo a value.",
|
|
2570
|
+
inputSchema: { type: "object" },
|
|
2571
|
+
execute() {
|
|
2572
|
+
toolExecutions += 1;
|
|
2573
|
+
return Promise.resolve({ content: "unsafe", isError: false });
|
|
2574
|
+
},
|
|
2575
|
+
},
|
|
2576
|
+
undefined,
|
|
2577
|
+
{ "malformed-tools": initial },
|
|
2578
|
+
);
|
|
2579
|
+
const handle = await root.agents.create({
|
|
2580
|
+
...allowEffectOptions,
|
|
2581
|
+
botId: "resume-bot",
|
|
2582
|
+
sessionId: "malformed-tools",
|
|
2583
|
+
provider: "malformed-tools",
|
|
2584
|
+
model: "test-model",
|
|
2585
|
+
});
|
|
2586
|
+
|
|
2587
|
+
handle.agent.resume();
|
|
2588
|
+
await handle.agent.whenIdle();
|
|
2589
|
+
|
|
2590
|
+
expect(modelRequests).toBe(0);
|
|
2591
|
+
expect(toolExecutions).toBe(0);
|
|
2592
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
2593
|
+
type: "turn/end",
|
|
2594
|
+
outcome: "model-error",
|
|
2595
|
+
});
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2598
|
+
test("finishes a durable text response without another model request", async () => {
|
|
2599
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
2600
|
+
const initial = [
|
|
2601
|
+
{ type: "session/created", createdAt: timestamp },
|
|
2602
|
+
{ type: "turn/start", turn: 1 },
|
|
2603
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
2604
|
+
{
|
|
2605
|
+
type: "model/request",
|
|
2606
|
+
turn: 1,
|
|
2607
|
+
step: 1,
|
|
2608
|
+
request: {
|
|
2609
|
+
requestId: "completed-text-request",
|
|
2610
|
+
provider: "resume-text",
|
|
2611
|
+
model: "test-model",
|
|
2612
|
+
system: "",
|
|
2613
|
+
messages: [],
|
|
2614
|
+
tools: [],
|
|
2615
|
+
},
|
|
2616
|
+
},
|
|
2617
|
+
{
|
|
2618
|
+
type: "assistant/message",
|
|
2619
|
+
turn: 1,
|
|
2620
|
+
step: 1,
|
|
2621
|
+
requestId: "completed-text-request",
|
|
2622
|
+
text: "Already durable.",
|
|
2623
|
+
toolCalls: [],
|
|
2624
|
+
},
|
|
2625
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
2626
|
+
const provider: LlmProvider = {
|
|
2627
|
+
id: "resume-text",
|
|
2628
|
+
async *stream() {
|
|
2629
|
+
throw new Error("resume must not create another model request");
|
|
2630
|
+
},
|
|
2631
|
+
};
|
|
2632
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
2633
|
+
"resume-text": initial,
|
|
2634
|
+
});
|
|
2635
|
+
const handle = await root.agents.create({
|
|
2636
|
+
...allowEffectOptions,
|
|
2637
|
+
botId: "resume-bot",
|
|
2638
|
+
sessionId: "resume-text",
|
|
2639
|
+
provider: "resume-text",
|
|
2640
|
+
model: "test-model",
|
|
2641
|
+
});
|
|
2642
|
+
|
|
2643
|
+
handle.agent.resume();
|
|
2644
|
+
await handle.agent.whenIdle();
|
|
2645
|
+
|
|
2646
|
+
expect(
|
|
2647
|
+
handle.agent.session.events.filter(
|
|
2648
|
+
(event) => event.type === "model/request",
|
|
2649
|
+
),
|
|
2650
|
+
).toHaveLength(1);
|
|
2651
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
2652
|
+
type: "turn/end",
|
|
2653
|
+
outcome: "completed",
|
|
2654
|
+
});
|
|
2655
|
+
});
|
|
2656
|
+
|
|
2657
|
+
test("finishes a turn without duplicating its durable step end", async () => {
|
|
2658
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
2659
|
+
const initial = [
|
|
2660
|
+
{ type: "session/created", createdAt: timestamp },
|
|
2661
|
+
{ type: "turn/start", turn: 1 },
|
|
2662
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
2663
|
+
{
|
|
2664
|
+
type: "model/request",
|
|
2665
|
+
turn: 1,
|
|
2666
|
+
step: 1,
|
|
2667
|
+
request: {
|
|
2668
|
+
requestId: "ended-text-request",
|
|
2669
|
+
provider: "resume-ended-text",
|
|
2670
|
+
model: "test-model",
|
|
2671
|
+
system: "",
|
|
2672
|
+
messages: [],
|
|
2673
|
+
tools: [],
|
|
2674
|
+
},
|
|
2675
|
+
},
|
|
2676
|
+
{
|
|
2677
|
+
type: "assistant/message",
|
|
2678
|
+
turn: 1,
|
|
2679
|
+
step: 1,
|
|
2680
|
+
requestId: "ended-text-request",
|
|
2681
|
+
text: "Already durable.",
|
|
2682
|
+
toolCalls: [],
|
|
2683
|
+
},
|
|
2684
|
+
{ type: "step/end", turn: 1, step: 1, outcome: "completed" },
|
|
2685
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
2686
|
+
const provider: LlmProvider = {
|
|
2687
|
+
id: "resume-ended-text",
|
|
2688
|
+
async *stream() {
|
|
2689
|
+
throw new Error("resume must not create another model request");
|
|
2690
|
+
},
|
|
2691
|
+
};
|
|
2692
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
2693
|
+
"resume-ended-text": initial,
|
|
2694
|
+
});
|
|
2695
|
+
const handle = await root.agents.create({
|
|
2696
|
+
...allowEffectOptions,
|
|
2697
|
+
botId: "resume-bot",
|
|
2698
|
+
sessionId: "resume-ended-text",
|
|
2699
|
+
provider: "resume-ended-text",
|
|
2700
|
+
model: "test-model",
|
|
2701
|
+
});
|
|
2702
|
+
|
|
2703
|
+
handle.agent.resume();
|
|
2704
|
+
await handle.agent.whenIdle();
|
|
2705
|
+
|
|
2706
|
+
expect(
|
|
2707
|
+
handle.agent.session.events.filter(
|
|
2708
|
+
(event) =>
|
|
2709
|
+
event.type === "step/end" && event.turn === 1 && event.step === 1,
|
|
2710
|
+
),
|
|
2711
|
+
).toHaveLength(1);
|
|
2712
|
+
expect(
|
|
2713
|
+
handle.agent.session.events.filter(
|
|
2714
|
+
(event) => event.type === "turn/end" && event.turn === 1,
|
|
2715
|
+
),
|
|
2716
|
+
).toEqual([expect.objectContaining({ outcome: "completed" })]);
|
|
2717
|
+
});
|
|
2718
|
+
|
|
2719
|
+
test("resumes inside a durable step start awaiting its model request", async () => {
|
|
2720
|
+
const timestamp = "2026-08-28T00:00:00.000Z";
|
|
2721
|
+
const call = {
|
|
2722
|
+
id: "completed-call",
|
|
2723
|
+
name: "echo",
|
|
2724
|
+
input: { value: "completed" },
|
|
2725
|
+
};
|
|
2726
|
+
const initial = [
|
|
2727
|
+
{ type: "session/created", createdAt: timestamp },
|
|
2728
|
+
{ type: "turn/start", turn: 1 },
|
|
2729
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
2730
|
+
{
|
|
2731
|
+
type: "model/request",
|
|
2732
|
+
turn: 1,
|
|
2733
|
+
step: 1,
|
|
2734
|
+
request: {
|
|
2735
|
+
requestId: "first-request",
|
|
2736
|
+
provider: "resume-open-step",
|
|
2737
|
+
model: "test-model",
|
|
2738
|
+
system: "",
|
|
2739
|
+
messages: [],
|
|
2740
|
+
tools: [],
|
|
2741
|
+
},
|
|
2742
|
+
},
|
|
2743
|
+
{
|
|
2744
|
+
type: "assistant/message",
|
|
2745
|
+
turn: 1,
|
|
2746
|
+
step: 1,
|
|
2747
|
+
requestId: "first-request",
|
|
2748
|
+
text: "",
|
|
2749
|
+
toolCalls: [call],
|
|
2750
|
+
},
|
|
2751
|
+
{
|
|
2752
|
+
type: "tool/call",
|
|
2753
|
+
turn: 1,
|
|
2754
|
+
step: 1,
|
|
2755
|
+
occurrenceId: "tool:1:1:0",
|
|
2756
|
+
name: call.name,
|
|
2757
|
+
input: call.input,
|
|
2758
|
+
},
|
|
2759
|
+
{
|
|
2760
|
+
type: "tool/result",
|
|
2761
|
+
turn: 1,
|
|
2762
|
+
step: 1,
|
|
2763
|
+
occurrenceId: "tool:1:1:0",
|
|
2764
|
+
name: call.name,
|
|
2765
|
+
content: "completed",
|
|
2766
|
+
isError: false,
|
|
2767
|
+
status: "completed",
|
|
2768
|
+
},
|
|
2769
|
+
{ type: "step/end", turn: 1, step: 1, outcome: "completed" },
|
|
2770
|
+
{ type: "step/start", turn: 1, step: 2 },
|
|
2771
|
+
].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
|
|
2772
|
+
let modelRequests = 0;
|
|
2773
|
+
const provider: LlmProvider = {
|
|
2774
|
+
id: "resume-open-step",
|
|
2775
|
+
async *stream(request) {
|
|
2776
|
+
modelRequests += 1;
|
|
2777
|
+
expect(request.requestId).toBeTruthy();
|
|
2778
|
+
yield { type: "text-delta", text: "Finished after recovery." };
|
|
2779
|
+
yield { type: "finish", reason: "completed" };
|
|
2780
|
+
},
|
|
2781
|
+
};
|
|
2782
|
+
const root = await mountRuntime(provider, undefined, undefined, {
|
|
2783
|
+
"resume-open-step": initial,
|
|
2784
|
+
});
|
|
2785
|
+
const handle = await root.agents.create({
|
|
2786
|
+
...allowEffectOptions,
|
|
2787
|
+
botId: "resume-bot",
|
|
2788
|
+
sessionId: "resume-open-step",
|
|
2789
|
+
provider: "resume-open-step",
|
|
2790
|
+
model: "test-model",
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
handle.agent.resume();
|
|
2794
|
+
await handle.agent.whenIdle();
|
|
2795
|
+
|
|
2796
|
+
expect(modelRequests).toBe(1);
|
|
2797
|
+
expect(
|
|
2798
|
+
handle.agent.session.events.filter(
|
|
2799
|
+
(event) =>
|
|
2800
|
+
event.type === "step/start" && event.turn === 1 && event.step === 2,
|
|
2801
|
+
),
|
|
2802
|
+
).toHaveLength(1);
|
|
2803
|
+
expect(handle.agent.session.events).toContainEqual(
|
|
2804
|
+
expect.objectContaining({ type: "model/request", turn: 1, step: 2 }),
|
|
2805
|
+
);
|
|
2806
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
2807
|
+
type: "turn/end",
|
|
2808
|
+
outcome: "completed",
|
|
2809
|
+
});
|
|
2810
|
+
});
|
|
2811
|
+
test("carries the provider failure reason on a model-error turn/end", async () => {
|
|
2812
|
+
const provider: LlmProvider = {
|
|
2813
|
+
id: "provider-rejects",
|
|
2814
|
+
async *stream() {
|
|
2815
|
+
throw new LlmEffectNotStartedError(
|
|
2816
|
+
"Ollama Cloud responded 401: invalid api key",
|
|
2817
|
+
);
|
|
2818
|
+
},
|
|
2819
|
+
};
|
|
2820
|
+
const root = await mountRuntime(provider);
|
|
2821
|
+
const handle = await root.agents.create({
|
|
2822
|
+
botId: "reason-bot",
|
|
2823
|
+
sessionId: "provider-rejects",
|
|
2824
|
+
provider: "provider-rejects",
|
|
2825
|
+
model: "test-model",
|
|
2826
|
+
admitEffect: allowEffect,
|
|
2827
|
+
});
|
|
2828
|
+
|
|
2829
|
+
handle.agent.send("Say hello.");
|
|
2830
|
+
await handle.agent.whenIdle();
|
|
2831
|
+
|
|
2832
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
2833
|
+
type: "turn/end",
|
|
2834
|
+
turn: 1,
|
|
2835
|
+
outcome: "model-error",
|
|
2836
|
+
reason: "Ollama Cloud responded 401: invalid api key",
|
|
2837
|
+
});
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
test("bounds a turn/end reason to what the session event contract accepts", async () => {
|
|
2841
|
+
const provider: LlmProvider = {
|
|
2842
|
+
id: "provider-verbose-failure",
|
|
2843
|
+
async *stream() {
|
|
2844
|
+
throw new LlmEffectNotStartedError("x".repeat(900));
|
|
2845
|
+
},
|
|
2846
|
+
};
|
|
2847
|
+
const root = await mountRuntime(provider);
|
|
2848
|
+
const handle = await root.agents.create({
|
|
2849
|
+
botId: "reason-bot",
|
|
2850
|
+
sessionId: "provider-verbose-failure",
|
|
2851
|
+
provider: "provider-verbose-failure",
|
|
2852
|
+
model: "test-model",
|
|
2853
|
+
admitEffect: allowEffect,
|
|
2854
|
+
});
|
|
2855
|
+
|
|
2856
|
+
handle.agent.send("Say hello.");
|
|
2857
|
+
await handle.agent.whenIdle();
|
|
2858
|
+
|
|
2859
|
+
const end = handle.agent.session.events.at(-1);
|
|
2860
|
+
expect(end?.type).toBe("turn/end");
|
|
2861
|
+
expect(end?.type === "turn/end" ? end.reason : undefined).toBe(
|
|
2862
|
+
"x".repeat(500),
|
|
2863
|
+
);
|
|
2864
|
+
expect(() => decodeSessionEvent(end)).not.toThrow();
|
|
2865
|
+
});
|
|
2866
|
+
|
|
2867
|
+
test("omits a reason from a completed turn/end", async () => {
|
|
2868
|
+
const provider: LlmProvider = {
|
|
2869
|
+
id: "provider-completes",
|
|
2870
|
+
async *stream() {
|
|
2871
|
+
yield { type: "text-delta", text: "Hello." };
|
|
2872
|
+
yield { type: "finish", reason: "completed" };
|
|
2873
|
+
},
|
|
2874
|
+
};
|
|
2875
|
+
const root = await mountRuntime(provider);
|
|
2876
|
+
const handle = await root.agents.create({
|
|
2877
|
+
botId: "reason-bot",
|
|
2878
|
+
sessionId: "provider-completes",
|
|
2879
|
+
provider: "provider-completes",
|
|
2880
|
+
model: "test-model",
|
|
2881
|
+
admitEffect: allowEffect,
|
|
2882
|
+
});
|
|
2883
|
+
|
|
2884
|
+
handle.agent.send("Say hello.");
|
|
2885
|
+
await handle.agent.whenIdle();
|
|
2886
|
+
|
|
2887
|
+
const end = handle.agent.session.events.at(-1);
|
|
2888
|
+
expect(end).toMatchObject({ type: "turn/end", outcome: "completed" });
|
|
2889
|
+
expect(end && Object.hasOwn(end, "reason")).toBe(false);
|
|
2890
|
+
});
|
|
2891
|
+
});
|