@fevex/core 0.1.0-alpha.0 → 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1110 -52
  2. package/dist/agents/index.d.mts +2 -23
  3. package/dist/agents/index.mjs +1 -1
  4. package/dist/artifacts/index.d.mts +2 -0
  5. package/dist/artifacts/index.mjs +2 -0
  6. package/dist/artifacts-gUzemET7.mjs +440 -0
  7. package/dist/channels/index.d.mts +2 -0
  8. package/dist/channels/index.mjs +192 -0
  9. package/dist/evals/index.d.mts +153 -0
  10. package/dist/evals/index.mjs +422 -0
  11. package/dist/fevex-_KOsOsB8.d.mts +129 -0
  12. package/dist/http/index.d.mts +150 -0
  13. package/dist/http/index.mjs +692 -0
  14. package/dist/index-26ilbzAs.d.mts +50 -0
  15. package/dist/index-B8EcvWeN.d.mts +103 -0
  16. package/dist/index-BEqYWean.d.mts +118 -0
  17. package/dist/index-Btth7rdL.d.mts +58 -0
  18. package/dist/index-CFzrvDQ0.d.mts +21 -0
  19. package/dist/index-CIHWc5V1.d.mts +116 -0
  20. package/dist/index-D7r0puBi.d.mts +64 -0
  21. package/dist/index-DHcfIfh-.d.mts +224 -0
  22. package/dist/index-DUhNa7IR.d.mts +73 -0
  23. package/dist/index-lCCFokc6.d.mts +617 -0
  24. package/dist/index.d.mts +39 -15
  25. package/dist/index.mjs +4962 -208
  26. package/dist/knowledge/index.d.mts +2 -0
  27. package/dist/knowledge/index.mjs +289 -0
  28. package/dist/model-stream-JQdHDDcj.mjs +131 -0
  29. package/dist/models/index.d.mts +2 -27
  30. package/dist/observability/index.d.mts +2 -0
  31. package/dist/observability/index.mjs +1 -0
  32. package/dist/policies/index.d.mts +2 -0
  33. package/dist/policies/index.mjs +6 -0
  34. package/dist/run-error-Cr17wPGH.mjs +23 -0
  35. package/dist/run-support-CmZ6Xo6H.mjs +162 -0
  36. package/dist/runtime/index.d.mts +2 -17
  37. package/dist/runtime/index.mjs +354 -1
  38. package/dist/sandbox/index.d.mts +2 -0
  39. package/dist/sandbox/index.mjs +147 -0
  40. package/dist/teams/index.d.mts +2 -0
  41. package/dist/teams/index.mjs +2 -0
  42. package/dist/teams-Ctz676G4.mjs +82 -0
  43. package/dist/testing/index.d.mts +40 -8
  44. package/dist/testing/index.mjs +668 -23
  45. package/dist/tools/index.d.mts +3 -31
  46. package/dist/tools/index.mjs +442 -2
  47. package/dist/types-CckDlhYo.d.mts +575 -0
  48. package/dist/workflows/index.d.mts +2 -0
  49. package/dist/workflows/index.mjs +6 -0
  50. package/package.json +43 -3
  51. package/dist/exact-definition-NHBBFlFR.d.mts +0 -4
  52. package/dist/index-D_RBJmVt.d.mts +0 -44
@@ -1,44 +1,158 @@
1
- //#region src/testing/index.ts
2
- function fakeModel(...responses) {
3
- const calls = [];
4
- return {
5
- calls,
6
- async generate(input) {
7
- input.signal?.throwIfAborted();
8
- const response = responses[calls.length];
9
- calls.push(input);
10
- if (!response) throw new Error(`fakeModel has no response for call ${calls.length}`);
11
- return response;
12
- }
13
- };
14
- }
15
- const contractOutputSchema = { type: "string" };
1
+ import { isRetainableRunStore } from "../runtime/index.mjs";
2
+ import { IntegrationError } from "../tools/index.mjs";
3
+ import { t as readModelStream } from "../model-stream-JQdHDDcj.mjs";
4
+ //#region src/testing/contracts.ts
5
+ const contractOutputSchema = {
6
+ type: "object",
7
+ properties: { answer: { type: "string" } },
8
+ required: ["answer"],
9
+ additionalProperties: false
10
+ };
16
11
  const contractToolInputSchema = {
17
12
  type: "object",
18
13
  properties: { query: { type: "string" } },
19
- required: ["query"]
14
+ required: ["query"],
15
+ additionalProperties: false
20
16
  };
21
17
  function assert(condition, message) {
22
18
  if (!condition) throw new TypeError(message);
23
19
  }
