@intx/hub-sessions 0.1.2 → 0.3.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.
Files changed (113) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +82 -1
  3. package/dist/agent-repo.d.ts +93 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +189 -0
  7. package/dist/asset-service.d.ts +104 -0
  8. package/dist/asset-service.js +267 -0
  9. package/dist/committed-source-tree.d.ts +10 -0
  10. package/dist/committed-source-tree.js +35 -0
  11. package/dist/credential-push.d.ts +33 -0
  12. package/dist/credential-push.js +109 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +365 -0
  17. package/dist/hub-session-lookups.d.ts +135 -0
  18. package/dist/hub-session-lookups.js +663 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +87 -0
  21. package/dist/index.d.ts +27 -0
  22. package/dist/index.js +24 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1829 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +56 -0
  30. package/dist/repo-store/subscribe-kind.js +144 -0
  31. package/dist/repo-store/types.d.ts +571 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +416 -0
  34. package/dist/session-service.js +1191 -0
  35. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  36. package/dist/sidecar-allocation/contracts.js +21 -0
  37. package/dist/sidecar-allocation/index.d.ts +4 -0
  38. package/dist/sidecar-allocation/index.js +3 -0
  39. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  40. package/dist/sidecar-allocation/placement-policy.js +21 -0
  41. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  42. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  43. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  44. package/dist/sidecar-allocation/reconciler.js +431 -0
  45. package/dist/skill-kind.d.ts +41 -0
  46. package/dist/skill-kind.js +292 -0
  47. package/dist/substrate.d.ts +8 -0
  48. package/dist/substrate.js +21 -0
  49. package/dist/workflow-allocation-service.d.ts +58 -0
  50. package/dist/workflow-allocation-service.js +239 -0
  51. package/dist/workflow-closure-resolution.d.ts +106 -0
  52. package/dist/workflow-closure-resolution.js +123 -0
  53. package/dist/workflow-definition-ensure.d.ts +24 -0
  54. package/dist/workflow-definition-ensure.js +75 -0
  55. package/dist/workflow-dispatch-service.d.ts +40 -0
  56. package/dist/workflow-dispatch-service.js +146 -0
  57. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  58. package/dist/workflow-dispatch-settlement.js +140 -0
  59. package/dist/workflow-kind.d.ts +37 -0
  60. package/dist/workflow-kind.js +310 -0
  61. package/dist/workflow-probe-gate.d.ts +214 -0
  62. package/dist/workflow-probe-gate.js +207 -0
  63. package/dist/workflow-run-event-log.d.ts +21 -0
  64. package/dist/workflow-run-event-log.js +51 -0
  65. package/dist/workflow-run-kind.d.ts +440 -0
  66. package/dist/workflow-run-kind.js +2916 -0
  67. package/dist/workflow-run-reader.d.ts +47 -0
  68. package/dist/workflow-run-reader.js +153 -0
  69. package/dist/workflow-run-restore.d.ts +15 -0
  70. package/dist/workflow-run-restore.js +26 -0
  71. package/dist/workflow-source-closure.d.ts +35 -0
  72. package/dist/workflow-source-closure.js +342 -0
  73. package/dist/ws/index.d.ts +3 -0
  74. package/dist/ws/index.js +3 -0
  75. package/dist/ws/sidecar-events.d.ts +222 -0
  76. package/dist/ws/sidecar-events.js +72 -0
  77. package/dist/ws/sidecar-handler.d.ts +305 -0
  78. package/dist/ws/sidecar-handler.js +2537 -0
  79. package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
  80. package/dist/ws/sidecar-token-authenticator.js +81 -0
  81. package/package.json +35 -12
  82. package/src/agent-repo.test.ts +0 -310
  83. package/src/agent-repo.ts +0 -165
  84. package/src/agent-state-kind.test.ts +0 -247
  85. package/src/agent-state-kind.ts +0 -204
  86. package/src/asset-service.test.ts +0 -540
  87. package/src/asset-service.ts +0 -378
  88. package/src/available-skills-stanza.test.ts +0 -87
  89. package/src/available-skills-stanza.ts +0 -47
  90. package/src/credential-push.ts +0 -65
  91. package/src/event-collector-registry.test.ts +0 -73
  92. package/src/event-collector-registry.ts +0 -171
  93. package/src/event-collector.test.ts +0 -1387
  94. package/src/event-collector.ts +0 -424
  95. package/src/hub-session-lookups.ts +0 -206
  96. package/src/hub-session-orchestrator.test.ts +0 -510
  97. package/src/hub-session-orchestrator.ts +0 -213
  98. package/src/index.ts +0 -78
  99. package/src/repo-store/index.ts +0 -15
  100. package/src/repo-store/store.test.ts +0 -1169
  101. package/src/repo-store/store.ts +0 -428
  102. package/src/repo-store/types.ts +0 -253
  103. package/src/session-service.test.ts +0 -895
  104. package/src/session-service.ts +0 -464
  105. package/src/skill-kind.test.ts +0 -599
  106. package/src/skill-kind.ts +0 -350
  107. package/src/ws/index.ts +0 -18
  108. package/src/ws/sidecar-events.test.ts +0 -96
  109. package/src/ws/sidecar-events.ts +0 -231
  110. package/src/ws/sidecar-handler.test.ts +0 -2217
  111. package/src/ws/sidecar-handler.ts +0 -1574
  112. package/tsconfig.json +0 -4
  113. package/tsconfig.tsbuildinfo +0 -1
