@intx/hub-api 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +29 -0
  2. package/package.json +28 -0
  3. package/src/app.test.ts +225 -0
  4. package/src/app.ts +382 -0
  5. package/src/auth.ts +21 -0
  6. package/src/context.ts +38 -0
  7. package/src/format.ts +9 -0
  8. package/src/git-http/advertise-refs.test.ts +459 -0
  9. package/src/git-http/advertise-refs.ts +226 -0
  10. package/src/git-http/pkt-line.test.ts +220 -0
  11. package/src/git-http/pkt-line.ts +235 -0
  12. package/src/git-http/receive-pack.test.ts +397 -0
  13. package/src/git-http/receive-pack.ts +261 -0
  14. package/src/git-http/side-band-64k.test.ts +181 -0
  15. package/src/git-http/side-band-64k.ts +134 -0
  16. package/src/git-http/upload-pack.test.ts +545 -0
  17. package/src/git-http/upload-pack.ts +396 -0
  18. package/src/index.ts +23 -0
  19. package/src/middleware/git-token-auth.test.ts +587 -0
  20. package/src/middleware/git-token-auth.ts +315 -0
  21. package/src/middleware/grant.ts +106 -0
  22. package/src/middleware/session.ts +13 -0
  23. package/src/middleware/tenant.test.ts +192 -0
  24. package/src/middleware/tenant.ts +101 -0
  25. package/src/openapi.ts +66 -0
  26. package/src/pagination.ts +117 -0
  27. package/src/routes/agent-data.ts +179 -0
  28. package/src/routes/agent-state-git.ts +562 -0
  29. package/src/routes/agents.test.ts +337 -0
  30. package/src/routes/agents.ts +704 -0
  31. package/src/routes/approvals.ts +130 -0
  32. package/src/routes/assets.test.ts +567 -0
  33. package/src/routes/assets.ts +592 -0
  34. package/src/routes/credentials.ts +435 -0
  35. package/src/routes/git-tokens.test.ts +709 -0
  36. package/src/routes/git-tokens.ts +771 -0
  37. package/src/routes/grants.ts +509 -0
  38. package/src/routes/instances.test.ts +1103 -0
  39. package/src/routes/instances.ts +1797 -0
  40. package/src/routes/me.ts +405 -0
  41. package/src/routes/oauth-clients.ts +349 -0
  42. package/src/routes/observability.ts +146 -0
  43. package/src/routes/offerings.ts +382 -0
  44. package/src/routes/principals.ts +515 -0
  45. package/src/routes/providers.ts +351 -0
  46. package/src/routes/roles.ts +452 -0
  47. package/src/routes/sidecars.ts +221 -0
  48. package/src/routes/tenant-federation.ts +225 -0
  49. package/src/routes/tenants.ts +369 -0
  50. package/src/routes/wallets.ts +370 -0
  51. package/src/session.ts +44 -0
  52. package/src/timeline-reconstruction.test.ts +786 -0
  53. package/src/timeline-reconstruction.ts +383 -0
  54. package/tsconfig.json +4 -0
  55. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,786 @@
