macro-agent 0.1.3 → 0.1.5
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/dist/cognitive/workspace-handler.d.ts +9 -17
- package/dist/cognitive/workspace-handler.d.ts.map +1 -1
- package/dist/cognitive/workspace-handler.js +11 -10
- package/dist/cognitive/workspace-handler.js.map +1 -1
- package/dist/map/coordination-handler.d.ts +23 -7
- package/dist/map/coordination-handler.d.ts.map +1 -1
- package/dist/map/coordination-handler.js +100 -124
- package/dist/map/coordination-handler.js.map +1 -1
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +15 -3
- package/dist/map/sidecar.js.map +1 -1
- package/dist/map/trajectory-reporter.d.ts +9 -4
- package/dist/map/trajectory-reporter.d.ts.map +1 -1
- package/dist/map/trajectory-reporter.js +129 -15
- package/dist/map/trajectory-reporter.js.map +1 -1
- package/dist/map/types.d.ts +0 -37
- package/dist/map/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/e2e/cognitive-workspace.e2e.test.ts +1 -1
- package/src/__tests__/e2e/trajectory-content.e2e.test.ts +708 -0
- package/src/cognitive/__tests__/workspace-handler.test.ts +10 -2
- package/src/cognitive/workspace-handler.ts +15 -18
- package/src/map/__tests__/coordination-handler.test.ts +598 -0
- package/src/map/__tests__/trajectory-reporter.test.ts +254 -2
- package/src/map/coordination-handler.ts +120 -137
- package/src/map/sidecar.ts +20 -3
- package/src/map/trajectory-reporter.ts +154 -16
- package/src/map/types.ts +2 -40
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trajectory Content Serving E2E Test
|
|
3
|
+
*
|
|
4
|
+
* Verifies end-to-end that the trajectory reporter can serve session
|
|
5
|
+
* transcripts via sessionlog when the hub sends trajectory/content.request.
|
|
6
|
+
*
|
|
7
|
+
* Tests the full content resolution pipeline:
|
|
8
|
+
* 1. Live session lookup from sessionlog state files
|
|
9
|
+
* 2. Prompt extraction from various JSONL transcript formats
|
|
10
|
+
* 3. Multi-directory search (simulating multiple agent workspaces)
|
|
11
|
+
* 4. Checkpoint ID matching strategies (session ID, lastCheckpointID, turnCheckpointIDs)
|
|
12
|
+
* 5. Graceful degradation when sessions aren't found
|
|
13
|
+
* 6. Reporter lifecycle (register/unregister content handler)
|
|
14
|
+
*
|
|
15
|
+
* All tests use tmp dirs to avoid interfering with active sessionlog files.
|
|
16
|
+
*
|
|
17
|
+
* Run:
|
|
18
|
+
* npx vitest run src/__tests__/e2e/trajectory-content.e2e.test.ts
|
|
19
|
+
*
|
|
20
|
+
* @vitest-environment node
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import * as os from "node:os";
|
|
27
|
+
import { execSync } from "node:child_process";
|
|
28
|
+
import {
|
|
29
|
+
createTrajectoryReporter,
|
|
30
|
+
type TrajectoryConnection,
|
|
31
|
+
} from "../../map/trajectory-reporter.js";
|
|
32
|
+
import type { TrajectoryReporter } from "../../map/types.js";
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Test fixtures — simulate sessionlog directory layouts
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
/** Root tmp dir for all tests */
|
|
39
|
+
let rootTmpDir: string;
|
|
40
|
+
|
|
41
|
+
/** Workspace dirs simulating different agent cwds */
|
|
42
|
+
let workspace1Dir: string;
|
|
43
|
+
let workspace2Dir: string;
|
|
44
|
+
let emptyWorkspaceDir: string;
|
|
45
|
+
|
|
46
|
+
/** Session directories within workspaces */
|
|
47
|
+
let sessionsDir1: string;
|
|
48
|
+
let sessionsDir2: string;
|
|
49
|
+
|
|
50
|
+
/** Sample JSONL transcripts in various agent formats */
|
|
51
|
+
const CLAUDE_CODE_TRANSCRIPT = [
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
type: "user",
|
|
54
|
+
message: { role: "user", content: "Fix the authentication bug in login.ts" },
|
|
55
|
+
}),
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
type: "assistant",
|
|
58
|
+
message: {
|
|
59
|
+
role: "assistant",
|
|
60
|
+
content: [
|
|
61
|
+
{ type: "text", text: "I'll investigate the auth issue." },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
type: "tool_use",
|
|
67
|
+
tool_name: "Read",
|
|
68
|
+
input: { file_path: "/src/login.ts" },
|
|
69
|
+
}),
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
type: "tool_result",
|
|
72
|
+
tool_use_id: "tu_1",
|
|
73
|
+
content: "export function login() { ... }",
|
|
74
|
+
}),
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
type: "user",
|
|
77
|
+
message: { role: "user", content: "Now add rate limiting" },
|
|
78
|
+
}),
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
type: "assistant",
|
|
81
|
+
message: { role: "assistant", content: "Adding rate limiter." },
|
|
82
|
+
}),
|
|
83
|
+
].join("\n") + "\n";
|
|
84
|
+
|
|
85
|
+
const MULTI_BLOCK_TRANSCRIPT = [
|
|
86
|
+
JSON.stringify({
|
|
87
|
+
type: "user",
|
|
88
|
+
message: {
|
|
89
|
+
content: [
|
|
90
|
+
{ type: "text", text: "First instruction" },
|
|
91
|
+
{ type: "image", source: { type: "base64", data: "..." } },
|
|
92
|
+
{ type: "text", text: "Second instruction" },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
JSON.stringify({ type: "assistant", message: "Working on it" }),
|
|
97
|
+
].join("\n") + "\n";
|
|
98
|
+
|
|
99
|
+
const SIMPLE_STRING_TRANSCRIPT = [
|
|
100
|
+
JSON.stringify({ type: "user", message: "Simple prompt" }),
|
|
101
|
+
JSON.stringify({ type: "assistant", message: "Simple response" }),
|
|
102
|
+
].join("\n") + "\n";
|
|
103
|
+
|
|
104
|
+
// =============================================================================
|
|
105
|
+
// Setup / Teardown
|
|
106
|
+
// =============================================================================
|
|
107
|
+
|
|
108
|
+
/** Write a sessionlog-compatible flat state file: <sessionsDir>/<sessionId>.json */
|
|
109
|
+
function writeSession(
|
|
110
|
+
sessionsDir: string,
|
|
111
|
+
sessionId: string,
|
|
112
|
+
state: Record<string, unknown>,
|
|
113
|
+
transcript: string,
|
|
114
|
+
): void {
|
|
115
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
116
|
+
const transcriptPath = path.join(sessionsDir, `${sessionId}.jsonl`);
|
|
117
|
+
fs.writeFileSync(transcriptPath, transcript);
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
path.join(sessionsDir, `${sessionId}.json`),
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
sessionID: sessionId,
|
|
122
|
+
phase: "active",
|
|
123
|
+
baseCommit: "abc123",
|
|
124
|
+
startedAt: "2026-04-04T00:00:00Z",
|
|
125
|
+
agentType: "claude",
|
|
126
|
+
transcriptPath,
|
|
127
|
+
...state,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
beforeAll(() => {
|
|
133
|
+
rootTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "trajectory-content-e2e-"));
|
|
134
|
+
|
|
135
|
+
// Workspace 1: two sessions (one active with Claude Code transcript, one ended)
|
|
136
|
+
workspace1Dir = path.join(rootTmpDir, "workspace1");
|
|
137
|
+
sessionsDir1 = path.join(workspace1Dir, ".git", "sessionlog-sessions");
|
|
138
|
+
|
|
139
|
+
writeSession(sessionsDir1, "sess-auth-fix", {
|
|
140
|
+
stepCount: 5,
|
|
141
|
+
lastCheckpointID: "sess-auth-fix-step5",
|
|
142
|
+
turnCheckpointIDs: [
|
|
143
|
+
"sess-auth-fix-step1",
|
|
144
|
+
"sess-auth-fix-step3",
|
|
145
|
+
"sess-auth-fix-step5",
|
|
146
|
+
],
|
|
147
|
+
filesTouched: ["src/login.ts", "src/rate-limiter.ts"],
|
|
148
|
+
tokenUsage: {
|
|
149
|
+
input_tokens: 1500,
|
|
150
|
+
output_tokens: 800,
|
|
151
|
+
cache_read_tokens: 200,
|
|
152
|
+
},
|
|
153
|
+
firstPrompt: "Fix the authentication bug in login.ts",
|
|
154
|
+
promptAttributions: [
|
|
155
|
+
{ prompt: "Fix the authentication bug in login.ts", timestamp: "2026-04-04T00:00:00Z", agentLines: 20 },
|
|
156
|
+
{ prompt: "Now add rate limiting", timestamp: "2026-04-04T00:01:00Z", agentLines: 15 },
|
|
157
|
+
],
|
|
158
|
+
}, CLAUDE_CODE_TRANSCRIPT);
|
|
159
|
+
|
|
160
|
+
writeSession(sessionsDir1, "sess-old-work", {
|
|
161
|
+
phase: "ended",
|
|
162
|
+
stepCount: 2,
|
|
163
|
+
endedAt: "2026-04-03T11:00:00Z",
|
|
164
|
+
firstPrompt: "Simple prompt",
|
|
165
|
+
}, SIMPLE_STRING_TRANSCRIPT);
|
|
166
|
+
|
|
167
|
+
// Workspace 2: one session with multi-block content
|
|
168
|
+
workspace2Dir = path.join(rootTmpDir, "workspace2");
|
|
169
|
+
sessionsDir2 = path.join(workspace2Dir, ".swarm", "sessionlog", "sessions");
|
|
170
|
+
|
|
171
|
+
writeSession(sessionsDir2, "sess-multiblock", {
|
|
172
|
+
firstPrompt: "First instruction\nSecond instruction",
|
|
173
|
+
}, MULTI_BLOCK_TRANSCRIPT);
|
|
174
|
+
|
|
175
|
+
// Empty workspace: no sessions
|
|
176
|
+
emptyWorkspaceDir = path.join(rootTmpDir, "empty");
|
|
177
|
+
fs.mkdirSync(emptyWorkspaceDir, { recursive: true });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
afterAll(() => {
|
|
181
|
+
fs.rmSync(rootTmpDir, { recursive: true, force: true });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// =============================================================================
|
|
185
|
+
// Helpers
|
|
186
|
+
// =============================================================================
|
|
187
|
+
|
|
188
|
+
interface MockConnection extends TrajectoryConnection {
|
|
189
|
+
callExtension: ReturnType<typeof vi.fn>;
|
|
190
|
+
sendNotification: ReturnType<typeof vi.fn>;
|
|
191
|
+
onNotification: ReturnType<typeof vi.fn>;
|
|
192
|
+
offNotification: ReturnType<typeof vi.fn>;
|
|
193
|
+
contentHandler: ((params: unknown) => Promise<void>) | null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function createMockConnection(): MockConnection {
|
|
197
|
+
const conn: MockConnection = {
|
|
198
|
+
callExtension: vi.fn().mockResolvedValue({ ok: true }),
|
|
199
|
+
sendNotification: vi.fn().mockResolvedValue(undefined),
|
|
200
|
+
onNotification: vi.fn(),
|
|
201
|
+
offNotification: vi.fn(),
|
|
202
|
+
get isConnected() { return true; },
|
|
203
|
+
contentHandler: null,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Capture the content handler when registered
|
|
207
|
+
conn.onNotification.mockImplementation((method: string, handler: any) => {
|
|
208
|
+
if (method === "trajectory/content.request") {
|
|
209
|
+
conn.contentHandler = handler;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return conn;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Simulate a content request and return the response params */
|
|
217
|
+
async function requestContent(
|
|
218
|
+
conn: MockConnection,
|
|
219
|
+
checkpointId: string,
|
|
220
|
+
): Promise<Record<string, unknown>> {
|
|
221
|
+
expect(conn.contentHandler).not.toBeNull();
|
|
222
|
+
|
|
223
|
+
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
224
|
+
await conn.contentHandler!({
|
|
225
|
+
request_id: requestId,
|
|
226
|
+
checkpoint_id: checkpointId,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Find the response for this request
|
|
230
|
+
const responseCalls = conn.sendNotification.mock.calls.filter(
|
|
231
|
+
(c: any[]) => c[0] === "trajectory/content.response" && c[1]?.request_id === requestId,
|
|
232
|
+
);
|
|
233
|
+
expect(responseCalls.length).toBe(1);
|
|
234
|
+
return responseCalls[0][1] as Record<string, unknown>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// =============================================================================
|
|
238
|
+
// E2E Tests
|
|
239
|
+
// =============================================================================
|
|
240
|
+
|
|
241
|
+
describe("Trajectory Content E2E — live session serving", () => {
|
|
242
|
+
let conn: MockConnection;
|
|
243
|
+
let reporter: TrajectoryReporter;
|
|
244
|
+
|
|
245
|
+
beforeAll(() => {
|
|
246
|
+
conn = createMockConnection();
|
|
247
|
+
reporter = createTrajectoryReporter(conn, {
|
|
248
|
+
trajectorySyncLevel: "full",
|
|
249
|
+
sessionDirs: [sessionsDir1, sessionsDir2],
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
afterAll(() => {
|
|
254
|
+
reporter.stop();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("serves transcript for active session by derived session ID", async () => {
|
|
258
|
+
const response = await requestContent(conn, "sess-auth-fix-step2");
|
|
259
|
+
|
|
260
|
+
expect(response.transcript).toContain("Fix the authentication bug");
|
|
261
|
+
expect(response.transcript).toContain("rate limiting");
|
|
262
|
+
expect(response.metadata).toEqual(
|
|
263
|
+
expect.objectContaining({
|
|
264
|
+
sessionID: "sess-auth-fix",
|
|
265
|
+
phase: "active",
|
|
266
|
+
source: "live",
|
|
267
|
+
stepCount: 5,
|
|
268
|
+
filesTouched: ["src/login.ts", "src/rate-limiter.ts"],
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
expect(response.context).toContain("sess-auth-fix");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("matches by lastCheckpointID", async () => {
|
|
275
|
+
const response = await requestContent(conn, "sess-auth-fix-step5");
|
|
276
|
+
|
|
277
|
+
expect(response.transcript).toContain("Fix the authentication bug");
|
|
278
|
+
expect((response.metadata as any).sessionID).toBe("sess-auth-fix");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("matches by turnCheckpointIDs entry", async () => {
|
|
282
|
+
const response = await requestContent(conn, "sess-auth-fix-step3");
|
|
283
|
+
|
|
284
|
+
expect(response.transcript).toContain("Fix the authentication bug");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("extracts prompts from promptAttributions", async () => {
|
|
288
|
+
const response = await requestContent(conn, "sess-auth-fix-step1");
|
|
289
|
+
|
|
290
|
+
const prompts = response.prompts as string;
|
|
291
|
+
expect(prompts).toContain("Fix the authentication bug in login.ts");
|
|
292
|
+
expect(prompts).toContain("Now add rate limiting");
|
|
293
|
+
// Two prompts separated by ---
|
|
294
|
+
expect(prompts.split("---").length).toBe(2);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("serves ended session transcripts", async () => {
|
|
298
|
+
const response = await requestContent(conn, "sess-old-work-step1");
|
|
299
|
+
|
|
300
|
+
expect(response.transcript).toContain("Simple prompt");
|
|
301
|
+
expect((response.metadata as any).sessionID).toBe("sess-old-work");
|
|
302
|
+
expect((response.metadata as any).phase).toBe("ended");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("serves from second session directory (workspace2)", async () => {
|
|
306
|
+
const response = await requestContent(conn, "sess-multiblock-step1");
|
|
307
|
+
|
|
308
|
+
expect(response.transcript).toContain("First instruction");
|
|
309
|
+
expect((response.metadata as any).sessionID).toBe("sess-multiblock");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("uses firstPrompt when no promptAttributions", async () => {
|
|
313
|
+
const response = await requestContent(conn, "sess-multiblock-step1");
|
|
314
|
+
|
|
315
|
+
const prompts = response.prompts as string;
|
|
316
|
+
expect(prompts).toContain("First instruction");
|
|
317
|
+
expect(prompts).toContain("Second instruction");
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("Trajectory Content E2E — no content found", () => {
|
|
322
|
+
let conn: MockConnection;
|
|
323
|
+
let reporter: TrajectoryReporter;
|
|
324
|
+
|
|
325
|
+
beforeAll(() => {
|
|
326
|
+
conn = createMockConnection();
|
|
327
|
+
reporter = createTrajectoryReporter(conn, {
|
|
328
|
+
trajectorySyncLevel: "full",
|
|
329
|
+
sessionDirs: [path.join(emptyWorkspaceDir, "nonexistent")],
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
afterAll(() => {
|
|
334
|
+
reporter.stop();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("returns empty response for unknown session", async () => {
|
|
338
|
+
const response = await requestContent(conn, "sess-nonexistent-step1");
|
|
339
|
+
|
|
340
|
+
expect(response.transcript).toBe("");
|
|
341
|
+
expect(response.prompts).toBe("");
|
|
342
|
+
expect((response.metadata as any).source).toBe("macro-agent");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("returns empty response for checkpoint with no matching session", async () => {
|
|
346
|
+
const response = await requestContent(conn, "completely-unknown-id");
|
|
347
|
+
|
|
348
|
+
expect(response.transcript).toBe("");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("Trajectory Content E2E — reporter lifecycle", () => {
|
|
353
|
+
it("registers content handler on creation", () => {
|
|
354
|
+
const conn = createMockConnection();
|
|
355
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
356
|
+
trajectorySyncLevel: "full",
|
|
357
|
+
sessionDirs: [],
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(conn.contentHandler).not.toBeNull();
|
|
361
|
+
reporter.stop();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("unregisters content handler on stop", () => {
|
|
365
|
+
const conn = createMockConnection();
|
|
366
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
367
|
+
trajectorySyncLevel: "full",
|
|
368
|
+
sessionDirs: [],
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
reporter.stop();
|
|
372
|
+
|
|
373
|
+
expect(conn.offNotification).toHaveBeenCalledWith(
|
|
374
|
+
"trajectory/content.request",
|
|
375
|
+
expect.any(Function),
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("checkpoint reporting still works alongside content serving", async () => {
|
|
380
|
+
const conn = createMockConnection();
|
|
381
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
382
|
+
trajectorySyncLevel: "metrics",
|
|
383
|
+
sessionDirs: [sessionsDir1],
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const result = await reporter.reportCheckpoint({
|
|
387
|
+
id: "sess-auth-fix-step6",
|
|
388
|
+
session_id: "sess-auth-fix",
|
|
389
|
+
agent: "macro-agent-sidecar",
|
|
390
|
+
branch: "main",
|
|
391
|
+
files_touched: ["src/login.ts"],
|
|
392
|
+
checkpoints_count: 6,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(result).toEqual(expect.objectContaining({ ok: true }));
|
|
396
|
+
expect(conn.callExtension).toHaveBeenCalledWith(
|
|
397
|
+
"trajectory/checkpoint",
|
|
398
|
+
expect.objectContaining({
|
|
399
|
+
checkpoint: expect.objectContaining({ id: "sess-auth-fix-step6" }),
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
reporter.stop();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("Trajectory Content E2E — edge cases", () => {
|
|
408
|
+
it("handles malformed state.json gracefully", async () => {
|
|
409
|
+
const badDir = path.join(rootTmpDir, "bad-state");
|
|
410
|
+
fs.mkdirSync(badDir, { recursive: true });
|
|
411
|
+
fs.writeFileSync(path.join(badDir, "sess-bad.json"), "not valid json{{{");
|
|
412
|
+
|
|
413
|
+
const conn = createMockConnection();
|
|
414
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
415
|
+
trajectorySyncLevel: "full",
|
|
416
|
+
sessionDirs: [badDir],
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const response = await requestContent(conn, "sess-bad-step1");
|
|
420
|
+
expect(response.transcript).toBe("");
|
|
421
|
+
|
|
422
|
+
reporter.stop();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("handles state.json with missing transcriptPath", async () => {
|
|
426
|
+
const noTranscriptDir = path.join(rootTmpDir, "no-transcript");
|
|
427
|
+
fs.mkdirSync(noTranscriptDir, { recursive: true });
|
|
428
|
+
fs.writeFileSync(
|
|
429
|
+
path.join(noTranscriptDir, "sess-no-file.json"),
|
|
430
|
+
JSON.stringify({
|
|
431
|
+
sessionID: "sess-no-file",
|
|
432
|
+
phase: "active",
|
|
433
|
+
baseCommit: "abc",
|
|
434
|
+
startedAt: new Date().toISOString(),
|
|
435
|
+
agentType: "claude",
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
const conn = createMockConnection();
|
|
440
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
441
|
+
trajectorySyncLevel: "full",
|
|
442
|
+
sessionDirs: [noTranscriptDir],
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const response = await requestContent(conn, "sess-no-file-step1");
|
|
446
|
+
expect(response.transcript).toBe("");
|
|
447
|
+
|
|
448
|
+
reporter.stop();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("handles transcript with malformed JSONL lines", async () => {
|
|
452
|
+
const malformedDir = path.join(rootTmpDir, "malformed-transcript");
|
|
453
|
+
fs.mkdirSync(malformedDir, { recursive: true });
|
|
454
|
+
|
|
455
|
+
const transcriptPath = path.join(malformedDir, "sess-malformed.jsonl");
|
|
456
|
+
fs.writeFileSync(transcriptPath, [
|
|
457
|
+
JSON.stringify({ type: "user", message: "Valid prompt" }),
|
|
458
|
+
"this is not json",
|
|
459
|
+
"{ broken json",
|
|
460
|
+
JSON.stringify({ type: "user", message: "Another valid prompt" }),
|
|
461
|
+
].join("\n") + "\n");
|
|
462
|
+
|
|
463
|
+
fs.writeFileSync(
|
|
464
|
+
path.join(malformedDir, "sess-malformed.json"),
|
|
465
|
+
JSON.stringify({
|
|
466
|
+
sessionID: "sess-malformed",
|
|
467
|
+
phase: "active",
|
|
468
|
+
baseCommit: "abc",
|
|
469
|
+
startedAt: new Date().toISOString(),
|
|
470
|
+
agentType: "claude",
|
|
471
|
+
transcriptPath,
|
|
472
|
+
firstPrompt: "Valid prompt",
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const conn = createMockConnection();
|
|
477
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
478
|
+
trajectorySyncLevel: "full",
|
|
479
|
+
sessionDirs: [malformedDir],
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const response = await requestContent(conn, "sess-malformed-step1");
|
|
483
|
+
|
|
484
|
+
// Transcript is served raw (including malformed lines)
|
|
485
|
+
expect(response.transcript).toContain("Valid prompt");
|
|
486
|
+
expect(response.transcript).toContain("this is not json");
|
|
487
|
+
// Prompts come from state.firstPrompt, not transcript parsing
|
|
488
|
+
expect(response.prompts).toBe("Valid prompt");
|
|
489
|
+
|
|
490
|
+
reporter.stop();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("handles empty transcript file", async () => {
|
|
494
|
+
const emptyDir = path.join(rootTmpDir, "empty-transcript");
|
|
495
|
+
fs.mkdirSync(emptyDir, { recursive: true });
|
|
496
|
+
|
|
497
|
+
const transcriptPath = path.join(emptyDir, "sess-empty.jsonl");
|
|
498
|
+
fs.writeFileSync(transcriptPath, "");
|
|
499
|
+
|
|
500
|
+
fs.writeFileSync(
|
|
501
|
+
path.join(emptyDir, "sess-empty.json"),
|
|
502
|
+
JSON.stringify({
|
|
503
|
+
sessionID: "sess-empty",
|
|
504
|
+
phase: "active",
|
|
505
|
+
baseCommit: "abc",
|
|
506
|
+
startedAt: new Date().toISOString(),
|
|
507
|
+
agentType: "claude",
|
|
508
|
+
transcriptPath,
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
const conn = createMockConnection();
|
|
513
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
514
|
+
trajectorySyncLevel: "full",
|
|
515
|
+
sessionDirs: [emptyDir],
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const response = await requestContent(conn, "sess-empty-step1");
|
|
519
|
+
|
|
520
|
+
expect(response.transcript).toBe("");
|
|
521
|
+
expect(response.prompts).toBe("");
|
|
522
|
+
// Should still have metadata since the session was found
|
|
523
|
+
expect((response.metadata as any).sessionID).toBe("sess-empty");
|
|
524
|
+
|
|
525
|
+
reporter.stop();
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// =============================================================================
|
|
530
|
+
// E2E Tests — Committed checkpoint store (real git repo)
|
|
531
|
+
// =============================================================================
|
|
532
|
+
|
|
533
|
+
describe("Trajectory Content E2E — committed checkpoints via sessionlog", () => {
|
|
534
|
+
let checkpointRepoDir: string;
|
|
535
|
+
let committedCheckpointId: string;
|
|
536
|
+
let checkpointStore: any;
|
|
537
|
+
|
|
538
|
+
const COMMITTED_TRANSCRIPT = [
|
|
539
|
+
JSON.stringify({ type: "user", message: "Refactor the database layer" }),
|
|
540
|
+
JSON.stringify({ type: "assistant", message: "I'll restructure the DAL." }),
|
|
541
|
+
JSON.stringify({ type: "tool_use", tool_name: "Edit", input: { file_path: "src/db/dal.ts" } }),
|
|
542
|
+
JSON.stringify({ type: "user", message: "Also add connection pooling" }),
|
|
543
|
+
].join("\n") + "\n";
|
|
544
|
+
|
|
545
|
+
beforeAll(async () => {
|
|
546
|
+
// Create a real git repo for the checkpoint store
|
|
547
|
+
checkpointRepoDir = path.join(rootTmpDir, "checkpoint-repo");
|
|
548
|
+
fs.mkdirSync(checkpointRepoDir, { recursive: true });
|
|
549
|
+
|
|
550
|
+
// Initialize git repo with an initial commit
|
|
551
|
+
execSync("git init", { cwd: checkpointRepoDir, stdio: "pipe" });
|
|
552
|
+
execSync("git config user.email 'test@test.com'", { cwd: checkpointRepoDir, stdio: "pipe" });
|
|
553
|
+
execSync("git config user.name 'Test'", { cwd: checkpointRepoDir, stdio: "pipe" });
|
|
554
|
+
execSync("git commit --allow-empty -m 'init'", { cwd: checkpointRepoDir, stdio: "pipe" });
|
|
555
|
+
|
|
556
|
+
// Use sessionlog's CheckpointStore to write a committed checkpoint
|
|
557
|
+
try {
|
|
558
|
+
const sessionlog = await import("sessionlog");
|
|
559
|
+
checkpointStore = sessionlog.createCheckpointStore(
|
|
560
|
+
undefined,
|
|
561
|
+
checkpointRepoDir,
|
|
562
|
+
"sessionlog/checkpoints/v1",
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
committedCheckpointId = await checkpointStore.generateID();
|
|
566
|
+
|
|
567
|
+
await checkpointStore.writeCommitted({
|
|
568
|
+
checkpointID: committedCheckpointId,
|
|
569
|
+
sessionID: "sess-committed-db",
|
|
570
|
+
strategy: "manual-commit",
|
|
571
|
+
branch: "main",
|
|
572
|
+
transcript: Buffer.from(COMMITTED_TRANSCRIPT),
|
|
573
|
+
prompts: ["Refactor the database layer", "Also add connection pooling"],
|
|
574
|
+
context: Buffer.from("Database refactoring session"),
|
|
575
|
+
filesTouched: ["src/db/dal.ts", "src/db/pool.ts"],
|
|
576
|
+
checkpointsCount: 1,
|
|
577
|
+
authorName: "Test Agent",
|
|
578
|
+
authorEmail: "agent@test.com",
|
|
579
|
+
agent: "Claude Code",
|
|
580
|
+
turnID: "turn-committed-1",
|
|
581
|
+
checkpointTranscriptStart: 0,
|
|
582
|
+
});
|
|
583
|
+
} catch (err) {
|
|
584
|
+
// If sessionlog is not available or write fails, skip these tests
|
|
585
|
+
console.warn("[e2e] Skipping committed checkpoint tests:", (err as Error).message);
|
|
586
|
+
checkpointStore = null;
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("serves transcript from committed checkpoint store", async () => {
|
|
591
|
+
if (!checkpointStore) return; // skip if sessionlog unavailable
|
|
592
|
+
|
|
593
|
+
const conn = createMockConnection();
|
|
594
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
595
|
+
trajectorySyncLevel: "full",
|
|
596
|
+
// No live session dirs — force fallback to checkpoint store
|
|
597
|
+
sessionDirs: [],
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// Mock the sessionlog import to use our test repo
|
|
601
|
+
// The resolveContent function imports sessionlog dynamically, so we need
|
|
602
|
+
// to ensure it can find our checkpoint repo. We do this by temporarily
|
|
603
|
+
// changing cwd (createCheckpointStore defaults to cwd).
|
|
604
|
+
const origCwd = process.cwd();
|
|
605
|
+
try {
|
|
606
|
+
process.chdir(checkpointRepoDir);
|
|
607
|
+
|
|
608
|
+
const response = await requestContent(conn, committedCheckpointId);
|
|
609
|
+
|
|
610
|
+
expect(response.transcript).toContain("Refactor the database layer");
|
|
611
|
+
expect(response.transcript).toContain("connection pooling");
|
|
612
|
+
expect((response.metadata as any).source).toBe("committed");
|
|
613
|
+
} finally {
|
|
614
|
+
process.chdir(origCwd);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
reporter.stop();
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("committed checkpoint contains correct prompts", async () => {
|
|
621
|
+
if (!checkpointStore) return;
|
|
622
|
+
|
|
623
|
+
const conn = createMockConnection();
|
|
624
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
625
|
+
trajectorySyncLevel: "full",
|
|
626
|
+
sessionDirs: [],
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const origCwd = process.cwd();
|
|
630
|
+
try {
|
|
631
|
+
process.chdir(checkpointRepoDir);
|
|
632
|
+
|
|
633
|
+
const response = await requestContent(conn, committedCheckpointId);
|
|
634
|
+
|
|
635
|
+
const prompts = response.prompts as string;
|
|
636
|
+
expect(prompts).toContain("Refactor the database layer");
|
|
637
|
+
expect(prompts).toContain("connection pooling");
|
|
638
|
+
} finally {
|
|
639
|
+
process.chdir(origCwd);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
reporter.stop();
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("committed checkpoint includes context", async () => {
|
|
646
|
+
if (!checkpointStore) return;
|
|
647
|
+
|
|
648
|
+
const conn = createMockConnection();
|
|
649
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
650
|
+
trajectorySyncLevel: "full",
|
|
651
|
+
sessionDirs: [],
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
const origCwd = process.cwd();
|
|
655
|
+
try {
|
|
656
|
+
process.chdir(checkpointRepoDir);
|
|
657
|
+
|
|
658
|
+
const response = await requestContent(conn, committedCheckpointId);
|
|
659
|
+
|
|
660
|
+
expect(response.context).toContain("Database refactoring session");
|
|
661
|
+
} finally {
|
|
662
|
+
process.chdir(origCwd);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
reporter.stop();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("prefers live session over committed checkpoint when both exist", async () => {
|
|
669
|
+
if (!checkpointStore) return;
|
|
670
|
+
|
|
671
|
+
// Set up a live session dir that matches the committed checkpoint ID
|
|
672
|
+
const liveDir = path.join(rootTmpDir, "live-override");
|
|
673
|
+
fs.mkdirSync(liveDir, { recursive: true });
|
|
674
|
+
|
|
675
|
+
const liveTranscript = JSON.stringify({
|
|
676
|
+
type: "user",
|
|
677
|
+
message: "LIVE SESSION DATA",
|
|
678
|
+
}) + "\n";
|
|
679
|
+
const liveTranscriptPath = path.join(liveDir, "sess-committed-db.jsonl");
|
|
680
|
+
fs.writeFileSync(liveTranscriptPath, liveTranscript);
|
|
681
|
+
fs.writeFileSync(
|
|
682
|
+
path.join(liveDir, "sess-committed-db.json"),
|
|
683
|
+
JSON.stringify({
|
|
684
|
+
sessionID: "sess-committed-db",
|
|
685
|
+
phase: "active",
|
|
686
|
+
baseCommit: "abc",
|
|
687
|
+
startedAt: new Date().toISOString(),
|
|
688
|
+
agentType: "claude",
|
|
689
|
+
transcriptPath: liveTranscriptPath,
|
|
690
|
+
lastCheckpointID: committedCheckpointId,
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
const conn = createMockConnection();
|
|
695
|
+
const reporter = createTrajectoryReporter(conn, {
|
|
696
|
+
trajectorySyncLevel: "full",
|
|
697
|
+
sessionDirs: [liveDir],
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
const response = await requestContent(conn, committedCheckpointId);
|
|
701
|
+
|
|
702
|
+
// Live session should win over committed
|
|
703
|
+
expect(response.transcript).toContain("LIVE SESSION DATA");
|
|
704
|
+
expect((response.metadata as any).source).toBe("live");
|
|
705
|
+
|
|
706
|
+
reporter.stop();
|
|
707
|
+
});
|
|
708
|
+
});
|