@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.
- package/README.md +29 -0
- package/package.json +28 -0
- package/src/app.test.ts +225 -0
- package/src/app.ts +382 -0
- package/src/auth.ts +21 -0
- package/src/context.ts +38 -0
- package/src/format.ts +9 -0
- package/src/git-http/advertise-refs.test.ts +459 -0
- package/src/git-http/advertise-refs.ts +226 -0
- package/src/git-http/pkt-line.test.ts +220 -0
- package/src/git-http/pkt-line.ts +235 -0
- package/src/git-http/receive-pack.test.ts +397 -0
- package/src/git-http/receive-pack.ts +261 -0
- package/src/git-http/side-band-64k.test.ts +181 -0
- package/src/git-http/side-band-64k.ts +134 -0
- package/src/git-http/upload-pack.test.ts +545 -0
- package/src/git-http/upload-pack.ts +396 -0
- package/src/index.ts +23 -0
- package/src/middleware/git-token-auth.test.ts +587 -0
- package/src/middleware/git-token-auth.ts +315 -0
- package/src/middleware/grant.ts +106 -0
- package/src/middleware/session.ts +13 -0
- package/src/middleware/tenant.test.ts +192 -0
- package/src/middleware/tenant.ts +101 -0
- package/src/openapi.ts +66 -0
- package/src/pagination.ts +117 -0
- package/src/routes/agent-data.ts +179 -0
- package/src/routes/agent-state-git.ts +562 -0
- package/src/routes/agents.test.ts +337 -0
- package/src/routes/agents.ts +704 -0
- package/src/routes/approvals.ts +130 -0
- package/src/routes/assets.test.ts +567 -0
- package/src/routes/assets.ts +592 -0
- package/src/routes/credentials.ts +435 -0
- package/src/routes/git-tokens.test.ts +709 -0
- package/src/routes/git-tokens.ts +771 -0
- package/src/routes/grants.ts +509 -0
- package/src/routes/instances.test.ts +1103 -0
- package/src/routes/instances.ts +1797 -0
- package/src/routes/me.ts +405 -0
- package/src/routes/oauth-clients.ts +349 -0
- package/src/routes/observability.ts +146 -0
- package/src/routes/offerings.ts +382 -0
- package/src/routes/principals.ts +515 -0
- package/src/routes/providers.ts +351 -0
- package/src/routes/roles.ts +452 -0
- package/src/routes/sidecars.ts +221 -0
- package/src/routes/tenant-federation.ts +225 -0
- package/src/routes/tenants.ts +369 -0
- package/src/routes/wallets.ts +370 -0
- package/src/session.ts +44 -0
- package/src/timeline-reconstruction.test.ts +786 -0
- package/src/timeline-reconstruction.ts +383 -0
- package/tsconfig.json +4 -0
- 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
|
+
});
|