20
+ async function assertRejects(operation, message, code) {
21
+ try {
22
+ await operation();
23
+ } catch (error) {
24
+ if (code !== void 0) assert(error instanceof Error && "code" in error && error.code === code, message);
25
+ return;
26
+ }
27
+ throw new TypeError(message);
28
+ }
29
+ async function testMemoryStore(store) {
30
+ const actor = { id: `actor-${crypto.randomUUID()}` };
31
+ const sessionId = `session-${crypto.randomUUID()}`;
32
+ const context = {
33
+ agentName: "assistant",
34
+ input: "refund status",
35
+ sessionId,
36
+ context: {
37
+ namespace: "tenant-a",
38
+ actor
39
+ }
40
+ };
41
+ const saved = await store.write({
42
+ content: "Refund status is approved.",
43
+ agentName: "assistant",
44
+ sessionId,
45
+ namespace: "tenant-a",
46
+ actor,
47
+ metadata: { source: "contract" }
48
+ }, context);
49
+ assert(saved.id.trim(), "MemoryRecord id cannot be empty");
50
+ assert(saved.createdAt.trim(), "MemoryRecord createdAt cannot be empty");
51
+ saved.content = "mutated";
52
+ assert((await store.search({
53
+ query: "refund",
54
+ sessionId,
55
+ namespace: "tenant-a",
56
+ actor
57
+ }, context))[0]?.content === "Refund status is approved.", "MemoryStore must not expose mutable record references");
58
+ assert((await store.search({
59
+ query: "refund",
60
+ sessionId: "other"
61
+ }, context)).length === 0, "MemoryStore must isolate sessions");
62
+ assert((await store.search({
63
+ query: "refund",
64
+ namespace: "tenant-b"
65
+ }, context)).length === 0, "MemoryStore must isolate namespaces");
66
+ assert((await store.search({
67
+ query: "refund",
68
+ actor: { id: "other" }
69
+ }, context)).length === 0, "MemoryStore must isolate actors");
70
+ await store.write({
71
+ content: "Refund backup note.",
72
+ sessionId,
73
+ namespace: "tenant-a",
74
+ actor
75
+ }, context);
76
+ assert((await store.search({
77
+ query: "refund",
78
+ sessionId,
79
+ namespace: "tenant-a",
80
+ actor,
81
+ limit: 1
82
+ }, context)).length === 1, "MemoryStore must respect limits");
83
+ const controller = new AbortController();
84
+ controller.abort();
85
+ await store.search({ query: "refund" }, {
86
+ ...context,
87
+ signal: controller.signal
88
+ }).then(() => {
89
+ throw new TypeError("MemoryStore must reject aborted searches");
90
+ }, () => {});
91
+ }
92
+ async function testToolProvider(provider, contract) {
93
+ const tools = await provider.listTools({});
94
+ assert(tools.some((tool) => tool.name === contract.allowedTool), "ToolProvider must list the allowed contract tool");
95
+ if (contract.disallowedTool !== void 0) assert(!tools.some((tool) => tool.name === contract.disallowedTool), "ToolProvider must not list disallowed tools");
96
+ const output = await provider.callTool(contract.allowedTool, contract.input ?? { query: "value" }, {});
97
+ assert(JSON.stringify(output) === JSON.stringify(contract.output ?? { answer: "ok" }), "ToolProvider returned an unexpected output");
98
+ if (contract.safeError) await Promise.resolve(provider.callTool("contract_error", {}, {})).then(() => {
99
+ throw new TypeError("ToolProvider must reject configured error calls");
100
+ }, (error) => {
101
+ assert(error instanceof IntegrationError, "ToolProvider errors must be IntegrationError");
102
+ assert(error.message === error.safeMessage, "IntegrationError message must be safe");
103
+ });
104
+ if (contract.timeout) {
105
+ const controller = new AbortController();
106
+ controller.abort();
107
+ await Promise.resolve(provider.callTool(contract.allowedTool, {}, { signal: controller.signal })).then(() => {
108
+ throw new TypeError("ToolProvider must reject aborted calls");
109
+ }, () => {});
110
+ }
111
+ }
112
+ async function testChannelAdapter(adapter, contract) {
113
+ assert(adapter.name.trim(), "ChannelAdapter name cannot be empty");
114
+ const message = await adapter.parse(contract.input, {});
115
+ assert(message !== null, "ChannelAdapter must parse the contract input");
116
+ for (const [name, value] of [
117
+ ["id", message.id],
118
+ ["deliveryId", message.deliveryId],
119
+ ["conversationId", message.conversationId],
120
+ ["content", message.content]
121
+ ]) assert(value.trim(), `ChannelMessage ${name} cannot be empty`);
122
+ if (message.threadId !== void 0) assert(message.threadId.trim(), "ChannelMessage threadId cannot be empty");
123
+ if (message.actor !== void 0) assert(message.actor.id.trim(), "ChannelMessage actor id cannot be empty");
124
+ if (message.metadata !== void 0) JSON.stringify(message.metadata);
125
+ assert(JSON.stringify(message) === JSON.stringify(contract.message), "ChannelAdapter returned an unexpected message");
126
+ if (contract.ignoredInput !== void 0) assert(await adapter.parse(contract.ignoredInput, {}) === null, "ChannelAdapter must ignore the configured input");
127
+ if (contract.output.threadId !== void 0) assert(contract.output.threadId.trim(), "ChannelOutput threadId cannot be empty");
128
+ if (contract.output.metadata !== void 0) JSON.stringify(contract.output.metadata);
129
+ assert(JSON.stringify(await adapter.deliver(contract.output, {})) === JSON.stringify(contract.delivered), "ChannelAdapter returned an unexpected delivery");
130
+ if (contract.elicitation) {
131
+ assert(adapter.deliverElicitation, "ChannelAdapter must support elicitation delivery");
132
+ for (let attempt = 0; attempt < 2; attempt += 1) assert(JSON.stringify(await adapter.deliverElicitation(structuredClone(contract.elicitation.request), { ...contract.elicitation.context })) === JSON.stringify(contract.elicitation.delivered), "ChannelAdapter returned an unexpected elicitation delivery or retry receipt");
133
+ }
134
+ }
24
135
  async function testModelGateway(model, contract = {}) {
25
- const output = contract.output ?? "ok";
136
+ const output = contract.output ?? { answer: "ok" };
26
137
  const toolCall = contract.toolCall ?? {
27
138
  id: "call-1",
28
139
  name: "lookup",
29
140
  input: { query: "value" }
30
141
  };
31
- const outputResult = await model.generate({
142
+ const outputStream = readModelStream(model, {
32
143
  messages: [{
33
144
  role: "user",
34
145
  content: "Return a final answer."
35
146
  }],
36
147
  outputSchema: contractOutputSchema
37
148
  });
149
+ const firstOutput = await outputStream.next();
150
+ assert(!firstOutput.done && firstOutput.value.length > 0, "ModelGateway must stream an output delta before completed");
151
+ const outputResult = await collectModelResult(outputStream);
38
152
  assert(outputResult.output !== void 0, "ModelGateway must return output for a final answer");
39
- assert(outputResult.output === output, "ModelGateway returned an unexpected output");
153
+ assert(JSON.stringify(outputResult.output) === JSON.stringify(output), "ModelGateway returned an unexpected output");
40
154
  if (contract.usage) assert(outputResult.usage !== void 0, "ModelGateway must return usage when the contract requires it");
41
- const toolResult = await model.generate({
155
+ const toolResult = await collectModelResult(model, {
42
156
  messages: [{
43
157
  role: "user",
44
158
  content: "Call lookup."
@@ -52,9 +166,14 @@ async function testModelGateway(model, contract = {}) {
52
166
  assert(toolResult.toolCalls?.length === 1, "ModelGateway must return one tool call");
53
167
  assert(toolResult.toolCalls[0]?.id === toolCall.id, "ModelGateway returned an unexpected tool call id");
54
168
  assert(toolResult.toolCalls[0]?.name === toolCall.name, "ModelGateway returned an unexpected tool name");
169
+ if (model.stateCodec && toolResult.providerState !== void 0) {
170
+ const serialized = model.stateCodec.serialize(toolResult.providerState);
171
+ const restored = model.stateCodec.restore(structuredClone(serialized));
172
+ assert(JSON.stringify(model.stateCodec.serialize(restored)) === JSON.stringify(serialized), "ModelGateway stateCodec must preserve provider state across a JSON round-trip");
173
+ }
55
174
  const controller = new AbortController();
56
175
  controller.abort();
57
- await model.generate({
176
+ await collectModelResult(model, {
58
177
  messages: [{
59
178
  role: "user",
60
179
  content: "This call is aborted."
@@ -63,7 +182,7 @@ async function testModelGateway(model, contract = {}) {
63
182
  }).then(() => {
64
183
  throw new TypeError("ModelGateway must reject aborted calls");
65
184
  }, () => {});
66
- if (contract.error) await model.generate({ messages: [{
185
+ if (contract.error) await collectModelResult(model, { messages: [{
67
186
  role: "user",
68
187
  content: "Propagate an error."
69
188
  }] }).then(() => {
@@ -72,5 +191,531 @@ async function testModelGateway(model, contract = {}) {
72
191
  assert(error === contract.error, "ModelGateway must preserve provider error identity");
73
192
  });
74
193
  }
194
+ async function collectModelResult(modelOrStream, input) {
195
+ const stream = "stream" in modelOrStream ? readModelStream(modelOrStream, input) : modelOrStream;
196
+ while (true) {
197
+ const next = await stream.next();
198
+ if (next.done) return next.value;
199
+ }
200
+ }
201
+ async function testRunStore(store) {
202
+ const suffix = crypto.randomUUID();
203
+ const runId = `contract-run-${suffix}`;
204
+ const sessionId = `contract-session-${suffix}`;
205
+ const now = (/* @__PURE__ */ new Date()).toISOString();
206
+ const atomicRunId = `contract-atomic-run-${suffix}`;
207
+ const atomicSessionId = `contract-atomic-session-${suffix}`;
208
+ const atomicRun = {
209
+ id: atomicRunId,
210
+ sessionId: atomicSessionId,
211
+ agentName: "contract-agent",
212
+ status: "running",
213
+ revision: 0,
214
+ createdAt: now,
215
+ updatedAt: now
216
+ };
217
+ const atomicSession = {
218
+ id: atomicSessionId,
219
+ history: [],
220
+ createdAt: now,
221
+ updatedAt: now
222
+ };
223
+ const atomicCheckpoint = {
224
+ version: 2,
225
+ runId: atomicRunId,
226
+ definitionHash: "atomic-definition",
227
+ messages: [{
228
+ role: "user",
229
+ content: "atomic input"
230
+ }],
231
+ inputContent: "atomic input",
232
+ step: 1,
233
+ toolCallCount: 0,
234
+ seenToolCallIds: [],
235
+ pendingTools: [],
236
+ pendingIndex: 0
237
+ };
238
+ const atomicLease = {
239
+ generation: 0,
240
+ runId: atomicRunId,
241
+ ownerId: `atomic-owner-${suffix}`,
242
+ expiresAt: new Date(Date.now() + 3e4).toISOString()
243
+ };
244
+ const atomicStarted = {
245
+ id: `atomic-event-${suffix}`,
246
+ sequence: 1,
247
+ type: "run.started",
248
+ runId: atomicRunId,
249
+ timestamp: now
250
+ };
251
+ assert(await store.createExecution({
252
+ run: atomicRun,
253
+ session: atomicSession,
254
+ checkpoint: atomicCheckpoint,
255
+ lease: atomicLease,
256
+ events: [atomicStarted]
257
+ }), "RunStore must atomically create a new execution");
258
+ assert(atomicRun.revision === 1 && (await store.getRun(atomicRunId))?.revision === 1 && (await store.getSession(atomicSessionId))?.id === atomicSessionId && (await store.getCheckpoint(atomicRunId))?.version === 2 && (await store.listEvents(atomicRunId))[0]?.id === atomicStarted.id, "RunStore createExecution must include run, session, checkpoint, lease, and started event");
259
+ assert(!await store.acquireLease({
260
+ ...atomicLease,
261
+ ownerId: `atomic-competitor-${suffix}`
262
+ }), "RunStore createExecution must acquire the initial lease");
263
+ assert(!await store.createExecution({
264
+ run: {
265
+ ...atomicRun,
266
+ revision: 0
267
+ },
268
+ session: await store.getSession(atomicRun.sessionId),
269
+ checkpoint: atomicCheckpoint,
270
+ lease: atomicLease,
271
+ events: [atomicStarted]
272
+ }), "RunStore createExecution must reject duplicate run ids");
273
+ const ownedSession = await store.getSession(atomicRun.sessionId);
274
+ const competitorId = `competitor-${suffix}`;
275
+ const competitor = () => ({
276
+ run: {
277
+ ...atomicRun,
278
+ id: competitorId,
279
+ revision: 0,
280
+ status: "running"
281
+ },
282
+ session: structuredClone(ownedSession),
283
+ checkpoint: {
284
+ ...atomicCheckpoint,
285
+ runId: competitorId
286
+ },
287
+ lease: {
288
+ ...atomicLease,
289
+ runId: competitorId
290
+ },
291
+ events: []
292
+ });
293
+ assert(!await store.createExecution(competitor()), "Active sessions must reject another run");
294
+ await assertRejects(() => store.saveSession(structuredClone(ownedSession)), "Compaction must reject an active session");
295
+ assert(await store.commitExecution({
296
+ lease: atomicLease,
297
+ expectedRevision: atomicRun.revision,
298
+ run: Object.assign(atomicRun, { status: "paused" })
299
+ }), "Owner must be able to pause");
300
+ await store.releaseLease(atomicRunId, atomicLease.ownerId, atomicLease.generation);
301
+ assert(!await store.createExecution(competitor()), "Paused sessions remain reserved without a worker lease");
302
+ await assertRejects(() => store.saveSession(structuredClone(ownedSession)), "Compaction must reject a paused session");
303
+ assert(await store.acquireLease(atomicLease), "Resume must acquire a new lease generation");
304
+ assert(await store.commitExecution({
305
+ lease: atomicLease,
306
+ expectedRevision: atomicRun.revision,
307
+ run: Object.assign(atomicRun, { status: "completed" }),
308
+ session: ownedSession
309
+ }), "Terminal commit must release the session");
310
+ const staleSession = structuredClone(ownedSession);
311
+ await store.saveSession(ownedSession);
312
+ await assertRejects(() => store.saveSession(staleSession), "Stale compaction must not overwrite history");
313
+ assert(!await store.createExecution({
314
+ ...competitor(),
315
+ session: staleSession
316
+ }), "Run creation must reject history read before compaction");
317
+ const next = competitor();
318
+ assert(await store.createExecution(next), "Terminal sessions can be reserved again");
319
+ assert(!await store.commitExecution({
320
+ lease: next.lease,
321
+ expectedRevision: next.run.revision,
322
+ run: next.run,
323
+ session: staleSession
324
+ }), "A stale history commit must fail atomically");
325
+ assert((await store.getRun(next.run.id))?.revision === next.run.revision, "Rejected history commits must not advance the run");
326
+ const session = {
327
+ id: sessionId,
328
+ history: [],
329
+ createdAt: now,
330
+ updatedAt: now
331
+ };
332
+ const run = {
333
+ id: runId,
334
+ sessionId,
335
+ agentName: "contract-agent",
336
+ status: "running",
337
+ revision: 0,
338
+ createdAt: now,
339
+ updatedAt: now
340
+ };
341
+ await store.saveSession(session);
342
+ await store.saveRun(run);
343
+ const commitLease = {
344
+ generation: 0,
345
+ runId,
346
+ ownerId: `writer-${suffix}`,
347
+ expiresAt: new Date(Date.now() + 3e4).toISOString()
348
+ };
349
+ assert(await store.acquireLease(commitLease), "Writer must acquire a lease");
350
+ const snapshot = await store.getRun(runId);
351
+ assert(snapshot?.status === "running", "RunStore must return saved runs");
352
+ snapshot.status = "failed";
353
+ assert((await store.getRun(runId))?.status === "running", "RunStore must not expose mutable run references");
354
+ await store.saveRun({
355
+ ...run,
356
+ status: "completed"
357
+ });
358
+ assert((await store.getRun(runId))?.status === "completed", "RunStore must overwrite an existing run on save");
359
+ await store.saveRun(run);
360
+ const firstEvent = {
361
+ id: `event-1-${suffix}`,
362
+ sequence: 1,
363
+ type: "run.started",
364
+ runId,
365
+ timestamp: now
366
+ };
367
+ await store.appendEvent(firstEvent);
368
+ const checkpoint = {
369
+ version: 2,
370
+ runId,
371
+ definitionHash: "definition",
372
+ messages: [{
373
+ role: "user",
374
+ content: "input"
375
+ }],
376
+ inputContent: "input",
377
+ step: 1,
378
+ toolCallCount: 0,
379
+ seenToolCallIds: ["tool-call"],
380
+ pendingTools: [{
381
+ call: {
382
+ id: "tool-call",
383
+ name: "lookup",
384
+ input: { query: "value" }
385
+ },
386
+ input: { query: "value" },
387
+ idempotencyKey: `key-${suffix}`,
388
+ attempt: 1
389
+ }],
390
+ pendingIndex: 0
391
+ };
392
+ const toolExecution = {
393
+ runId,
394
+ toolCallId: "tool-call",
395
+ toolName: "lookup",
396
+ input: { query: "value" },
397
+ status: "completed",
398
+ attempt: 1,
399
+ idempotencyKey: `key-${suffix}`,
400
+ output: { answer: "ok" },
401
+ updatedAt: now
402
+ };
403
+ session.history.push({
404
+ role: "system",
405
+ content: "committed"
406
+ });
407
+ const secondEvent = {
408
+ id: `event-2-${suffix}`,
409
+ sequence: 2,
410
+ type: "tool.completed",
411
+ runId,
412
+ timestamp: now,
413
+ payload: {
414
+ step: 1,
415
+ toolCallId: "tool-call",
416
+ toolName: "lookup"
417
+ }
418
+ };
419
+ assert(await store.commitExecution({
420
+ lease: commitLease,
421
+ expectedRevision: 0,
422
+ run,
423
+ session,
424
+ checkpoint,
425
+ toolExecution,
426
+ events: [secondEvent]
427
+ }), "RunStore must commit a matching revision");
428
+ assert(run.revision === 1, "RunStore must advance the committed run revision");
429
+ assert((await store.getSession(sessionId))?.history.length === 1 && (await store.getCheckpoint(runId))?.definitionHash === "definition" && (await store.getToolExecution(runId, "tool-call"))?.status === "completed", "RunStore atomic commits must include session, checkpoint, and tool ledger");
430
+ assert(!await store.commitExecution({
431
+ lease: commitLease,
432
+ expectedRevision: 0,
433
+ run: {
434
+ ...run,
435
+ status: "failed"
436
+ },
437
+ events: [{
438
+ id: `stale-event-${suffix}`,
439
+ sequence: 3,
440
+ type: "run.failed",
441
+ runId,
442
+ timestamp: now,
443
+ payload: { error: "stale" }
444
+ }]
445
+ }), "RunStore must reject stale revisions");
446
+ const firstPage = await store.listEvents(runId, { limit: 1 });
447
+ assert(firstPage.length === 1 && firstPage[0]?.id === firstEvent.id, "Event pagination must honor limit and start at the first sequence");
448
+ assert((await store.listEvents(runId, {
449
+ after: firstEvent.id,
450
+ limit: 1
451
+ }))[0]?.id === secondEvent.id, "Event pagination must continue after the cursor");
452
+ assert((await store.listEvents(runId, {
453
+ after: secondEvent.id,
454
+ limit: 1
455
+ })).length === 0, "Event pagination after the last event must be empty");
456
+ const latestPage = await store.listEvents(runId, {
457
+ order: "desc",
458
+ limit: 1
459
+ });
460
+ assert(latestPage.length === 1 && latestPage[0]?.id === secondEvent.id, "Descending pagination must honor limit and find the latest event");
461
+ const descending = await store.listEvents(runId, {
462
+ order: "desc",
463
+ limit: 2
464
+ });
465
+ assert(descending.length === 2 && descending[0]?.id === secondEvent.id && descending[1]?.id === firstEvent.id, "Event pagination must order by sequence");
466
+ const descendingAfter = await store.listEvents(runId, {
467
+ after: firstEvent.id,
468
+ order: "desc",
469
+ limit: 2
470
+ });
471
+ assert(descendingAfter.length === 1 && descendingAfter[0]?.id === secondEvent.id, "Event cursors must select strictly newer sequences even in descending order");
472
+ await assertRejects(() => store.listEvents(runId, {
473
+ after: atomicStarted.id,
474
+ limit: 1
475
+ }), "Event pagination must reject a cursor from another run with INVALID_CURSOR", "INVALID_CURSOR");
476
+ for (const limit of [
477
+ 0,
478
+ -1,
479
+ NaN,
480
+ Infinity,
481
+ 1.5
482
+ ]) await assertRejects(() => store.listEvents(runId, { limit }), "Event pagination must validate limits");
483
+ const events = await store.listEvents(runId, { after: firstEvent.id });
484
+ assert(events.length === 1 && events[0]?.id === secondEvent.id, "RunStore cursors must return stable ordered suffixes");
485
+ await assertRejects(() => store.listEvents(`missing-run-${suffix}`), "RunStore listEvents must reject unknown runs");
486
+ await assertRejects(() => store.listEvents(runId, { after: `missing-cursor-${suffix}` }), "RunStore listEvents must reject unknown cursors with INVALID_CURSOR", "INVALID_CURSOR");
487
+ assert(await store.commitExecution({
488
+ lease: commitLease,
489
+ expectedRevision: 1,
490
+ run,
491
+ checkpoint: null
492
+ }), "RunStore must commit a checkpoint deletion");
493
+ assert(await store.getCheckpoint(runId) === void 0, "RunStore must delete the checkpoint when a commit sets it to null");
494
+ assert((await store.getToolExecution(runId, "tool-call"))?.status === "completed", "RunStore must keep the tool ledger when a checkpoint is deleted");
495
+ await store.releaseLease(runId, commitLease.ownerId, commitLease.generation);
496
+ const lease1 = {
497
+ generation: 0,
498
+ runId,
499
+ ownerId: `owner-1-${suffix}`,
500
+ expiresAt: new Date(Date.now() + 3e4).toISOString()
501
+ };
502
+ const lease2 = {
503
+ generation: 0,
504
+ runId,
505
+ ownerId: `owner-2-${suffix}`,
506
+ expiresAt: new Date(Date.now() + 3e4).toISOString()
507
+ };
508
+ assert(await store.acquireLease(lease1), "RunStore must acquire a free lease");
509
+ assert(!await store.acquireLease(lease2), "RunStore must reject a competing lease");
510
+ assert(!await store.renewLease(lease2), "RunStore must reject renewal by another owner");
511
+ assert(await store.renewLease(lease1), "RunStore must renew a matching lease");
512
+ await store.releaseLease(runId, lease1.ownerId, lease1.generation);
513
+ assert(await store.acquireLease(lease2), "RunStore must release a lease for another owner");
514
+ await store.releaseLease(runId, lease2.ownerId, lease2.generation);
515
+ assert(!await store.renewLease(lease1), "RunStore must reject renewal of a released lease");
516
+ const expiredLease = {
517
+ generation: 0,
518
+ runId,
519
+ ownerId: `owner-3-${suffix}`,
520
+ expiresAt: (/* @__PURE__ */ new Date(Date.now() - 1e3)).toISOString()
521
+ };
522
+ const takeoverLease = {
523
+ generation: 0,
524
+ runId,
525
+ ownerId: `owner-4-${suffix}`,
526
+ expiresAt: new Date(Date.now() + 3e4).toISOString()
527
+ };
528
+ assert(await store.acquireLease(expiredLease), "RunStore must acquire a free lease");
529
+ const beforeTakeover = await store.getRun(runId);
530
+ assert(!await store.commitExecution({
531
+ lease: expiredLease,
532
+ expectedRevision: beforeTakeover.revision,
533
+ run: {
534
+ ...beforeTakeover,
535
+ status: "failed"
536
+ }
537
+ }), "An expired lease must not commit even before takeover");
538
+ assert(!await store.renewLease({
539
+ ...expiredLease,
540
+ expiresAt: takeoverLease.expiresAt
541
+ }), "An expired lease cannot be revived by renewal");
542
+ assert(await store.acquireLease(takeoverLease), "RunStore must let another owner take over an expired lease");
543
+ await store.releaseLease(runId, takeoverLease.ownerId, takeoverLease.generation);
544
+ const oldToken = { ...takeoverLease };
545
+ assert(await store.acquireLease(takeoverLease), "A released owner can acquire a new generation");
546
+ assert(takeoverLease.generation > oldToken.generation, "Generation must increase even for the same owner");
547
+ const currentRun = await store.getRun(runId);
548
+ const currentSession = await store.getSession(sessionId);
549
+ const currentEvents = await store.listEvents(runId);
550
+ const currentTool = await store.getToolExecution(runId, "tool-call");
551
+ assert(currentRun.revision === beforeTakeover.revision, "Takeover must be tested before revision changes");
552
+ const staleCommit = {
553
+ lease: oldToken,
554
+ expectedRevision: currentRun.revision,
555
+ run: {
556
+ ...currentRun,
557
+ status: "failed"
558
+ },
559
+ session: {
560
+ ...currentSession,
561
+ history: []
562
+ },
563
+ checkpoint,
564
+ toolExecution: {
565
+ ...toolExecution,
566
+ output: "stale"
567
+ },
568
+ events: [{
569
+ ...firstEvent,
570
+ id: `stale-owner-${suffix}`,
571
+ sequence: 3
572
+ }]
573
+ };
574
+ assert(!await store.commitExecution(staleCommit), "A previous generation must not commit with a still-current run revision");
575
+ assert(!await store.commitExecution({
576
+ ...staleCommit,
577
+ lease: {
578
+ ...takeoverLease,
579
+ ownerId: "wrong-owner"
580
+ }
581
+ }), "Generation alone must not authorize a commit");
582
+ assert(!await store.renewLease({
583
+ ...oldToken,
584
+ expiresAt: takeoverLease.expiresAt
585
+ }), "A stale generation must not renew a new lease owned by the same owner");
586
+ await store.releaseLease(runId, oldToken.ownerId, oldToken.generation);
587
+ assert(await store.renewLease(takeoverLease), "A stale release must not revoke the new generation");
588
+ assert(JSON.stringify(await store.getRun(runId)) === JSON.stringify(currentRun), "Rejected lease commits must not change the run");
589
+ assert(JSON.stringify(await store.getSession(sessionId)) === JSON.stringify(currentSession), "Rejected lease commits must not change history");
590
+ assert(JSON.stringify(await store.listEvents(runId)) === JSON.stringify(currentEvents), "Rejected lease commits must not append events");
591
+ assert(JSON.stringify(await store.getToolExecution(runId, "tool-call")) === JSON.stringify(currentTool), "Rejected lease commits must not change the tool ledger");
592
+ assert(await store.getCheckpoint(runId) === void 0, "Rejected lease commits must not create checkpoints");
593
+ assert(await store.commitExecution({
594
+ lease: takeoverLease,
595
+ expectedRevision: currentRun.revision,
596
+ run: {
597
+ ...currentRun,
598
+ status: "completed"
599
+ }
600
+ }), "The current generation must be able to commit");
601
+ await store.releaseLease(runId, takeoverLease.ownerId, takeoverLease.generation);
602
+ }
603
+ async function testRetentionStore(store) {
604
+ assert(isRetainableRunStore(store), "RunStore must implement purgeRuns");
605
+ const suffix = crypto.randomUUID();
606
+ const now = Date.now();
607
+ const old = (/* @__PURE__ */ new Date(now - 2 * 864e5)).toISOString();
608
+ const before = (/* @__PURE__ */ new Date(now - 864e5)).toISOString();
609
+ const recent = new Date(now).toISOString();
610
+ const entries = [
611
+ {
612
+ id: `retention-old-completed-${suffix}`,
613
+ status: "completed",
614
+ createdAt: old
615
+ },
616
+ {
617
+ id: `retention-old-failed-${suffix}`,
618
+ status: "failed",
619
+ createdAt: old
620
+ },
621
+ {
622
+ id: `retention-recent-completed-${suffix}`,
623
+ status: "completed",
624
+ createdAt: recent
625
+ },
626
+ {
627
+ id: `retention-old-running-${suffix}`,
628
+ status: "running",
629
+ createdAt: old
630
+ }
631
+ ];
632
+ for (const entry of entries) {
633
+ const sessionId = `retention-session-${entry.id}`;
634
+ await store.saveSession({
635
+ id: sessionId,
636
+ history: [],
637
+ createdAt: entry.createdAt,
638
+ updatedAt: entry.createdAt
639
+ });
640
+ await store.saveRun({
641
+ id: entry.id,
642
+ sessionId,
643
+ agentName: "retention-agent",
644
+ status: entry.status,
645
+ revision: 1,
646
+ createdAt: entry.createdAt,
647
+ updatedAt: entry.createdAt
648
+ });
649
+ await store.appendEvent({
650
+ id: `retention-event-${entry.id}`,
651
+ sequence: 1,
652
+ type: "run.started",
653
+ runId: entry.id,
654
+ timestamp: entry.createdAt
655
+ });
656
+ }
657
+ const purged = await store.purgeRuns({ before });
658
+ assert(purged.runs === 2, "Retention must delete only old terminal runs");
659
+ assert(purged.sessions === 2, "Retention must delete sessions left without runs");
660
+ assert(!await store.getRun(entries[0].id), "Retention must delete the completed run");
661
+ assert(!await store.getRun(entries[1].id), "Retention must delete the failed run");
662
+ assert(await store.getRun(entries[2].id), "Retention must keep recent terminal runs");
663
+ assert(await store.getRun(entries[3].id), "Retention must keep active runs");
664
+ assert(!await store.getSession(`retention-session-${entries[0].id}`), "Retention must delete empty sessions");
665
+ assert(await store.getSession(`retention-session-${entries[2].id}`), "Retention must keep sessions with runs");
666
+ assert((await store.purgeRuns({ limit: 1 })).runs === 1, "Retention must honor the limit");
667
+ await store.purgeRuns({ before: new Date(Date.now() + 864e5).toISOString() });
668
+ assert(!await store.getRun(entries[2].id), "Retention must purge remaining terminal runs");
669
+ assert(await store.getRun(entries[3].id), "Retention must keep active runs after a full purge");
670
+ const boundaryId = `retention-boundary-${suffix}`;
671
+ const boundarySessionId = `retention-boundary-session-${suffix}`;
672
+ const boundaryCreatedAt = "2020-01-01T00:00:00.100Z";
673
+ await store.saveSession({
674
+ id: boundarySessionId,
675
+ history: [],
676
+ createdAt: boundaryCreatedAt,
677
+ updatedAt: boundaryCreatedAt
678
+ });
679
+ await store.saveRun({
680
+ id: boundaryId,
681
+ sessionId: boundarySessionId,
682
+ agentName: "retention-agent",
683
+ status: "completed",
684
+ revision: 1,
685
+ createdAt: boundaryCreatedAt,
686
+ updatedAt: boundaryCreatedAt
687
+ });
688
+ assert((await store.purgeRuns({
689
+ before: "2020-01-01T00:00:00.500Z",
690
+ statuses: ["completed"]
691
+ })).runs === 1, "Retention must compare subsecond timestamps");
692
+ assert(!await store.getRun(boundaryId), "Retention must delete the boundary run");
693
+ }
694
+ //#endregion
695
+ //#region src/testing/fake-model.ts
696
+ /** Creates a deterministic model gateway for unit tests. */
697
+ function fakeModel(...responses) {
698
+ const calls = [];
699
+ return {
700
+ calls,
701
+ async *stream(input) {
702
+ input.signal?.throwIfAborted();
703
+ const response = responses[calls.length];
704
+ calls.push(input);
705
+ if (!response) throw new Error(`fakeModel has no response for call ${calls.length}`);
706
+ if (response.output !== void 0) {
707
+ const delta = typeof response.output === "string" ? response.output : JSON.stringify(response.output);
708
+ if (delta) yield {
709
+ type: "output.delta",
710
+ delta
711
+ };
712
+ }
713
+ yield {
714
+ type: "completed",
715
+ result: response
716
+ };
717
+ }
718
+ };
719
+ }
75
720
  //#endregion
76
- export { fakeModel, testModelGateway };
721
+ export { fakeModel, testChannelAdapter, testMemoryStore, testModelGateway, testRetentionStore, testRunStore, testToolProvider };