@@ -1,1387 +0,0 @@
1
- import { describe, test, expect, beforeEach } from "bun:test";
2
- import { inferenceTurn, turnPart } from "@intx/db/schema";
3
- import type { InferenceEvent } from "@intx/types/runtime";
4
-
5
- import {
6
- createEventCollector,
7
- type EventCollector,
8
- type TurnFinalized,
9
- } from "./event-collector";
10
- import { createEventCollectorRegistry } from "./event-collector-registry";
11
-
12
- // ---------------------------------------------------------------------------
13
- // Test helpers: fake DB that records insert/update calls
14
- // ---------------------------------------------------------------------------
15
-
16
- type InsertCall = {
17
- table: "inference_turn" | "turn_part";
18
- values: Record<string, unknown>;
19
- };
20
-
21
- type UpdateCall = {
22
- table: "inference_turn" | "turn_part";
23
- set: Record<string, unknown>;
24
- };
25
-
26
- function createFakeDB() {
27
- const inserts: InsertCall[] = [];
28
- const updates: UpdateCall[] = [];
29
-
30
- function tableName(table: unknown): "inference_turn" | "turn_part" {
31
- if (table === inferenceTurn) return "inference_turn";
32
- if (table === turnPart) return "turn_part";
33
- throw new Error(`Unexpected table: ${String(table)}`);
34
- }
35
-
36
- const db = {
37
- insert(table: unknown) {
38
- const name = tableName(table);
39
- return {
40
- values(vals: Record<string, unknown>) {
41
- inserts.push({ table: name, values: vals });
42
- return Promise.resolve();
43
- },
44
- };
45
- },
46
- update(table: unknown) {
47
- const name = tableName(table);
48
- return {
49
- set(vals: Record<string, unknown>) {
50
- return {
51
- where(_condition: unknown) {
52
- updates.push({ table: name, set: vals });
53
- return Promise.resolve();
54
- },
55
- };
56
- },
57
- };
58
- },
59
- };
60
-
61
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- fake DB satisfies the shape required by event-collector at runtime
62
- return { db: db as never, inserts, updates };
63
- }
64
-
65
- function event(type: string, seq: number, data: unknown): InferenceEvent {
66
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test helper: type string is the correct discriminant, validated by the test setup
67
- return { type, seq, data } as InferenceEvent;
68
- }
69
-
70
- function at<T>(arr: T[], index: number): T {
71
- const item = arr[index];
72
- if (item === undefined) {
73
- throw new Error(`Expected element at index ${index}`);
74
- }
75
- return item;
76
- }
77
-
78
- // ---------------------------------------------------------------------------
79
- // Tests
80
- // ---------------------------------------------------------------------------
81
-
82
- describe("EventCollector", () => {
83
- let fakeDB: ReturnType<typeof createFakeDB>;
84
- let collector: EventCollector;
85
-
86
- beforeEach(() => {
87
- fakeDB = createFakeDB();
88
- collector = createEventCollector({
89
- db: fakeDB.db,
90
- sessionId: "ses_test",
91
- instanceId: "ins_test",
92
- tenantId: "tnt_test",
93
- });
94
- });
95
-
96
- test("inference.start creates an inference_turn row and step-start part", async () => {
97
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
98
-
99
- const turns = fakeDB.inserts.filter((i) => i.table === "inference_turn");
100
- expect(turns).toHaveLength(1);
101
- const insert = at(turns, 0);
102
- expect(insert.values.model).toBe("gpt-4");
103
- expect(insert.values.status).toBe("running");
104
- expect(insert.values.sessionId).toBe("ses_test");
105
- expect(insert.values.tenantId).toBe("tnt_test");
106
- expect(insert.values.startedAt).toBeInstanceOf(Date);
107
-
108
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
109
- expect(parts).toHaveLength(1);
110
- expect(at(parts, 0).values.type).toBe("step-start");
111
- expect(at(parts, 0).values.metadata).toEqual({ model: "gpt-4" });
112
- });
113
-
114
- test("reactor.start alone does not create a turn", async () => {
115
- await collector.onEvent(event("reactor.start", 1, {}));
116
-
117
- expect(fakeDB.inserts).toHaveLength(0);
118
- });
119
-
120
- test("inference.done inserts text, reasoning, and tool-call parts", async () => {
121
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
122
- await collector.onEvent(
123
- event("inference.done", 5, {
124
- turn: {
125
- role: "assistant",
126
- content: [
127
- { type: "text", text: "Hello world" },
128
- { type: "thinking", thinking: "Let me think..." },
129
- {
130
- type: "tool_call",
131
- id: "call_1",
132
- name: "search",
133
- arguments: { query: "test" },
134
- },
135
- ],
136
- model: "gpt-4",
137
- },
138
- usage: { input: 10, output: 20 },
139
- }),
140
- );
141
-
142
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
143
- // step-start + text + reasoning + tool_call + step-finish = 5 parts
144
- expect(parts).toHaveLength(5);
145
-
146
- expect(at(parts, 0).values.type).toBe("step-start");
147
-
148
- expect(at(parts, 1).values.type).toBe("text");
149
- expect(at(parts, 1).values.content).toBe("Hello world");
150
-
151
- expect(at(parts, 2).values.type).toBe("reasoning");
152
- expect(at(parts, 2).values.content).toBe("Let me think...");
153
-
154
- expect(at(parts, 3).values.type).toBe("tool");
155
- expect(at(parts, 3).values.metadata).toEqual({
156
- kind: "call",
157
- callId: "call_1",
158
- name: "search",
159
- arguments: { query: "test" },
160
- });
161
-
162
- expect(at(parts, 4).values.type).toBe("step-finish");
163
- });
164
-
165
- test("refusal content blocks insert a refusal part carrying the reason", async () => {
166
- // RefusalBlock landed in the ContentBlock union as part of the
167
- // structured-outputs work; the collector's switch must handle
168
- // it explicitly or the refusal text silently disappears from
169
- // session persistence. Pin a dedicated `refusal` part kind so
170
- // session readers can distinguish a policy decline from
171
- // ordinary assistant text or an HTTP error.
172
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
173
- await collector.onEvent(
174
- event("inference.done", 5, {
175
- turn: {
176
- role: "assistant",
177
- content: [
178
- { type: "text", text: "Let me see..." },
179
- { type: "refusal", reason: "I cannot help with that." },
180
- ],
181
- model: "gpt-4",
182
- timestamp: 1234,
183
- },
184
- usage: { input: 10, output: 20 },
185
- }),
186
- );
187
-
188
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
189
- // step-start + text + refusal + step-finish = 4 parts
190
- expect(parts).toHaveLength(4);
191
- expect(at(parts, 1).values.type).toBe("text");
192
- expect(at(parts, 1).values.content).toBe("Let me see...");
193
- expect(at(parts, 2).values.type).toBe("refusal");
194
- expect(at(parts, 2).values.content).toBe("I cannot help with that.");
195
- });
196
-
197
- test("media content blocks insert a file part keyed by source kind", async () => {
198
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
199
- await collector.onEvent(
200
- event("inference.done", 5, {
201
- turn: {
202
- role: "assistant",
203
- content: [
204
- {
205
- type: "image",
206
- source: {
207
- kind: "base64",
208
- mimeType: "image/png",
209
- data: "aGVsbG8=",
210
- },
211
- },
212
- {
213
- type: "image",
214
- source: {
215
- kind: "file-reference",
216
- mimeType: "application/pdf",
217
- reference: "file_abc123",
218
- },
219
- },
220
- {
221
- type: "audio",
222
- source: {
223
- kind: "base64",
224
- mimeType: "audio/wav",
225
- data: "UklGRg==",
226
- },
227
- },
228
- {
229
- type: "video",
230
- source: {
231
- kind: "file-reference",
232
- mimeType: "video/mp4",
233
- reference: "file_video",
234
- },
235
- },
236
- {
237
- type: "document",
238
- source: {
239
- kind: "file-reference",
240
- mimeType: "application/pdf",
241
- reference: "file_report",
242
- },
243
- },
244
- {
245
- type: "image",
246
- source: {
247
- kind: "url",
248
- mimeType: "image/jpeg",
249
- url: "https://example.com/photo.jpg",
250
- },
251
- },
252
- ],
253
- model: "gpt-4",
254
- },
255
- usage: { input: 10, output: 5 },
256
- }),
257
- );
258
-
259
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
260
- // step-start + 6 media parts + step-finish = 8 parts
261
- expect(parts).toHaveLength(8);
262
-
263
- expect(at(parts, 1).values.type).toBe("file");
264
- expect(at(parts, 1).values.metadata).toEqual({
265
- kind: "base64",
266
- mimeType: "image/png",
267
- dataLength: 8,
268
- });
269
-
270
- expect(at(parts, 2).values.type).toBe("file");
271
- expect(at(parts, 2).values.metadata).toEqual({
272
- kind: "file-reference",
273
- mimeType: "application/pdf",
274
- reference: "file_abc123",
275
- });
276
-
277
- expect(at(parts, 3).values.type).toBe("file");
278
- expect(at(parts, 3).values.metadata).toEqual({
279
- kind: "base64",
280
- mimeType: "audio/wav",
281
- dataLength: 8,
282
- });
283
-
284
- expect(at(parts, 4).values.type).toBe("file");
285
- expect(at(parts, 4).values.metadata).toEqual({
286
- kind: "file-reference",
287
- mimeType: "video/mp4",
288
- reference: "file_video",
289
- });
290
-
291
- expect(at(parts, 5).values.type).toBe("file");
292
- expect(at(parts, 5).values.metadata).toEqual({
293
- kind: "file-reference",
294
- mimeType: "application/pdf",
295
- reference: "file_report",
296
- });
297
-
298
- expect(at(parts, 6).values.type).toBe("file");
299
- expect(at(parts, 6).values.metadata).toEqual({
300
- kind: "url",
301
- mimeType: "image/jpeg",
302
- url: "https://example.com/photo.jpg",
303
- });
304
- });
305
-
306
- test("tool.done inserts a tool result part", async () => {
307
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
308
- await collector.onEvent(
309
- event("tool.done", 3, {
310
- result: {
311
- callId: "call_1",
312
- content: "Result text",
313
- isError: false,
314
- },
315
- }),
316
- );
317
-
318
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
319
- // step-start from inference.start + tool result = 2 parts
320
- expect(parts).toHaveLength(2);
321
- expect(at(parts, 0).values.type).toBe("step-start");
322
- expect(at(parts, 1).values.type).toBe("tool");
323
- expect(at(parts, 1).values.metadata).toEqual({
324
- kind: "result",
325
- callId: "call_1",
326
- content: "Result text",
327
- isError: false,
328
- });
329
- });
330
-
331
- test("reactor.done marks turn as completed with endedAt", async () => {
332
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
333
- await collector.onEvent(event("reactor.done", 10, {}));
334
-
335
- expect(fakeDB.updates).toHaveLength(1);
336
- expect(at(fakeDB.updates, 0).table).toBe("inference_turn");
337
- expect(at(fakeDB.updates, 0).set.status).toBe("completed");
338
- expect(at(fakeDB.updates, 0).set.endedAt).toBeInstanceOf(Date);
339
- });
340
-
341
- test("reactor.error with fatal=true marks turn as failed", async () => {
342
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
343
- await collector.onEvent(
344
- event("reactor.error", 10, { error: "boom", fatal: true }),
345
- );
346
-
347
- expect(fakeDB.updates).toHaveLength(1);
348
- expect(at(fakeDB.updates, 0).table).toBe("inference_turn");
349
- expect(at(fakeDB.updates, 0).set.status).toBe("failed");
350
- expect(at(fakeDB.updates, 0).set.endedAt).toBeInstanceOf(Date);
351
- });
352
-
353
- test("reactor.error with fatal=false does not update status", async () => {
354
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
355
- await collector.onEvent(
356
- event("reactor.error", 10, { error: "transient", fatal: false }),
357
- );
358
-
359
- expect(fakeDB.updates).toHaveLength(0);
360
- });
361
-
362
- test("parts before inference.start are dropped", async () => {
363
- await collector.onEvent(
364
- event("inference.done", 5, {
365
- turn: {
366
- role: "assistant",
367
- content: [{ type: "text", text: "orphan" }],
368
- model: "gpt-4",
369
- },
370
- usage: { input: 1, output: 1 },
371
- }),
372
- );
373
-
374
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
375
- expect(parts).toHaveLength(0);
376
- });
377
-
378
- test("ordinals increment correctly across parts", async () => {
379
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
380
- await collector.onEvent(
381
- event("inference.done", 5, {
382
- turn: {
383
- role: "assistant",
384
- content: [
385
- { type: "text", text: "hello" },
386
- { type: "thinking", thinking: "hmm" },
387
- ],
388
- model: "gpt-4",
389
- },
390
- usage: { input: 1, output: 1 },
391
- }),
392
- );
393
-
394
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
395
- const ordinals = parts.map((p) => p.values.ordinal);
396
- // step-start (0), text (1), reasoning (2), step-finish (3)
397
- expect(ordinals).toEqual([0, 1, 2, 3]);
398
- });
399
-
400
- test("abandon marks pending turn as failed", async () => {
401
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
402
- await collector.abandon();
403
-
404
- expect(fakeDB.updates).toHaveLength(1);
405
- expect(at(fakeDB.updates, 0).table).toBe("inference_turn");
406
- expect(at(fakeDB.updates, 0).set.status).toBe("failed");
407
- expect(at(fakeDB.updates, 0).set.endedAt).toBeInstanceOf(Date);
408
- });
409
-
410
- test("abandon with no active turn is a no-op", async () => {
411
- await collector.abandon();
412
- expect(fakeDB.updates).toHaveLength(0);
413
- });
414
-
415
- test("tool_result content blocks are skipped to avoid duplication", async () => {
416
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
417
- await collector.onEvent(
418
- event("inference.done", 5, {
419
- turn: {
420
- role: "assistant",
421
- content: [
422
- {
423
- type: "tool_result",
424
- callId: "call_1",
425
- content: "already persisted",
426
- },
427
- { type: "text", text: "final answer" },
428
- ],
429
- model: "gpt-4",
430
- },
431
- usage: { input: 1, output: 1 },
432
- }),
433
- );
434
-
435
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
436
- const types = parts.map((p) => p.values.type);
437
- expect(types).toEqual(["step-start", "text", "step-finish"]);
438
- });
439
-
440
- test("streaming deltas are not persisted", async () => {
441
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
442
- await collector.onEvent(
443
- event("inference.text.delta", 2, {
444
- token: "hi",
445
- partial: { text: "hi" },
446
- }),
447
- );
448
- await collector.onEvent(
449
- event("inference.thinking.delta", 3, {
450
- token: "hmm",
451
- partial: { text: "hi", thinking: "hmm" },
452
- }),
453
- );
454
- await collector.onEvent(
455
- event("inference.usage", 4, {
456
- usage: { input: 10, output: 5 },
457
- }),
458
- );
459
-
460
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
461
- // Only the step-start from inference.start; deltas are not persisted
462
- expect(parts).toHaveLength(1);
463
- expect(at(parts, 0).values.type).toBe("step-start");
464
- });
465
-
466
- test("full reactor cycle produces correct sequence with per-turn rows", async () => {
467
- // Simulate: inference turn 1 (text + tool call) ->
468
- // tool result -> inference turn 2 (text) -> reactor.done
469
- //
470
- // Each inference.start creates a new turn. The second inference.start
471
- // finalizes the first turn as failed (via the orphan-guard path) and
472
- // starts a fresh one.
473
-
474
- // Turn 1
475
- await collector.onEvent(event("inference.start", 2, { model: "claude-3" }));
476
- await collector.onEvent(
477
- event("inference.done", 5, {
478
- turn: {
479
- role: "assistant",
480
- content: [
481
- { type: "text", text: "Let me search" },
482
- {
483
- type: "tool_call",
484
- id: "call_1",
485
- name: "search",
486
- arguments: { q: "test" },
487
- },
488
- ],
489
- model: "claude-3",
490
- },
491
- usage: { input: 10, output: 20 },
492
- }),
493
- );
494
-
495
- // Tool execution
496
- await collector.onEvent(
497
- event("tool.done", 7, {
498
- result: { callId: "call_1", content: "found: test data" },
499
- }),
500
- );
501
-
502
- // Turn 2 — inference.start finalizes turn 1 as completed
503
- await collector.onEvent(event("inference.start", 8, { model: "claude-3" }));
504
- await collector.onEvent(
505
- event("inference.done", 12, {
506
- turn: {
507
- role: "assistant",
508
- content: [{ type: "text", text: "Here are the results" }],
509
- model: "claude-3",
510
- },
511
- usage: { input: 30, output: 15 },
512
- }),
513
- );
514
-
515
- await collector.onEvent(event("reactor.done", 13, {}));
516
-
517
- // Two turn rows created (one per inference.start)
518
- const turns = fakeDB.inserts.filter((i) => i.table === "inference_turn");
519
- expect(turns).toHaveLength(2);
520
-
521
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
522
- const types = parts.map((p) => p.values.type);
523
- expect(types).toEqual([
524
- "step-start", // inference turn 1 start
525
- "text", // "Let me search"
526
- "tool", // tool call
527
- "step-finish", // inference turn 1 end
528
- "tool", // tool result
529
- "step-start", // inference turn 2 start
530
- "text", // "Here are the results"
531
- "step-finish", // inference turn 2 end
532
- ]);
533
-
534
- // Both turns finalized as completed
535
- expect(fakeDB.updates).toHaveLength(2);
536
- expect(at(fakeDB.updates, 0).set.status).toBe("completed");
537
- expect(at(fakeDB.updates, 1).set.status).toBe("completed");
538
- });
539
-
540
- test("second inference.start finalizes first turn as completed", async () => {
541
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
542
- await collector.onEvent(event("inference.start", 5, { model: "gpt-4" }));
543
-
544
- // Two turn rows created
545
- const turns = fakeDB.inserts.filter((i) => i.table === "inference_turn");
546
- expect(turns).toHaveLength(2);
547
-
548
- // First turn finalized as completed (superseded by next inference step)
549
- expect(fakeDB.updates).toHaveLength(1);
550
- expect(at(fakeDB.updates, 0).set.status).toBe("completed");
551
- });
552
-
553
- test("abandon after reactor.done is a no-op", async () => {
554
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
555
- await collector.onEvent(event("reactor.done", 5, {}));
556
- await collector.abandon();
557
-
558
- // Only one update from reactor.done, not two
559
- expect(fakeDB.updates).toHaveLength(1);
560
- expect(at(fakeDB.updates, 0).set.status).toBe("completed");
561
- });
562
-
563
- test("fatal reactor.error with no active turn creates a turn and inserts error part", async () => {
564
- await collector.onEvent(
565
- event("reactor.error", 1, {
566
- error: "context store unavailable",
567
- fatal: true,
568
- }),
569
- );
570
-
571
- const turns = fakeDB.inserts.filter((i) => i.table === "inference_turn");
572
- expect(turns).toHaveLength(1);
573
- expect(at(turns, 0).values.model).toBe("unknown");
574
- expect(at(turns, 0).values.status).toBe("running");
575
-
576
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
577
- expect(parts).toHaveLength(1);
578
- expect(at(parts, 0).values.type).toBe("error");
579
- expect(at(parts, 0).values.content).toBe("context store unavailable");
580
- expect(at(parts, 0).values.metadata).toEqual({ category: "reactor_error" });
581
-
582
- expect(fakeDB.updates).toHaveLength(1);
583
- expect(at(fakeDB.updates, 0).table).toBe("inference_turn");
584
- expect(at(fakeDB.updates, 0).set.status).toBe("failed");
585
- });
586
-
587
- test("fatal reactor.error inserts error part before finalizing", async () => {
588
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
589
- await collector.onEvent(
590
- event("reactor.error", 5, { error: "fatal failure", fatal: true }),
591
- );
592
-
593
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
594
- const types = parts.map((p) => p.values.type);
595
- expect(types).toContain("error");
596
-
597
- const errorPart = parts.find((p) => p.values.type === "error");
598
- if (errorPart === undefined) throw new Error("Expected an error part");
599
- expect(errorPart.values.content).toBe("fatal failure");
600
- expect(errorPart.values.metadata).toEqual({ category: "reactor_error" });
601
-
602
- expect(fakeDB.updates).toHaveLength(1);
603
- expect(at(fakeDB.updates, 0).set.status).toBe("failed");
604
- });
605
-
606
- test("inference.error persists error part and connector.reply persists text", async () => {
607
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
608
- await collector.onEvent(
609
- event("inference.error", 3, {
610
- error: {
611
- message: "rate limit exceeded",
612
- category: "rate_limit",
613
- statusCode: 429,
614
- },
615
- }),
616
- );
617
- await collector.onEvent(
618
- event("connector.reply", 4, { content: "I encountered an error." }),
619
- );
620
-
621
- const parts = fakeDB.inserts.filter((i) => i.table === "turn_part");
622
- const types = parts.map((p) => p.values.type);
623
- expect(types).toEqual(["step-start", "error", "text"]);
624
-
625
- expect(at(parts, 1).values.content).toBe("rate limit exceeded");
626
- expect(at(parts, 1).values.metadata).toEqual({
627
- category: "rate_limit",
628
- statusCode: 429,
629
- });
630
- expect(at(parts, 2).values.content).toBe("I encountered an error.");
631
-
632
- expect(fakeDB.updates).toHaveLength(1);
633
- expect(at(fakeDB.updates, 0).set.status).toBe("failed");
634
- });
635
-
636
- describe("onTurnFinalized callback", () => {
637
- let notifications: TurnFinalized[];
638
- let notifyCollector: EventCollector;
639
-
640
- beforeEach(() => {
641
- notifications = [];
642
- notifyCollector = createEventCollector({
643
- db: fakeDB.db,
644
- sessionId: "ses_test",
645
- instanceId: "ins_test",
646
- tenantId: "tnt_test",
647
- onTurnFinalized: (turn) => notifications.push(turn),
648
- });
649
- });
650
-
651
- test("reactor.done fires callback with accumulated text and hadReply false", async () => {
652
- await notifyCollector.onEvent(
653
- event("inference.start", 1, { model: "gpt-4" }),
654
- );
655
- await notifyCollector.onEvent(
656
- event("inference.done", 5, {
657
- turn: {
658
- role: "assistant",
659
- content: [{ type: "text", text: "Hello world" }],
660
- model: "gpt-4",
661
- },
662
- usage: { input: 10, output: 20 },
663
- }),
664
- );
665
- await notifyCollector.onEvent(event("reactor.done", 10, {}));
666
-
667
- expect(notifications).toHaveLength(1);
668
- expect(at(notifications, 0).status).toBe("completed");
669
- expect(at(notifications, 0).text).toBe("Hello world");
670
- expect(at(notifications, 0).hadReply).toBe(false);
671
- expect(at(notifications, 0).hadError).toBe(false);
672
- });
673
-
674
- test("callback excludes thinking blocks from text", async () => {
675
- await notifyCollector.onEvent(
676
- event("inference.start", 1, { model: "gpt-4" }),
677
- );
678
- await notifyCollector.onEvent(
679
- event("inference.done", 5, {
680
- turn: {
681
- role: "assistant",
682
- content: [
683
- { type: "thinking", thinking: "Let me think..." },
684
- { type: "text", text: "The answer" },
685
- ],
686
- model: "gpt-4",
687
- },
688
- usage: { input: 10, output: 20 },
689
- }),
690
- );
691
- await notifyCollector.onEvent(event("reactor.done", 10, {}));
692
-
693
- expect(notifications).toHaveLength(1);
694
- expect(at(notifications, 0).text).toBe("The answer");
695
- });
696
-
697
- test("multi-step tool loop fires callback with hadReply false for each turn", async () => {
698
- await notifyCollector.onEvent(
699
- event("inference.start", 1, { model: "gpt-4" }),
700
- );
701
- await notifyCollector.onEvent(
702
- event("inference.done", 3, {
703
- turn: {
704
- role: "assistant",
705
- content: [{ type: "text", text: "Searching" }],
706
- model: "gpt-4",
707
- },
708
- usage: { input: 10, output: 5 },
709
- }),
710
- );
711
- // Second inference.start finalizes the first turn via orphan guard
712
- await notifyCollector.onEvent(
713
- event("inference.start", 5, { model: "gpt-4" }),
714
- );
715
- await notifyCollector.onEvent(
716
- event("inference.done", 8, {
717
- turn: {
718
- role: "assistant",
719
- content: [{ type: "text", text: "Results" }],
720
- model: "gpt-4",
721
- },
722
- usage: { input: 20, output: 10 },
723
- }),
724
- );
725
- await notifyCollector.onEvent(event("reactor.done", 10, {}));
726
-
727
- expect(notifications).toHaveLength(2);
728
- expect(at(notifications, 0).text).toBe("Searching");
729
- expect(at(notifications, 0).hadReply).toBe(false);
730
- expect(at(notifications, 1).text).toBe("Results");
731
- expect(at(notifications, 1).hadReply).toBe(false);
732
- });
733
-
734
- test("abandon does not fire callback", async () => {
735
- await notifyCollector.onEvent(
736
- event("inference.start", 1, { model: "gpt-4" }),
737
- );
738
- await notifyCollector.onEvent(
739
- event("inference.done", 5, {
740
- turn: {
741
- role: "assistant",
742
- content: [{ type: "text", text: "Some text" }],
743
- model: "gpt-4",
744
- },
745
- usage: { input: 10, output: 20 },
746
- }),
747
- );
748
- await notifyCollector.abandon();
749
-
750
- expect(notifications).toHaveLength(0);
751
- });
752
-
753
- test("connector.reply sets hadReply true and hadError true on error path", async () => {
754
- await notifyCollector.onEvent(
755
- event("inference.start", 1, { model: "gpt-4" }),
756
- );
757
- await notifyCollector.onEvent(
758
- event("inference.error", 3, {
759
- error: { message: "rate limit", category: "rate_limit" },
760
- }),
761
- );
762
- await notifyCollector.onEvent(
763
- event("connector.reply", 4, { content: "I encountered an error." }),
764
- );
765
-
766
- expect(notifications).toHaveLength(1);
767
- expect(at(notifications, 0).status).toBe("failed");
768
- expect(at(notifications, 0).text).toBe("I encountered an error.");
769
- expect(at(notifications, 0).hadReply).toBe(true);
770
- expect(at(notifications, 0).hadError).toBe(true);
771
- });
772
-
773
- test("tool-only turn with no text fires callback with empty text", async () => {
774
- await notifyCollector.onEvent(
775
- event("inference.start", 1, { model: "gpt-4" }),
776
- );
777
- await notifyCollector.onEvent(
778
- event("inference.done", 3, {
779
- turn: {
780
- role: "assistant",
781
- content: [
782
- {
783
- type: "tool_call",
784
- id: "call_1",
785
- name: "search",
786
- arguments: { q: "test" },
787
- },
788
- ],
789
- model: "gpt-4",
790
- },
791
- usage: { input: 10, output: 5 },
792
- }),
793
- );
794
- await notifyCollector.onEvent(event("reactor.done", 10, {}));
795
-
796
- expect(notifications).toHaveLength(1);
797
- expect(at(notifications, 0).text).toBe("");
798
- });
799
-
800
- test("fatal reactor.error fires callback with failed status and hadReply false", async () => {
801
- await notifyCollector.onEvent(
802
- event("inference.start", 1, { model: "gpt-4" }),
803
- );
804
- await notifyCollector.onEvent(
805
- event("reactor.error", 5, { error: "boom", fatal: true }),
806
- );
807
-
808
- expect(notifications).toHaveLength(1);
809
- expect(at(notifications, 0).status).toBe("failed");
810
- expect(at(notifications, 0).hadReply).toBe(false);
811
- });
812
-
813
- test("fatal reactor.error with no active turn includes error in TurnFinalized", async () => {
814
- await notifyCollector.onEvent(
815
- event("reactor.error", 1, {
816
- error: "context store unavailable",
817
- fatal: true,
818
- }),
819
- );
820
-
821
- expect(notifications).toHaveLength(1);
822
- expect(at(notifications, 0).status).toBe("failed");
823
- expect(at(notifications, 0).errors).toEqual([
824
- { category: "reactor_error", message: "context store unavailable" },
825
- ]);
826
- });
827
-
828
- test("TurnFinalized includes accumulated errors with hadReply true", async () => {
829
- await notifyCollector.onEvent(
830
- event("inference.start", 1, { model: "gpt-4" }),
831
- );
832
- await notifyCollector.onEvent(
833
- event("inference.error", 3, {
834
- error: { message: "rate limit exceeded", category: "rate_limit" },
835
- }),
836
- );
837
- await notifyCollector.onEvent(
838
- event("connector.reply", 4, { content: "I hit a rate limit." }),
839
- );
840
-
841
- expect(notifications).toHaveLength(1);
842
- expect(at(notifications, 0).hadReply).toBe(true);
843
- expect(at(notifications, 0).errors).toEqual([
844
- { category: "rate_limit", message: "rate limit exceeded" },
845
- ]);
846
- });
847
-
848
- test("TurnFinalized includes tool errors from failed tool results", async () => {
849
- await notifyCollector.onEvent(
850
- event("inference.start", 1, { model: "gpt-4" }),
851
- );
852
- await notifyCollector.onEvent(
853
- event("inference.done", 3, {
854
- turn: {
855
- role: "assistant",
856
- content: [
857
- {
858
- type: "tool_call",
859
- id: "call_1",
860
- name: "file_read",
861
- arguments: { path: "/etc/shadow" },
862
- },
863
- ],
864
- model: "gpt-4",
865
- },
866
- usage: { input: 10, output: 5 },
867
- }),
868
- );
869
- await notifyCollector.onEvent(
870
- event("tool.done", 5, {
871
- result: {
872
- callId: "call_1",
873
- content: "Permission denied",
874
- isError: true,
875
- },
876
- }),
877
- );
878
- await notifyCollector.onEvent(event("reactor.done", 10, {}));
879
-
880
- expect(notifications).toHaveLength(1);
881
- expect(at(notifications, 0).toolErrors).toEqual([
882
- { name: "file_read", content: "Permission denied" },
883
- ]);
884
- });
885
-
886
- test("TurnFinalized includes non-fatal reactor errors", async () => {
887
- await notifyCollector.onEvent(
888
- event("inference.start", 1, { model: "gpt-4" }),
889
- );
890
- await notifyCollector.onEvent(
891
- event("reactor.error", 3, {
892
- error: "checkpoint failed",
893
- fatal: false,
894
- }),
895
- );
896
- await notifyCollector.onEvent(
897
- event("inference.done", 5, {
898
- turn: {
899
- role: "assistant",
900
- content: [{ type: "text", text: "Continuing" }],
901
- model: "gpt-4",
902
- },
903
- usage: { input: 5, output: 5 },
904
- }),
905
- );
906
- await notifyCollector.onEvent(event("reactor.done", 10, {}));
907
-
908
- expect(notifications).toHaveLength(1);
909
- expect(at(notifications, 0).errors).toEqual([
910
- { category: "reactor_error", message: "checkpoint failed" },
911
- ]);
912
- expect(at(notifications, 0).hadError).toBe(true);
913
- });
914
-
915
- test("non-fatal reactor.error with no active turn creates a turn and surfaces error", async () => {
916
- await notifyCollector.onEvent(
917
- event("reactor.error", 1, {
918
- error: "checkpoint hook failed",
919
- fatal: false,
920
- }),
921
- );
922
- // The non-fatal error created a synthetic turn. The next
923
- // inference.start auto-finalizes it before starting a new turn.
924
- await notifyCollector.onEvent(
925
- event("inference.start", 5, { model: "gpt-4" }),
926
- );
927
-
928
- expect(notifications).toHaveLength(1);
929
- expect(at(notifications, 0).status).toBe("completed");
930
- expect(at(notifications, 0).hadError).toBe(true);
931
- expect(at(notifications, 0).errors).toEqual([
932
- { category: "reactor_error", message: "checkpoint hook failed" },
933
- ]);
934
- });
935
-
936
- test("TurnFinalized errors array is empty when no errors occurred", async () => {
937
- await notifyCollector.onEvent(
938
- event("inference.start", 1, { model: "gpt-4" }),
939
- );
940
- await notifyCollector.onEvent(
941
- event("inference.done", 5, {
942
- turn: {
943
- role: "assistant",
944
- content: [{ type: "text", text: "All good" }],
945
- model: "gpt-4",
946
- },
947
- usage: { input: 5, output: 5 },
948
- }),
949
- );
950
- await notifyCollector.onEvent(event("reactor.done", 10, {}));
951
-
952
- expect(notifications).toHaveLength(1);
953
- expect(at(notifications, 0).errors).toEqual([]);
954
- });
955
- });
956
-
957
- describe("getAccumulatedText", () => {
958
- test("returns empty string before any events", () => {
959
- expect(collector.getAccumulatedText()).toBe("");
960
- });
961
-
962
- test("returns accumulated text after inference.done events with text blocks", async () => {
963
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
964
- await collector.onEvent(
965
- event("inference.done", 5, {
966
- turn: {
967
- role: "assistant",
968
- content: [{ type: "text", text: "Hello " }],
969
- model: "gpt-4",
970
- },
971
- usage: { input: 10, output: 5 },
972
- }),
973
- );
974
- await collector.onEvent(
975
- event("inference.done", 6, {
976
- turn: {
977
- role: "assistant",
978
- content: [{ type: "text", text: "world" }],
979
- model: "gpt-4",
980
- },
981
- usage: { input: 10, output: 5 },
982
- }),
983
- );
984
-
985
- expect(collector.getAccumulatedText()).toBe("Hello world");
986
- });
987
-
988
- test("resets to empty string after a new inference.start event", async () => {
989
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
990
- await collector.onEvent(
991
- event("inference.done", 5, {
992
- turn: {
993
- role: "assistant",
994
- content: [{ type: "text", text: "First turn text" }],
995
- model: "gpt-4",
996
- },
997
- usage: { input: 10, output: 5 },
998
- }),
999
- );
1000
- expect(collector.getAccumulatedText()).toBe("First turn text");
1001
-
1002
- await collector.onEvent(event("inference.start", 8, { model: "gpt-4" }));
1003
- expect(collector.getAccumulatedText()).toBe("");
1004
- });
1005
- });
1006
- });
1007
-
1008
- describe("EventCollectorRegistry getAccumulatedText", () => {
1009
- function createFakeDBForRegistry() {
1010
- const db = {
1011
- insert(_table: unknown) {
1012
- return {
1013
- values(_vals: Record<string, unknown>) {
1014
- return Promise.resolve();
1015
- },
1016
- };
1017
- },
1018
- update(_table: unknown) {
1019
- return {
1020
- set(_vals: Record<string, unknown>) {
1021
- return {
1022
- where(_condition: unknown) {
1023
- return Promise.resolve();
1024
- },
1025
- };
1026
- },
1027
- };
1028
- },
1029
- };
1030
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- fake DB satisfies the shape required by event-collector-registry at runtime
1031
- return { db: db as never };
1032
- }
1033
-
1034
- test("returns undefined for unknown agent address", () => {
1035
- const { db } = createFakeDBForRegistry();
1036
- const registry = createEventCollectorRegistry({ db });
1037
- expect(registry.getAccumulatedText("agent://unknown")).toBeUndefined();
1038
- });
1039
-
1040
- test("returns accumulated text from the collector for a known address", async () => {
1041
- const { db } = createFakeDBForRegistry();
1042
- const registry = createEventCollectorRegistry({ db });
1043
- const address = "agent://test-instance";
1044
-
1045
- registry.create(address, "tnt_test", "ses_test", "ins_test");
1046
-
1047
- registry.dispatch(address, event("inference.start", 1, { model: "gpt-4" }));
1048
- // dispatch is fire-and-forget; wait for the microtask queue to flush
1049
- await Promise.resolve();
1050
-
1051
- registry.dispatch(
1052
- address,
1053
- event("inference.done", 5, {
1054
- turn: {
1055
- role: "assistant",
1056
- content: [{ type: "text", text: "streaming text" }],
1057
- model: "gpt-4",
1058
- },
1059
- usage: { input: 10, output: 5 },
1060
- }),
1061
- );
1062
- await Promise.resolve();
1063
-
1064
- expect(registry.getAccumulatedText(address)).toBe("streaming text");
1065
- });
1066
- });
1067
-
1068
- describe("EventCollector.getCurrentTurnId", () => {
1069
- let fakeDB: ReturnType<typeof createFakeDB>;
1070
- let collector: EventCollector;
1071
-
1072
- beforeEach(() => {
1073
- fakeDB = createFakeDB();
1074
- collector = createEventCollector({
1075
- db: fakeDB.db,
1076
- sessionId: "ses_test",
1077
- instanceId: "ins_test",
1078
- tenantId: "tnt_test",
1079
- });
1080
- });
1081
-
1082
- test("returns null before any turn starts", () => {
1083
- expect(collector.getCurrentTurnId()).toBeNull();
1084
- });
1085
-
1086
- test("returns turn id after inference.start", async () => {
1087
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1088
- expect(collector.getCurrentTurnId()).not.toBeNull();
1089
- expect(collector.getCurrentTurnId()).toMatch(/^itn_/);
1090
- });
1091
-
1092
- test("retains turn id after inference.done", async () => {
1093
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1094
- const turnId = collector.getCurrentTurnId();
1095
-
1096
- await collector.onEvent(
1097
- event("inference.done", 2, {
1098
- turn: {
1099
- role: "assistant",
1100
- content: [{ type: "text", text: "hello" }],
1101
- model: "gpt-4",
1102
- },
1103
- usage: { input: 10, output: 5 },
1104
- }),
1105
- );
1106
-
1107
- expect(collector.getCurrentTurnId()).toBe(turnId);
1108
- });
1109
-
1110
- test("returns null after connector.reply finalizes the turn", async () => {
1111
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1112
- await collector.onEvent(
1113
- event("inference.done", 2, {
1114
- turn: {
1115
- role: "assistant",
1116
- content: [{ type: "text", text: "hello" }],
1117
- model: "gpt-4",
1118
- },
1119
- usage: { input: 10, output: 5 },
1120
- }),
1121
- );
1122
-
1123
- await collector.onEvent(event("connector.reply", 3, { content: "hello" }));
1124
-
1125
- expect(collector.getCurrentTurnId()).toBeNull();
1126
- });
1127
-
1128
- test("updates turn id when a new turn starts", async () => {
1129
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1130
- const firstTurnId = collector.getCurrentTurnId();
1131
-
1132
- await collector.onEvent(
1133
- event("inference.done", 2, {
1134
- turn: {
1135
- role: "assistant",
1136
- content: [{ type: "text", text: "step 1" }],
1137
- model: "gpt-4",
1138
- },
1139
- usage: { input: 10, output: 5 },
1140
- }),
1141
- );
1142
-
1143
- await collector.onEvent(event("inference.start", 3, { model: "gpt-4" }));
1144
- const secondTurnId = collector.getCurrentTurnId();
1145
-
1146
- expect(secondTurnId).not.toBeNull();
1147
- expect(secondTurnId).not.toBe(firstTurnId);
1148
- });
1149
- });
1150
-
1151
- describe("EventCollector post-finalization guard", () => {
1152
- let fakeDB: ReturnType<typeof createFakeDB>;
1153
- let collector: EventCollector;
1154
-
1155
- async function finalizeTurn(c: EventCollector): Promise<void> {
1156
- await c.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1157
- await c.onEvent(
1158
- event("inference.done", 2, {
1159
- turn: {
1160
- role: "assistant",
1161
- content: [{ type: "text", text: "hello" }],
1162
- model: "gpt-4",
1163
- },
1164
- usage: { input: 10, output: 5 },
1165
- }),
1166
- );
1167
- await c.onEvent(event("connector.reply", 3, { content: "hello" }));
1168
- }
1169
-
1170
- beforeEach(() => {
1171
- fakeDB = createFakeDB();
1172
- collector = createEventCollector({
1173
- db: fakeDB.db,
1174
- sessionId: "ses_test",
1175
- instanceId: "ins_test",
1176
- tenantId: "tnt_test",
1177
- });
1178
- });
1179
-
1180
- test("tool.done after finalization does not insert a part", async () => {
1181
- await finalizeTurn(collector);
1182
- const partsBefore = fakeDB.inserts.filter(
1183
- (i) => i.table === "turn_part",
1184
- ).length;
1185
-
1186
- await collector.onEvent(
1187
- event("tool.done", 4, {
1188
- result: { callId: "call_1", content: "stale result", isError: false },
1189
- }),
1190
- );
1191
-
1192
- const partsAfter = fakeDB.inserts.filter(
1193
- (i) => i.table === "turn_part",
1194
- ).length;
1195
- expect(partsAfter).toBe(partsBefore);
1196
- });
1197
-
1198
- test("inference.error after finalization does not insert a part", async () => {
1199
- await finalizeTurn(collector);
1200
- const partsBefore = fakeDB.inserts.filter(
1201
- (i) => i.table === "turn_part",
1202
- ).length;
1203
-
1204
- await collector.onEvent(
1205
- event("inference.error", 4, {
1206
- error: { category: "rate_limit", message: "too many requests" },
1207
- }),
1208
- );
1209
-
1210
- const partsAfter = fakeDB.inserts.filter(
1211
- (i) => i.table === "turn_part",
1212
- ).length;
1213
- expect(partsAfter).toBe(partsBefore);
1214
- });
1215
-
1216
- test("non-fatal reactor.error after finalization does not insert a part", async () => {
1217
- await finalizeTurn(collector);
1218
- const partsBefore = fakeDB.inserts.filter(
1219
- (i) => i.table === "turn_part",
1220
- ).length;
1221
-
1222
- await collector.onEvent(
1223
- event("reactor.error", 4, {
1224
- fatal: false,
1225
- error: "context store failed",
1226
- }),
1227
- );
1228
-
1229
- const partsAfter = fakeDB.inserts.filter(
1230
- (i) => i.table === "turn_part",
1231
- ).length;
1232
- expect(partsAfter).toBe(partsBefore);
1233
- });
1234
-
1235
- test("fatal reactor.error after finalization does not open a spurious turn", async () => {
1236
- await finalizeTurn(collector);
1237
- const turnsBefore = fakeDB.inserts.filter(
1238
- (i) => i.table === "inference_turn",
1239
- ).length;
1240
- const partsBefore = fakeDB.inserts.filter(
1241
- (i) => i.table === "turn_part",
1242
- ).length;
1243
-
1244
- await collector.onEvent(
1245
- event("reactor.error", 4, { fatal: true, error: "reactor crashed" }),
1246
- );
1247
-
1248
- const turnsAfter = fakeDB.inserts.filter(
1249
- (i) => i.table === "inference_turn",
1250
- ).length;
1251
- const partsAfter = fakeDB.inserts.filter(
1252
- (i) => i.table === "turn_part",
1253
- ).length;
1254
- expect(turnsAfter).toBe(turnsBefore);
1255
- expect(partsAfter).toBe(partsBefore);
1256
- });
1257
-
1258
- test("non-fatal reactor.error after finalization does not mutate delivered TurnFinalized", async () => {
1259
- const deliveries: TurnFinalized[] = [];
1260
- const tracked = createEventCollector({
1261
- db: fakeDB.db,
1262
- sessionId: "ses_test",
1263
- instanceId: "ins_test",
1264
- tenantId: "tnt_test",
1265
- onTurnFinalized: (turn) => {
1266
- deliveries.push(turn);
1267
- },
1268
- });
1269
-
1270
- await tracked.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1271
- await tracked.onEvent(
1272
- event("inference.done", 2, {
1273
- turn: {
1274
- role: "assistant",
1275
- content: [{ type: "text", text: "hello" }],
1276
- model: "gpt-4",
1277
- },
1278
- usage: { input: 10, output: 5 },
1279
- }),
1280
- );
1281
- await tracked.onEvent(event("connector.reply", 3, { content: "hello" }));
1282
-
1283
- expect(deliveries).toHaveLength(1);
1284
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length asserted above
1285
- const snapshot = deliveries[0]!;
1286
- expect(snapshot.errors).toHaveLength(0);
1287
- expect(snapshot.hadError).toBe(false);
1288
-
1289
- await tracked.onEvent(
1290
- event("reactor.error", 4, { fatal: false, error: "onShutdown failed" }),
1291
- );
1292
-
1293
- expect(snapshot.errors).toHaveLength(0);
1294
- expect(snapshot.hadError).toBe(false);
1295
- });
1296
-
1297
- test("connector.reply after finalization does not mutate accumulatedText", async () => {
1298
- const tracked = createEventCollector({
1299
- db: fakeDB.db,
1300
- sessionId: "ses_test",
1301
- instanceId: "ins_test",
1302
- tenantId: "tnt_test",
1303
- });
1304
-
1305
- // inference.error sets pendingError = true
1306
- await tracked.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1307
- await tracked.onEvent(
1308
- event("inference.error", 2, {
1309
- error: { category: "rate_limit", message: "limit reached" },
1310
- }),
1311
- );
1312
- // reactor.done finalizes the turn (pendingError is NOT cleared)
1313
- await tracked.onEvent(event("reactor.done", 3, {}));
1314
-
1315
- const textAfterFinalize = tracked.getAccumulatedText();
1316
-
1317
- // Late connector.reply arrives — should be ignored
1318
- await tracked.onEvent(
1319
- event("connector.reply", 4, { content: "stale reply" }),
1320
- );
1321
-
1322
- expect(tracked.getAccumulatedText()).toBe(textAfterFinalize);
1323
- });
1324
- });
1325
-
1326
- describe("EventCollector.getLastTurnId", () => {
1327
- let fakeDB: ReturnType<typeof createFakeDB>;
1328
- let collector: EventCollector;
1329
-
1330
- beforeEach(() => {
1331
- fakeDB = createFakeDB();
1332
- collector = createEventCollector({
1333
- db: fakeDB.db,
1334
- sessionId: "ses_test",
1335
- instanceId: "ins_test",
1336
- tenantId: "tnt_test",
1337
- });
1338
- });
1339
-
1340
- test("returns null before any turn starts", () => {
1341
- expect(collector.getLastTurnId()).toBeNull();
1342
- });
1343
-
1344
- test("matches getCurrentTurnId during active turn", async () => {
1345
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1346
- expect(collector.getLastTurnId()).toBe(collector.getCurrentTurnId());
1347
- });
1348
-
1349
- test("retains turn id after connector.reply finalizes the turn", async () => {
1350
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1351
- const turnId = collector.getCurrentTurnId();
1352
-
1353
- await collector.onEvent(
1354
- event("inference.done", 2, {
1355
- turn: {
1356
- role: "assistant",
1357
- content: [{ type: "text", text: "hello" }],
1358
- model: "gpt-4",
1359
- },
1360
- usage: { input: 10, output: 5 },
1361
- }),
1362
- );
1363
- await collector.onEvent(event("connector.reply", 3, { content: "hello" }));
1364
-
1365
- expect(collector.getCurrentTurnId()).toBeNull();
1366
- expect(collector.getLastTurnId()).toBe(turnId);
1367
- });
1368
-
1369
- test("updates to new turn id when a second turn starts", async () => {
1370
- await collector.onEvent(event("inference.start", 1, { model: "gpt-4" }));
1371
- const firstTurnId = collector.getLastTurnId();
1372
-
1373
- await collector.onEvent(
1374
- event("inference.done", 2, {
1375
- turn: {
1376
- role: "assistant",
1377
- content: [{ type: "text", text: "step 1" }],
1378
- model: "gpt-4",
1379
- },
1380
- usage: { input: 10, output: 5 },
1381
- }),
1382
- );
1383
- await collector.onEvent(event("inference.start", 3, { model: "gpt-4" }));
1384
-
1385
- expect(collector.getLastTurnId()).not.toBe(firstTurnId);
1386
- });
1387
- });