1
+ import { describe, test, expect, afterAll } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import git from "isomorphic-git";
6
+ import {
7
+ IsogitStore,
8
+ initAgentRepo,
9
+ createMailAuditStore,
10
+ } from "@intx/storage-isogit";
11
+ import type { ConversationTurn } from "@intx/types/runtime";
12
+ import type { ErrorRecord } from "@intx/types/audit";
13
+ import { reconstructTimeline } from "./timeline-reconstruction";
14
+
15
+ const tempDirs: string[] = [];
16
+
17
+ async function makeTempDir(): Promise<string> {
18
+ const d = await fs.promises.mkdtemp(
19
+ path.join(os.tmpdir(), "timeline-recon-"),
20
+ );
21
+ tempDirs.push(d);
22
+ return d;
23
+ }
24
+
25
+ afterAll(async () => {
26
+ for (const d of tempDirs) {
27
+ await fs.promises.rm(d, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ function userMessage(text: string, timestamp = Date.now()): ConversationTurn {
32
+ return { role: "user", content: [{ type: "text", text }], timestamp };
33
+ }
34
+
35
+ function assistantMessage(
36
+ text: string,
37
+ timestamp = Date.now(),
38
+ ): ConversationTurn {
39
+ return {
40
+ role: "assistant",
41
+ content: [{ type: "text", text }],
42
+ model: "test-model",
43
+ timestamp,
44
+ };
45
+ }
46
+
47
+ function toolCallMessage(
48
+ callId: string,
49
+ name: string,
50
+ args: Record<string, unknown>,
51
+ timestamp = Date.now(),
52
+ ): ConversationTurn {
53
+ return {
54
+ role: "assistant",
55
+ content: [{ type: "tool_call", id: callId, name, arguments: args }],
56
+ model: "test-model",
57
+ timestamp,
58
+ };
59
+ }
60
+
61
+ function toolResultMessage(
62
+ callId: string,
63
+ result: string,
64
+ timestamp = Date.now(),
65
+ ): ConversationTurn {
66
+ return {
67
+ role: "user",
68
+ content: [
69
+ {
70
+ type: "tool_result",
71
+ callId,
72
+ content: [{ type: "text", text: result }],
73
+ },
74
+ ],
75
+ timestamp,
76
+ };
77
+ }
78
+
79
+ function buildRawMessage(opts: {
80
+ messageId: string;
81
+ from?: string;
82
+ to?: string;
83
+ inReplyTo?: string;
84
+ body?: string;
85
+ }): Uint8Array {
86
+ const lines: string[] = [];
87
+ lines.push(`Message-ID: ${opts.messageId}`);
88
+ lines.push(`From: ${opts.from ?? "sender@example.com"}`);
89
+ lines.push(`To: ${opts.to ?? "recipient@example.com"}`);
90
+ lines.push(`Date: ${new Date().toUTCString()}`);
91
+ if (opts.inReplyTo !== undefined) {
92
+ lines.push(`In-Reply-To: ${opts.inReplyTo}`);
93
+ }
94
+ lines.push("");
95
+ lines.push(opts.body ?? "test body");
96
+ return new TextEncoder().encode(lines.join("\r\n"));
97
+ }
98
+
99
+ describe("reconstructTimeline", () => {
100
+ test("reconstructs a single-turn conversation", async () => {
101
+ const dir = await makeTempDir();
102
+ await initAgentRepo(dir);
103
+ const store = new IsogitStore(dir);
104
+
105
+ const t = 1700000000000;
106
+ const messages: ConversationTurn[] = [
107
+ userMessage("Hello", t),
108
+ assistantMessage("Hi there", t + 1000),
109
+ ];
110
+ await store.writeTurns(messages);
111
+
112
+ await store.commit({ message: "checkpoint: inference-done" });
113
+
114
+ const result = await reconstructTimeline(dir);
115
+
116
+ const turns = result.events.filter((e) => e.kind === "turn");
117
+ expect(turns).toHaveLength(1);
118
+ expect(turns[0]?.content).toBe("Hi there");
119
+ // Should use the per-message timestamp, not the git commit timestamp
120
+ expect(turns[0]?.timestamp).toBe(t + 1000);
121
+ // Status derived from checkpoint reason
122
+ expect(turns[0]?.kind === "turn" && turns[0].status).toBe("completed");
123
+ });
124
+
125
+ test("surfaces refusal-only assistant turns in the timeline summary", async () => {
126
+ // A turn whose only content is a RefusalBlock (OpenAI strict-
127
+ // mode policy decline) must appear on the timeline with the
128
+ // refusal text as the turn content. Filtering to text-only
129
+ // blocks would render the turn invisible even though the model
130
+ // produced coherent output.
131
+ const dir = await makeTempDir();
132
+ await initAgentRepo(dir);
133
+ const store = new IsogitStore(dir);
134
+
135
+ const t = 1700000000000;
136
+ const messages: ConversationTurn[] = [
137
+ userMessage("Tell me how to do something policy-tripping.", t),
138
+ {
139
+ role: "assistant",
140
+ content: [{ type: "refusal", reason: "I cannot help with that." }],
141
+ model: "gpt-test",
142
+ timestamp: t + 1000,
143
+ },
144
+ ];
145
+ await store.writeTurns(messages);
146
+
147
+ await store.commit({ message: "checkpoint: inference-done" });
148
+
149
+ const result = await reconstructTimeline(dir);
150
+ const turns = result.events.filter((e) => e.kind === "turn");
151
+ expect(turns).toHaveLength(1);
152
+ expect(turns[0]?.content).toBe("I cannot help with that.");
153
+ expect(turns[0]?.timestamp).toBe(t + 1000);
154
+ });
155
+
156
+ test("reconstructs multi-turn conversations across checkpoints", async () => {
157
+ const dir = await makeTempDir();
158
+ await initAgentRepo(dir);
159
+ const store = new IsogitStore(dir);
160
+
161
+ const t = 1700000000000;
162
+
163
+ // First turn
164
+ const messages1: ConversationTurn[] = [
165
+ userMessage("Hello", t),
166
+ assistantMessage("Hi there", t + 1000),
167
+ ];
168
+ await store.writeTurns(messages1);
169
+
170
+ await store.commit({ message: "checkpoint: inference-done" });
171
+
172
+ // Second turn (appends to the same message array)
173
+ const messages2: ConversationTurn[] = [
174
+ ...messages1,
175
+ userMessage("How are you?", t + 5000),
176
+ assistantMessage("I'm doing well", t + 6000),
177
+ ];
178
+ await store.writeTurns(messages2);
179
+
180
+ await store.commit({ message: "checkpoint: inference-done" });
181
+
182
+ const result = await reconstructTimeline(dir);
183
+
184
+ const turns = result.events.filter((e) => e.kind === "turn");
185
+ expect(turns).toHaveLength(2);
186
+ expect(turns[0]?.content).toBe("Hi there");
187
+ expect(turns[1]?.content).toBe("I'm doing well");
188
+
189
+ // Should use per-message timestamps
190
+ expect(turns[0]?.timestamp).toBe(t + 1000);
191
+ expect(turns[1]?.timestamp).toBe(t + 6000);
192
+ });
193
+
194
+ test("treats a tool-use loop as a single turn", async () => {
195
+ const dir = await makeTempDir();
196
+ await initAgentRepo(dir);
197
+ const store = new IsogitStore(dir);
198
+
199
+ const messages: ConversationTurn[] = [
200
+ userMessage("What's the weather?"),
201
+ toolCallMessage("call-1", "get_weather", { city: "SF" }),
202
+ toolResultMessage("call-1", "72F and sunny"),
203
+ assistantMessage("The weather in SF is 72F and sunny."),
204
+ ];
205
+ await store.writeTurns(messages);
206
+
207
+ await store.commit({ message: "checkpoint: inference-done" });
208
+
209
+ const result = await reconstructTimeline(dir);
210
+
211
+ const turns = result.events.filter((e) => e.kind === "turn");
212
+ expect(turns).toHaveLength(1);
213
+ expect(turns[0]?.content).toBe("The weather in SF is 72F and sunny.");
214
+ });
215
+
216
+ test("reconstructs mail events", async () => {
217
+ const dir = await makeTempDir();
218
+ await initAgentRepo(dir);
219
+
220
+ const mailStore = await createMailAuditStore(dir);
221
+
222
+ const inbound = buildRawMessage({
223
+ messageId: "<msg-1@test>",
224
+ from: "alice@example.com",
225
+ to: "agent@example.com",
226
+ body: "Please help me",
227
+ });
228
+ await mailStore.commitMail(inbound, "in");
229
+
230
+ const outbound = buildRawMessage({
231
+ messageId: "<msg-2@test>",
232
+ from: "agent@example.com",
233
+ to: "alice@example.com",
234
+ inReplyTo: "<msg-1@test>",
235
+ body: "Sure, how can I help?",
236
+ });
237
+ await mailStore.commitMail(outbound, "out");
238
+
239
+ const result = await reconstructTimeline(dir);
240
+
241
+ const mailEvents = result.events.filter((e) => e.kind === "mail");
242
+ expect(mailEvents).toHaveLength(2);
243
+
244
+ const inboundEvent = mailEvents.find(
245
+ (e) => e.kind === "mail" && e.direction === "in",
246
+ );
247
+ expect(inboundEvent).toBeDefined();
248
+ if (inboundEvent !== undefined && inboundEvent.kind === "mail") {
249
+ expect(inboundEvent.messageId).toBe("<msg-1@test>");
250
+ }
251
+
252
+ const outboundEvent = mailEvents.find(
253
+ (e) => e.kind === "mail" && e.direction === "out",
254
+ );
255
+ expect(outboundEvent).toBeDefined();
256
+ if (outboundEvent !== undefined && outboundEvent.kind === "mail") {
257
+ expect(outboundEvent.messageId).toBe("<msg-2@test>");
258
+ }
259
+ });
260
+
261
+ test("links outbound mail to checkpoint via Checkpoint trailer", async () => {
262
+ const dir = await makeTempDir();
263
+ await initAgentRepo(dir);
264
+ const store = new IsogitStore(dir);
265
+
266
+ const t = 1700000000000;
267
+ const messages: ConversationTurn[] = [
268
+ userMessage("Hello", t),
269
+ assistantMessage("Hi there", t + 1000),
270
+ ];
271
+ await store.writeTurns(messages);
272
+
273
+ const commit = await store.commit({
274
+ message: "checkpoint: inference-done",
275
+ });
276
+
277
+ const mailStore = await createMailAuditStore(dir);
278
+ const outbound = buildRawMessage({
279
+ messageId: "<reply@test>",
280
+ from: "agent@example.com",
281
+ to: "alice@example.com",
282
+ body: "Hi there",
283
+ });
284
+ await mailStore.commitMail(outbound, "out", {
285
+ checkpointHash: commit.hash,
286
+ });
287
+
288
+ const result = await reconstructTimeline(dir);
289
+
290
+ const mailEvents = result.events.filter((e) => e.kind === "mail");
291
+ expect(mailEvents).toHaveLength(1);
292
+ const mailEvent = mailEvents[0];
293
+ expect(mailEvent).toBeDefined();
294
+ if (mailEvent !== undefined && mailEvent.kind === "mail") {
295
+ expect(mailEvent.checkpointHash).toBe(commit.hash);
296
+ }
297
+ });
298
+
299
+ test("associates error records from git with the preceding turn", async () => {
300
+ const dir = await makeTempDir();
301
+ await initAgentRepo(dir);
302
+ const store = new IsogitStore(dir);
303
+
304
+ // Write a conversation first
305
+ const messages: ConversationTurn[] = [
306
+ userMessage("Do something risky"),
307
+ assistantMessage("Attempting..."),
308
+ ];
309
+ await store.writeTurns(messages);
310
+
311
+ await store.commit({ message: "checkpoint: inference-done" });
312
+
313
+ // Write error records (committed to git by the store)
314
+ const errors: ErrorRecord[] = [
315
+ {
316
+ source: "inference",
317
+ category: "rate_limit",
318
+ message: "Rate limit exceeded",
319
+ fatal: false,
320
+ timestamp: new Date().toISOString(),
321
+ sessionId: "test-session",
322
+ seq: 0,
323
+ },
324
+ ];
325
+ await store.commitErrors(errors);
326
+
327
+ const result = await reconstructTimeline(dir);
328
+
329
+ // Errors should be attached to the preceding turn, not a synthetic one
330
+ const turns = result.events.filter((e) => e.kind === "turn");
331
+ expect(turns).toHaveLength(1);
332
+ const turn = turns[0];
333
+ expect(turn?.kind === "turn" && turn.isError).toBe(true);
334
+ expect(turn?.kind === "turn" && turn.errors).toHaveLength(1);
335
+ if (turn?.kind === "turn") {
336
+ expect(turn.errors?.[0]?.category).toBe("rate_limit");
337
+ }
338
+ });
339
+
340
+ test("handles message-count regression gracefully", async () => {
341
+ const dir = await makeTempDir();
342
+ await initAgentRepo(dir);
343
+ const store = new IsogitStore(dir);
344
+
345
+ // First checkpoint with 4 messages
346
+ const messages1: ConversationTurn[] = [
347
+ userMessage("Hello"),
348
+ assistantMessage("Hi"),
349
+ userMessage("More"),
350
+ assistantMessage("Sure"),
351
+ ];
352
+ await store.writeTurns(messages1);
353
+
354
+ await store.commit({ message: "checkpoint: inference-done" });
355
+
356
+ // Second checkpoint with only 2 messages (regression)
357
+ const messages2: ConversationTurn[] = [
358
+ userMessage("Fresh start"),
359
+ assistantMessage("OK"),
360
+ ];
361
+ await store.writeTurns(messages2);
362
+
363
+ await store.commit({ message: "checkpoint: inference-done" });
364
+
365
+ const result = await reconstructTimeline(dir);
366
+
367
+ // Should not crash — should produce a gap record
368
+ const regressionGap = result.gaps.find(
369
+ (g) => g.kind === "message-count-regression",
370
+ );
371
+ expect(regressionGap).toBeDefined();
372
+
373
+ // Should still produce turn events from what it can reconstruct
374
+ const turns = result.events.filter((e) => e.kind === "turn");
375
+ expect(turns.length).toBeGreaterThan(0);
376
+ });
377
+
378
+ test("interleaves mail and turn events by timestamp", async () => {
379
+ const dir = await makeTempDir();
380
+ await initAgentRepo(dir);
381
+ const store = new IsogitStore(dir);
382
+ const mailStore = await createMailAuditStore(dir);
383
+
384
+ // Inbound mail first
385
+ const inbound = buildRawMessage({
386
+ messageId: "<msg-1@test>",
387
+ body: "Hello agent",
388
+ });
389
+ await mailStore.commitMail(inbound, "in");
390
+
391
+ // Then a turn
392
+ const messages: ConversationTurn[] = [
393
+ userMessage("Hello"),
394
+ assistantMessage("Hi there"),
395
+ ];
396
+ await store.writeTurns(messages);
397
+
398
+ await store.commit({ message: "checkpoint: inference-done" });
399
+
400
+ const result = await reconstructTimeline(dir);
401
+
402
+ // Both kinds should be present
403
+ const mailEvents = result.events.filter((e) => e.kind === "mail");
404
+ const turnEvents = result.events.filter((e) => e.kind === "turn");
405
+ expect(mailEvents).toHaveLength(1);
406
+ expect(turnEvents).toHaveLength(1);
407
+
408
+ // Mail should come before turn (committed first)
409
+ if (result.events[0] !== undefined && result.events[1] !== undefined) {
410
+ expect(result.events[0].timestamp).toBeLessThanOrEqual(
411
+ result.events[1].timestamp,
412
+ );
413
+ }
414
+ });
415
+
416
+ test("produces no gaps for a well-formed checkpoint", async () => {
417
+ const dir = await makeTempDir();
418
+ await initAgentRepo(dir);
419
+ const store = new IsogitStore(dir);
420
+
421
+ const t = 1700000000000;
422
+ const messages: ConversationTurn[] = [
423
+ userMessage("Hello", t),
424
+ assistantMessage("Hi", t + 1000),
425
+ ];
426
+ await store.writeTurns(messages);
427
+
428
+ await store.commit({ message: "checkpoint: inference-done" });
429
+
430
+ const result = await reconstructTimeline(dir);
431
+
432
+ expect(result.gaps).toHaveLength(0);
433
+ });
434
+
435
+ test("marks turns as error when checkpoint reason is inference-error", async () => {
436
+ const dir = await makeTempDir();
437
+ await initAgentRepo(dir);
438
+ const store = new IsogitStore(dir);
439
+
440
+ const t = 1700000000000;
441
+ const messages: ConversationTurn[] = [
442
+ userMessage("Hello", t),
443
+ assistantMessage("Something went wrong", t + 1000),
444
+ ];
445
+ await store.writeTurns(messages);
446
+
447
+ await store.commit({ message: "checkpoint: inference-error" });
448
+
449
+ const result = await reconstructTimeline(dir);
450
+
451
+ const turns = result.events.filter((e) => e.kind === "turn");
452
+ expect(turns).toHaveLength(1);
453
+ expect(turns[0]?.kind === "turn" && turns[0].status).toBe("error");
454
+ expect(turns[0]?.kind === "turn" && turns[0].isError).toBe(true);
455
+ });
456
+
457
+ test("marks turns as in-progress for mid-turn checkpoints", async () => {
458
+ const dir = await makeTempDir();
459
+ await initAgentRepo(dir);
460
+ const store = new IsogitStore(dir);
461
+
462
+ const t = 1700000000000;
463
+ const messages: ConversationTurn[] = [
464
+ userMessage("What's the weather?", t),
465
+ toolCallMessage("call-1", "get_weather", { city: "SF" }, t + 1000),
466
+ ];
467
+ await store.writeTurns(messages);
468
+
469
+ await store.commit({ message: "checkpoint: tool-execution" });
470
+
471
+ // Add tool result and final response
472
+ const messages2: ConversationTurn[] = [
473
+ ...messages,
474
+ toolResultMessage("call-1", "72F", t + 2000),
475
+ assistantMessage("It's 72F in SF", t + 3000),
476
+ ];
477
+ await store.writeTurns(messages2);
478
+
479
+ await store.commit({ message: "checkpoint: inference-done" });
480
+
481
+ const result = await reconstructTimeline(dir);
482
+
483
+ const turns = result.events.filter((e) => e.kind === "turn");
484
+ // tool-execution checkpoint has no text content, so no turn emitted
485
+ // inference-done checkpoint has the final response
486
+ expect(turns).toHaveLength(1);
487
+ expect(turns[0]?.kind === "turn" && turns[0].status).toBe("completed");
488
+ });
489
+
490
+ test("handles empty repo with no checkpoints", async () => {
491
+ const dir = await makeTempDir();
492
+ await initAgentRepo(dir);
493
+
494
+ const result = await reconstructTimeline(dir);
495
+
496
+ expect(result.events).toHaveLength(0);
497
+ expect(result.gaps).toBeDefined();
498
+ });
499
+
500
+ test("records a gap when a checkpoint commit has corrupt context", async () => {
501
+ const dir = await makeTempDir();
502
+ await initAgentRepo(dir);
503
+ const store = new IsogitStore(dir);
504
+
505
+ // Write a valid checkpoint first
506
+ const messages: ConversationTurn[] = [
507
+ userMessage("Hello"),
508
+ assistantMessage("Hi"),
509
+ ];
510
+ await store.writeTurns(messages);
511
+
512
+ await store.commit({ message: "checkpoint: inference-done" });
513
+
514
+ // Write a corrupt checkpoint directly via git
515
+ const turnsPath = path.join(dir, "turns.jsonl");
516
+ await fs.promises.writeFile(turnsPath, "NOT VALID JSON");
517
+ await git.add({ fs, dir, filepath: "turns.jsonl" });
518
+ await git.commit({
519
+ fs,
520
+ dir,
521
+ message: "checkpoint: inference-done",
522
+ author: { name: "test", email: "test@test.dev" },
523
+ });
524
+
525
+ const result = await reconstructTimeline(dir);
526
+
527
+ // Should still have the valid turn from the first checkpoint
528
+ const turns = result.events.filter((e) => e.kind === "turn");
529
+ expect(turns).toHaveLength(1);
530
+
531
+ // Should record a gap for the corrupt checkpoint
532
+ const corruptGaps = result.gaps.filter(
533
+ (g) => g.kind === "corrupt-checkpoint",
534
+ );
535
+ expect(corruptGaps).toHaveLength(1);
536
+ });
537
+
538
+ test("records gaps for corrupt error record files in git", async () => {
539
+ const dir = await makeTempDir();
540
+ await initAgentRepo(dir);
541
+ const store = new IsogitStore(dir);
542
+
543
+ await store.writeTurns([userMessage("Hello"), assistantMessage("Hi")]);
544
+ await store.commit({ message: "checkpoint: inference-done" });
545
+
546
+ // Commit a valid error record via the store
547
+ await store.commitErrors([
548
+ {
549
+ source: "inference",
550
+ category: "rate_limit",
551
+ message: "Rate limited",
552
+ fatal: false,
553
+ timestamp: new Date().toISOString(),
554
+ sessionId: "test-session",
555
+ seq: 0,
556
+ },
557
+ ]);
558
+
559
+ // Manually commit a corrupt error file directly via git
560
+ const errorsDir = path.join(dir, "state/errors/test-session");
561
+ await fs.promises.writeFile(
562
+ path.join(errorsDir, "00000001-corrupt.json"),
563
+ "NOT VALID JSON",
564
+ );
565
+ await git.add({
566
+ fs,
567
+ dir,
568
+ filepath: "state/errors/test-session/00000001-corrupt.json",
569
+ });
570
+ await git.commit({
571
+ fs,
572
+ dir,
573
+ message: "Record 1 error record",
574
+ author: { name: "test", email: "test@test.dev" },
575
+ });
576
+
577
+ const result = await reconstructTimeline(dir);
578
+
579
+ // Valid error should be associated with the preceding turn
580
+ const turns = result.events.filter((e) => e.kind === "turn");
581
+ expect(turns).toHaveLength(1);
582
+ const turn = turns[0];
583
+ expect(turn?.kind === "turn" && turn.isError).toBe(true);
584
+ expect(turn?.kind === "turn" && turn.errors?.length).toBeGreaterThanOrEqual(
585
+ 1,
586
+ );
587
+
588
+ // Corrupt file should produce a gap
589
+ const corruptGaps = result.gaps.filter(
590
+ (g) => g.kind === "corrupt-error-record",
591
+ );
592
+ expect(corruptGaps).toHaveLength(1);
593
+ expect(corruptGaps[0]?.description).toContain("00000001-corrupt.json");
594
+ });
595
+
596
+ test("reconstructs a full multi-step session from git", async () => {
597
+ const dir = await makeTempDir();
598
+ await initAgentRepo(dir);
599
+ const store = new IsogitStore(dir);
600
+ const mailStore = await createMailAuditStore(dir);
601
+
602
+ const t = 1700000000000;
603
+
604
+ // 1. Inbound mail arrives
605
+ const inbound = buildRawMessage({
606
+ messageId: "<inbound-1@test>",
607
+ from: "alice@example.com",
608
+ to: "agent@example.com",
609
+ body: "What is the weather in SF and NYC?",
610
+ });
611
+ await mailStore.commitMail(inbound, "in");
612
+
613
+ // 2. First inference: agent decides to call a tool
614
+ const msgs1: ConversationTurn[] = [
615
+ userMessage("What is the weather in SF and NYC?", t),
616
+ toolCallMessage("call-1", "get_weather", { city: "SF" }, t + 1000),
617
+ ];
618
+ await store.writeTurns(msgs1);
619
+
620
+ await store.commit({ message: "checkpoint: tool-execution" });
621
+
622
+ // 3. Tool result comes back
623
+ const msgs2: ConversationTurn[] = [
624
+ ...msgs1,
625
+ toolResultMessage("call-1", "72F and sunny", t + 2000),
626
+ ];
627
+ await store.writeTurns(msgs2);
628
+
629
+ await store.commit({ message: "checkpoint: tool-done" });
630
+
631
+ // 4. Second inference: agent calls another tool
632
+ const msgs3: ConversationTurn[] = [
633
+ ...msgs2,
634
+ toolCallMessage("call-2", "get_weather", { city: "NYC" }, t + 3000),
635
+ ];
636
+ await store.writeTurns(msgs3);
637
+
638
+ await store.commit({ message: "checkpoint: tool-execution" });
639
+
640
+ // 5. Second tool result
641
+ const msgs4: ConversationTurn[] = [
642
+ ...msgs3,
643
+ toolResultMessage("call-2", "55F and rainy", t + 4000),
644
+ ];
645
+ await store.writeTurns(msgs4);
646
+
647
+ await store.commit({ message: "checkpoint: tool-done" });
648
+
649
+ // 6. Final inference: agent composes reply
650
+ const msgs5: ConversationTurn[] = [
651
+ ...msgs4,
652
+ assistantMessage("SF is 72F and sunny. NYC is 55F and rainy.", t + 5000),
653
+ ];
654
+ await store.writeTurns(msgs5);
655
+
656
+ const finalCommit = await store.commit({
657
+ message: "checkpoint: inference-done",
658
+ });
659
+
660
+ // 7. Outbound mail sent with checkpoint linkage
661
+ const outbound = buildRawMessage({
662
+ messageId: "<outbound-1@test>",
663
+ from: "agent@example.com",
664
+ to: "alice@example.com",
665
+ inReplyTo: "<inbound-1@test>",
666
+ body: "SF is 72F and sunny. NYC is 55F and rainy.",
667
+ });
668
+ await mailStore.commitMail(outbound, "out", {
669
+ checkpointHash: finalCommit.hash,
670
+ });
671
+
672
+ // 8. An error occurs on a follow-up inference
673
+ const msgs6: ConversationTurn[] = [
674
+ ...msgs5,
675
+ userMessage("What about London?", t + 10000),
676
+ assistantMessage("Let me check London weather.", t + 11000),
677
+ ];
678
+ await store.writeTurns(msgs6);
679
+
680
+ await store.commit({ message: "checkpoint: inference-error" });
681
+
682
+ // 9. Error record committed
683
+ await store.commitErrors([
684
+ {
685
+ source: "inference",
686
+ category: "rate_limit",
687
+ message: "Rate limited by provider",
688
+ fatal: false,
689
+ timestamp: new Date(t + 11000).toISOString(),
690
+ sessionId: "test-session",
691
+ seq: 0,
692
+ },
693
+ ]);
694
+
695
+ // --- Reconstruct and verify ---
696
+ const result = await reconstructTimeline(dir);
697
+
698
+ // No gaps — all data is well-formed and linked
699
+ expect(result.gaps).toHaveLength(0);
700
+
701
+ // Mail events
702
+ const mailEvents = result.events.filter((e) => e.kind === "mail");
703
+ expect(mailEvents).toHaveLength(2);
704
+
705
+ const inboundMail = mailEvents.find(
706
+ (e) => e.kind === "mail" && e.direction === "in",
707
+ );
708
+ expect(inboundMail).toBeDefined();
709
+ if (inboundMail !== undefined && inboundMail.kind === "mail") {
710
+ expect(inboundMail.messageId).toBe("<inbound-1@test>");
711
+ }
712
+
713
+ const outboundMail = mailEvents.find(
714
+ (e) => e.kind === "mail" && e.direction === "out",
715
+ );
716
+ expect(outboundMail).toBeDefined();
717
+ if (outboundMail !== undefined && outboundMail.kind === "mail") {
718
+ expect(outboundMail.messageId).toBe("<outbound-1@test>");
719
+ expect(outboundMail.checkpointHash).toBe(finalCommit.hash);
720
+ }
721
+
722
+ // Turn events
723
+ const turns = result.events.filter((e) => e.kind === "turn");
724
+
725
+ // Tool-only checkpoints (tool-execution, tool-done) produce no text
726
+ // content, so no turn events. Only inference-done and inference-error
727
+ // checkpoints that add assistant text produce turns.
728
+ expect(turns).toHaveLength(2);
729
+
730
+ const completedTurn = turns.find(
731
+ (e) => e.kind === "turn" && e.status === "completed",
732
+ );
733
+ expect(completedTurn).toBeDefined();
734
+ if (completedTurn !== undefined && completedTurn.kind === "turn") {
735
+ expect(completedTurn.content).toBe(
736
+ "SF is 72F and sunny. NYC is 55F and rainy.",
737
+ );
738
+ }
739
+
740
+ const errorTurn = turns.find(
741
+ (e) => e.kind === "turn" && e.status === "error",
742
+ );
743
+ expect(errorTurn).toBeDefined();
744
+ if (errorTurn !== undefined && errorTurn.kind === "turn") {
745
+ expect(errorTurn.content).toBe("Let me check London weather.");
746
+ expect(errorTurn.isError).toBe(true);
747
+ expect(errorTurn.errors).toBeDefined();
748
+ expect(errorTurn.errors?.length).toBe(1);
749
+ expect(errorTurn.errors?.[0]?.category).toBe("rate_limit");
750
+ }
751
+
752
+ // Events are sorted by timestamp — mail and turns interleaved correctly
753
+ for (let i = 1; i < result.events.length; i++) {
754
+ const prev = result.events[i - 1];
755
+ const curr = result.events[i];
756
+ if (prev !== undefined && curr !== undefined) {
757
+ expect(curr.timestamp).toBeGreaterThanOrEqual(prev.timestamp);
758
+ }
759
+ }
760
+ });
761
+
762
+ test("records multiple gaps for multiple corrupt checkpoints", async () => {
763
+ const dir = await makeTempDir();
764
+ await initAgentRepo(dir);
765
+
766
+ // Write two corrupt checkpoints
767
+ for (let i = 0; i < 2; i++) {
768
+ const turnsPath = path.join(dir, "turns.jsonl");
769
+ await fs.promises.writeFile(turnsPath, `CORRUPT ${String(i)}`);
770
+ await git.add({ fs, dir, filepath: "turns.jsonl" });
771
+ await git.commit({
772
+ fs,
773
+ dir,
774
+ message: "checkpoint: inference-done",
775
+ author: { name: "test", email: "test@test.dev" },
776
+ });
777
+ }
778
+
779
+ const result = await reconstructTimeline(dir);
780
+
781
+ const corruptGaps = result.gaps.filter(
782
+ (g) => g.kind === "corrupt-checkpoint",
783
+ );
784
+ expect(corruptGaps).toHaveLength(2);
785
+ });
786
+ });