@posthog/agent 2.1.131 → 2.1.138

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 (40) hide show
  1. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +14 -28
  2. package/dist/adapters/claude/conversion/tool-use-to-acp.js +118 -165
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
  4. package/dist/adapters/claude/permissions/permission-options.js +33 -0
  5. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  6. package/dist/adapters/claude/session/jsonl-hydration.d.ts +45 -0
  7. package/dist/adapters/claude/session/jsonl-hydration.js +444 -0
  8. package/dist/adapters/claude/session/jsonl-hydration.js.map +1 -0
  9. package/dist/adapters/claude/tools.js +21 -11
  10. package/dist/adapters/claude/tools.js.map +1 -1
  11. package/dist/agent.d.ts +2 -0
  12. package/dist/agent.js +1261 -608
  13. package/dist/agent.js.map +1 -1
  14. package/dist/posthog-api.js +6 -2
  15. package/dist/posthog-api.js.map +1 -1
  16. package/dist/server/agent-server.js +1307 -657
  17. package/dist/server/agent-server.js.map +1 -1
  18. package/dist/server/bin.cjs +1285 -637
  19. package/dist/server/bin.cjs.map +1 -1
  20. package/package.json +8 -4
  21. package/src/adapters/base-acp-agent.ts +6 -3
  22. package/src/adapters/claude/UPSTREAM.md +63 -0
  23. package/src/adapters/claude/claude-agent.ts +682 -421
  24. package/src/adapters/claude/conversion/sdk-to-acp.ts +249 -85
  25. package/src/adapters/claude/conversion/tool-use-to-acp.ts +176 -150
  26. package/src/adapters/claude/hooks.ts +53 -1
  27. package/src/adapters/claude/permissions/permission-handlers.ts +39 -21
  28. package/src/adapters/claude/session/commands.ts +13 -9
  29. package/src/adapters/claude/session/jsonl-hydration.test.ts +903 -0
  30. package/src/adapters/claude/session/jsonl-hydration.ts +581 -0
  31. package/src/adapters/claude/session/mcp-config.ts +2 -5
  32. package/src/adapters/claude/session/options.ts +58 -6
  33. package/src/adapters/claude/session/settings.ts +326 -0
  34. package/src/adapters/claude/tools.ts +1 -0
  35. package/src/adapters/claude/types.ts +38 -0
  36. package/src/adapters/codex/spawn.ts +1 -1
  37. package/src/agent.ts +4 -0
  38. package/src/execution-mode.ts +26 -10
  39. package/src/server/agent-server.test.ts +41 -1
  40. package/src/utils/common.ts +1 -1
@@ -0,0 +1,903 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { StoredEntry } from "../../../types.js";
3
+ import {
4
+ conversationTurnsToJsonlEntries,
5
+ getSessionJsonlPath,
6
+ rebuildConversation,
7
+ } from "./jsonl-hydration.js";
8
+
9
+ function entry(
10
+ sessionUpdate: string,
11
+ extra: Record<string, unknown> = {},
12
+ ): StoredEntry {
13
+ return {
14
+ type: "notification",
15
+ timestamp: new Date().toISOString(),
16
+ notification: {
17
+ jsonrpc: "2.0",
18
+ method: "session/update",
19
+ params: { update: { sessionUpdate, ...extra } },
20
+ },
21
+ };
22
+ }
23
+
24
+ function toolEntry(
25
+ sessionUpdate: string,
26
+ meta: Record<string, unknown>,
27
+ ): StoredEntry {
28
+ return entry(sessionUpdate, { _meta: { claudeCode: meta } });
29
+ }
30
+
31
+ describe("getSessionJsonlPath", () => {
32
+ it("constructs path from sessionId and cwd", () => {
33
+ const original = process.env.CLAUDE_CONFIG_DIR;
34
+ process.env.CLAUDE_CONFIG_DIR = "/tmp/claude-test";
35
+ try {
36
+ const result = getSessionJsonlPath("sess-123", "/home/user/project");
37
+ expect(result).toBe(
38
+ "/tmp/claude-test/projects/-home-user-project/sess-123.jsonl",
39
+ );
40
+ } finally {
41
+ if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR;
42
+ else process.env.CLAUDE_CONFIG_DIR = original;
43
+ }
44
+ });
45
+
46
+ it("replaces dots and special chars like the Claude Code binary", () => {
47
+ const original = process.env.CLAUDE_CONFIG_DIR;
48
+ process.env.CLAUDE_CONFIG_DIR = "/tmp/claude-test";
49
+ try {
50
+ const result = getSessionJsonlPath(
51
+ "sess-1",
52
+ "/Users/dev/.twig/worktrees/repo",
53
+ );
54
+ expect(result).toBe(
55
+ "/tmp/claude-test/projects/-Users-dev--twig-worktrees-repo/sess-1.jsonl",
56
+ );
57
+ } finally {
58
+ if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR;
59
+ else process.env.CLAUDE_CONFIG_DIR = original;
60
+ }
61
+ });
62
+
63
+ it("truncates long paths with hash like the Claude Code binary", () => {
64
+ const original = process.env.CLAUDE_CONFIG_DIR;
65
+ process.env.CLAUDE_CONFIG_DIR = "/tmp/claude-test";
66
+ try {
67
+ const longPath = `/home/${"a".repeat(250)}/project`;
68
+ const result = getSessionJsonlPath("sess-1", longPath);
69
+ const projectDir = result
70
+ .replace("/tmp/claude-test/projects/", "")
71
+ .replace("/sess-1.jsonl", "");
72
+ expect(projectDir.length).toBeLessThanOrEqual(220);
73
+ expect(projectDir).toMatch(/-[a-z0-9]+$/);
74
+ } finally {
75
+ if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR;
76
+ else process.env.CLAUDE_CONFIG_DIR = original;
77
+ }
78
+ });
79
+
80
+ it("handles backslashes in cwd", () => {
81
+ const original = process.env.CLAUDE_CONFIG_DIR;
82
+ process.env.CLAUDE_CONFIG_DIR = "/tmp/claude-test";
83
+ try {
84
+ const result = getSessionJsonlPath("sess-1", "C:\\Users\\dev\\project");
85
+ expect(result).toContain("C--Users-dev-project");
86
+ } finally {
87
+ if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR;
88
+ else process.env.CLAUDE_CONFIG_DIR = original;
89
+ }
90
+ });
91
+ });
92
+
93
+ describe("rebuildConversation", () => {
94
+ it("returns empty turns for empty entries", () => {
95
+ expect(rebuildConversation([])).toEqual([]);
96
+ });
97
+
98
+ it("returns empty turns for non-session/update entries", () => {
99
+ const entries: StoredEntry[] = [
100
+ {
101
+ type: "notification",
102
+ timestamp: new Date().toISOString(),
103
+ notification: {
104
+ jsonrpc: "2.0",
105
+ method: "some/other_method",
106
+ params: {},
107
+ },
108
+ },
109
+ ];
110
+ expect(rebuildConversation(entries)).toEqual([]);
111
+ });
112
+
113
+ it("produces a single user turn from user_message", () => {
114
+ const turns = rebuildConversation([
115
+ entry("user_message", {
116
+ content: { type: "text", text: "hello" },
117
+ }),
118
+ ]);
119
+
120
+ expect(turns).toHaveLength(1);
121
+ expect(turns[0].role).toBe("user");
122
+ expect(turns[0].content).toEqual([{ type: "text", text: "hello" }]);
123
+ });
124
+
125
+ it("handles user_message with array content", () => {
126
+ const turns = rebuildConversation([
127
+ entry("user_message", {
128
+ content: [
129
+ { type: "text", text: "first" },
130
+ { type: "text", text: "second" },
131
+ ],
132
+ }),
133
+ ]);
134
+
135
+ expect(turns).toHaveLength(1);
136
+ expect(turns[0].content).toHaveLength(2);
137
+ });
138
+
139
+ it("merges consecutive user messages into one turn", () => {
140
+ const turns = rebuildConversation([
141
+ entry("user_message", { content: { type: "text", text: "hello" } }),
142
+ entry("user_message", { content: { type: "text", text: "world" } }),
143
+ entry("agent_message", { content: { type: "text", text: "hi" } }),
144
+ ]);
145
+
146
+ expect(turns).toHaveLength(2);
147
+ expect(turns[0].role).toBe("user");
148
+ expect(turns[0].content).toEqual([
149
+ { type: "text", text: "hello" },
150
+ { type: "text", text: "world" },
151
+ ]);
152
+ expect(turns[1].role).toBe("assistant");
153
+ });
154
+
155
+ it("skips empty content in consecutive user messages", () => {
156
+ const turns = rebuildConversation([
157
+ entry("user_message", { content: { type: "text", text: "prompt" } }),
158
+ entry("user_message", {}),
159
+ entry("user_message", {}),
160
+ entry("agent_message", { content: { type: "text", text: "response" } }),
161
+ ]);
162
+
163
+ expect(turns).toHaveLength(2);
164
+ expect(turns[0].role).toBe("user");
165
+ expect(turns[0].content).toEqual([{ type: "text", text: "prompt" }]);
166
+ });
167
+
168
+ it("coalesces consecutive agent text chunks", () => {
169
+ const turns = rebuildConversation([
170
+ entry("user_message", { content: { type: "text", text: "hi" } }),
171
+ entry("agent_message_chunk", { content: { type: "text", text: "hel" } }),
172
+ entry("agent_message_chunk", { content: { type: "text", text: "lo" } }),
173
+ entry("agent_message_chunk", {
174
+ content: { type: "text", text: " world" },
175
+ }),
176
+ ]);
177
+
178
+ expect(turns).toHaveLength(2);
179
+ expect(turns[1].role).toBe("assistant");
180
+ expect(turns[1].content).toHaveLength(1);
181
+ expect(turns[1].content[0]).toEqual({
182
+ type: "text",
183
+ text: "hello world",
184
+ });
185
+ });
186
+
187
+ it("does not coalesce non-text blocks", () => {
188
+ const turns = rebuildConversation([
189
+ entry("user_message", { content: { type: "text", text: "hi" } }),
190
+ entry("agent_message", {
191
+ content: { type: "thinking", thinking: "hmm" },
192
+ }),
193
+ entry("agent_message", { content: { type: "text", text: "answer" } }),
194
+ ]);
195
+
196
+ expect(turns).toHaveLength(2);
197
+ expect(turns[1].content).toHaveLength(2);
198
+ expect(turns[1].content[0]).toEqual({ type: "thinking", thinking: "hmm" });
199
+ expect(turns[1].content[1]).toEqual({ type: "text", text: "answer" });
200
+ });
201
+
202
+ it("produces alternating user/assistant turns for multi-round conversation", () => {
203
+ const turns = rebuildConversation([
204
+ entry("user_message", { content: { type: "text", text: "q1" } }),
205
+ entry("agent_message", { content: { type: "text", text: "a1" } }),
206
+ entry("user_message", { content: { type: "text", text: "q2" } }),
207
+ entry("agent_message", { content: { type: "text", text: "a2" } }),
208
+ ]);
209
+
210
+ expect(turns).toHaveLength(4);
211
+ expect(turns.map((t) => t.role)).toEqual([
212
+ "user",
213
+ "assistant",
214
+ "user",
215
+ "assistant",
216
+ ]);
217
+ });
218
+
219
+ it("tracks tool calls with results", () => {
220
+ const turns = rebuildConversation([
221
+ entry("user_message", { content: { type: "text", text: "do it" } }),
222
+ entry("agent_message", { content: { type: "text", text: "ok" } }),
223
+ toolEntry("tool_call", {
224
+ toolCallId: "tc-1",
225
+ toolName: "Bash",
226
+ toolInput: { command: "ls" },
227
+ }),
228
+ toolEntry("tool_result", {
229
+ toolCallId: "tc-1",
230
+ toolResponse: "file.txt",
231
+ }),
232
+ ]);
233
+
234
+ expect(turns).toHaveLength(2);
235
+ const assistant = turns[1];
236
+ expect(assistant.toolCalls).toHaveLength(1);
237
+ expect(assistant.toolCalls?.[0]).toEqual({
238
+ toolCallId: "tc-1",
239
+ toolName: "Bash",
240
+ input: { command: "ls" },
241
+ result: "file.txt",
242
+ });
243
+ });
244
+
245
+ it("updates tool result via tool_call_update", () => {
246
+ const turns = rebuildConversation([
247
+ entry("user_message", { content: { type: "text", text: "go" } }),
248
+ toolEntry("tool_call", {
249
+ toolCallId: "tc-1",
250
+ toolName: "Read",
251
+ toolInput: { path: "/a" },
252
+ }),
253
+ toolEntry("tool_call_update", {
254
+ toolCallId: "tc-1",
255
+ toolName: "Read",
256
+ toolResponse: "contents",
257
+ }),
258
+ ]);
259
+
260
+ expect(turns[1].toolCalls?.[0].result).toBe("contents");
261
+ });
262
+
263
+ it("flushes trailing assistant content", () => {
264
+ const turns = rebuildConversation([
265
+ entry("user_message", { content: { type: "text", text: "hi" } }),
266
+ entry("agent_message", { content: { type: "text", text: "bye" } }),
267
+ ]);
268
+
269
+ expect(turns).toHaveLength(2);
270
+ expect(turns[1].role).toBe("assistant");
271
+ expect(turns[1].content[0]).toEqual({ type: "text", text: "bye" });
272
+ });
273
+
274
+ it("flushes trailing tool calls without explicit result", () => {
275
+ const turns = rebuildConversation([
276
+ entry("user_message", { content: { type: "text", text: "go" } }),
277
+ toolEntry("tool_call", {
278
+ toolCallId: "tc-1",
279
+ toolName: "Bash",
280
+ toolInput: { command: "echo" },
281
+ }),
282
+ ]);
283
+
284
+ expect(turns).toHaveLength(2);
285
+ expect(turns[1].toolCalls).toHaveLength(1);
286
+ expect(turns[1].toolCalls?.[0].result).toBeUndefined();
287
+ });
288
+ });
289
+
290
+ describe("conversationTurnsToJsonlEntries", () => {
291
+ const config = { sessionId: "sess-1", cwd: "/repo" };
292
+
293
+ function parseConversationEntries(lines: string[]) {
294
+ return lines
295
+ .map((l) => JSON.parse(l))
296
+ .filter((e: { type: string }) => e.type !== "queue-operation");
297
+ }
298
+
299
+ function parseQueueEntries(lines: string[]) {
300
+ return lines
301
+ .map((l) => JSON.parse(l))
302
+ .filter((e: { type: string }) => e.type === "queue-operation");
303
+ }
304
+
305
+ it("returns empty array for empty turns", () => {
306
+ expect(conversationTurnsToJsonlEntries([], config)).toEqual([]);
307
+ });
308
+
309
+ it("produces queue ops and a user line with array content", () => {
310
+ const lines = conversationTurnsToJsonlEntries(
311
+ [{ role: "user", content: [{ type: "text", text: "hello" }] }],
312
+ config,
313
+ );
314
+
315
+ // enqueue + dequeue + user entry
316
+ expect(lines).toHaveLength(3);
317
+
318
+ const queueOps = parseQueueEntries(lines);
319
+ expect(queueOps).toHaveLength(2);
320
+ expect(queueOps[0].operation).toBe("enqueue");
321
+ expect(queueOps[1].operation).toBe("dequeue");
322
+ expect(queueOps[0].sessionId).toBe("sess-1");
323
+
324
+ const [parsed] = parseConversationEntries(lines);
325
+ expect(parsed.type).toBe("user");
326
+ expect(parsed.message.role).toBe("user");
327
+ expect(parsed.message.content).toEqual([{ type: "text", text: "hello" }]);
328
+ expect(parsed.sessionId).toBe("sess-1");
329
+ expect(parsed.cwd).toBe("/repo");
330
+ expect(parsed.parentUuid).toBeNull();
331
+ expect(parsed.version).toBe("2.1.63");
332
+ expect(parsed.permissionMode).toBe("default");
333
+ expect(parsed.gitBranch).toBeDefined();
334
+ expect(parsed.slug).toBeDefined();
335
+ });
336
+
337
+ it("chains parentUuid across conversation entries", () => {
338
+ const lines = conversationTurnsToJsonlEntries(
339
+ [
340
+ { role: "user", content: [{ type: "text", text: "q" }] },
341
+ { role: "assistant", content: [{ type: "text", text: "a" }] },
342
+ ],
343
+ config,
344
+ );
345
+
346
+ const conv = parseConversationEntries(lines);
347
+ expect(conv[0].parentUuid).toBeNull();
348
+ expect(conv[1].parentUuid).toBe(conv[0].uuid);
349
+ });
350
+
351
+ it("emits one line per assistant block with shared message id", () => {
352
+ const lines = conversationTurnsToJsonlEntries(
353
+ [
354
+ {
355
+ role: "assistant",
356
+ content: [{ type: "text", text: "running" }],
357
+ toolCalls: [
358
+ {
359
+ toolCallId: "tc-1",
360
+ toolName: "Bash",
361
+ input: { command: "ls" },
362
+ result: "output",
363
+ },
364
+ ],
365
+ },
366
+ ],
367
+ config,
368
+ );
369
+
370
+ // No queue ops for assistant-only turn; text + tool_use + tool_result
371
+ const conv = parseConversationEntries(lines);
372
+ expect(conv).toHaveLength(3);
373
+
374
+ expect(conv[0].type).toBe("assistant");
375
+ expect(conv[0].message.content).toEqual([
376
+ { type: "text", text: "running" },
377
+ ]);
378
+ expect(conv[0].message.stop_reason).toBeNull();
379
+ expect(conv[0].message.model).toBe("claude-opus-4-6");
380
+ expect(conv[0].message.id).toMatch(/^msg_01[A-Za-z0-9]{24}$/);
381
+
382
+ expect(conv[1].type).toBe("assistant");
383
+ expect(conv[1].message.content).toEqual([
384
+ {
385
+ type: "tool_use",
386
+ id: "tc-1",
387
+ name: "Bash",
388
+ input: { command: "ls" },
389
+ },
390
+ ]);
391
+ expect(conv[1].message.stop_reason).toBe("tool_use");
392
+ expect(conv[1].message.id).toBe(conv[0].message.id);
393
+
394
+ expect(conv[2].type).toBe("user");
395
+ expect(conv[2].message.content[0]).toEqual({
396
+ type: "tool_result",
397
+ tool_use_id: "tc-1",
398
+ content: "output",
399
+ });
400
+ expect(conv[2].parentUuid).toBe(conv[1].uuid);
401
+ });
402
+
403
+ it("sets stop_reason only on last block, null on intermediate", () => {
404
+ const lines = conversationTurnsToJsonlEntries(
405
+ [
406
+ {
407
+ role: "assistant",
408
+ content: [
409
+ { type: "thinking", thinking: "hmm" } as unknown as {
410
+ type: "text";
411
+ text: string;
412
+ },
413
+ { type: "text", text: "answer" },
414
+ ],
415
+ },
416
+ ],
417
+ config,
418
+ );
419
+
420
+ const conv = parseConversationEntries(lines);
421
+ expect(conv).toHaveLength(2);
422
+ expect(conv[0].message.stop_reason).toBeNull();
423
+ expect(conv[1].message.stop_reason).toBe("end_turn");
424
+ expect(conv[0].message.id).toBe(conv[1].message.id);
425
+ });
426
+
427
+ it("skips tool results that are undefined", () => {
428
+ const lines = conversationTurnsToJsonlEntries(
429
+ [
430
+ {
431
+ role: "assistant",
432
+ content: [{ type: "text", text: "x" }],
433
+ toolCalls: [
434
+ {
435
+ toolCallId: "tc-1",
436
+ toolName: "Bash",
437
+ input: {},
438
+ },
439
+ ],
440
+ },
441
+ ],
442
+ config,
443
+ );
444
+
445
+ const conv = parseConversationEntries(lines);
446
+ expect(conv).toHaveLength(2);
447
+ expect(conv[0].type).toBe("assistant");
448
+ expect(conv[1].type).toBe("assistant");
449
+ expect(conv[1].message.content[0].type).toBe("tool_use");
450
+ });
451
+
452
+ it("serializes non-string tool results as JSON", () => {
453
+ const lines = conversationTurnsToJsonlEntries(
454
+ [
455
+ {
456
+ role: "assistant",
457
+ content: [{ type: "text", text: "x" }],
458
+ toolCalls: [
459
+ {
460
+ toolCallId: "tc-1",
461
+ toolName: "Read",
462
+ input: {},
463
+ result: { files: ["a.ts"] },
464
+ },
465
+ ],
466
+ },
467
+ ],
468
+ config,
469
+ );
470
+
471
+ const conv = parseConversationEntries(lines);
472
+ expect(conv[2].message.content[0].content).toBe(
473
+ JSON.stringify({ files: ["a.ts"] }),
474
+ );
475
+ });
476
+
477
+ it("falls back to space for empty user content", () => {
478
+ const lines = conversationTurnsToJsonlEntries(
479
+ [{ role: "user", content: [] }],
480
+ config,
481
+ );
482
+
483
+ const [parsed] = parseConversationEntries(lines);
484
+ expect(parsed.message.content).toEqual([{ type: "text", text: " " }]);
485
+ });
486
+
487
+ it("uses custom model and version from config", () => {
488
+ const lines = conversationTurnsToJsonlEntries(
489
+ [
490
+ { role: "user", content: [{ type: "text", text: "hi" }] },
491
+ { role: "assistant", content: [{ type: "text", text: "hello" }] },
492
+ ],
493
+ { sessionId: "s", cwd: "/", model: "claude-opus-4-6", version: "3.0.0" },
494
+ );
495
+
496
+ const conv = parseConversationEntries(lines);
497
+ expect(conv[0].version).toBe("3.0.0");
498
+ expect(conv[1].version).toBe("3.0.0");
499
+ expect(conv[1].message.model).toBe("claude-opus-4-6");
500
+ });
501
+
502
+ it("passes gitBranch, slug and permissionMode from config", () => {
503
+ const lines = conversationTurnsToJsonlEntries(
504
+ [
505
+ { role: "user", content: [{ type: "text", text: "hi" }] },
506
+ { role: "assistant", content: [{ type: "text", text: "hello" }] },
507
+ ],
508
+ {
509
+ sessionId: "s",
510
+ cwd: "/",
511
+ gitBranch: "feat/test",
512
+ slug: "custom-slug-name",
513
+ permissionMode: "plan",
514
+ },
515
+ );
516
+
517
+ const conv = parseConversationEntries(lines);
518
+ // User entry
519
+ expect(conv[0].gitBranch).toBe("feat/test");
520
+ expect(conv[0].slug).toBe("custom-slug-name");
521
+ expect(conv[0].permissionMode).toBe("plan");
522
+ // Assistant entry
523
+ expect(conv[1].gitBranch).toBe("feat/test");
524
+ expect(conv[1].slug).toBe("custom-slug-name");
525
+ // Assistant entries don't have permissionMode
526
+ expect(conv[1].permissionMode).toBeUndefined();
527
+ });
528
+ });
529
+
530
+ describe("end-to-end: S3 log entries -> JSONL output", () => {
531
+ const config = { sessionId: "sess-abc", cwd: "/home/user/repo" };
532
+
533
+ function s3Entry(
534
+ sessionUpdate: string,
535
+ extra: Record<string, unknown> = {},
536
+ ): StoredEntry {
537
+ return {
538
+ type: "notification",
539
+ timestamp: "2026-03-03T12:00:00.000Z",
540
+ notification: {
541
+ jsonrpc: "2.0",
542
+ method: "session/update",
543
+ params: { update: { sessionUpdate, ...extra } },
544
+ },
545
+ };
546
+ }
547
+
548
+ function filterConv(parsed: Record<string, unknown>[]) {
549
+ return parsed.filter((e) => e.type !== "queue-operation");
550
+ }
551
+
552
+ function filterQueue(parsed: Record<string, unknown>[]) {
553
+ return parsed.filter((e) => e.type === "queue-operation");
554
+ }
555
+
556
+ it("converts a multi-turn session with tool use into valid JSONL", () => {
557
+ const s3Logs: StoredEntry[] = [
558
+ s3Entry("user_message", {
559
+ content: { type: "text", text: "List the files in src/" },
560
+ }),
561
+ s3Entry("agent_message_chunk", {
562
+ content: { type: "thinking", thinking: "I should use Bash to run ls" },
563
+ }),
564
+ s3Entry("agent_message_chunk", {
565
+ content: { type: "text", text: "I'll list the files " },
566
+ }),
567
+ s3Entry("agent_message_chunk", {
568
+ content: { type: "text", text: "for you." },
569
+ }),
570
+ s3Entry("tool_call", {
571
+ _meta: {
572
+ claudeCode: {
573
+ toolCallId: "toolu_01ABC",
574
+ toolName: "Bash",
575
+ toolInput: { command: "ls src/" },
576
+ },
577
+ },
578
+ }),
579
+ s3Entry("tool_result", {
580
+ _meta: {
581
+ claudeCode: {
582
+ toolCallId: "toolu_01ABC",
583
+ toolResponse: "index.ts\nutils.ts\nconfig.ts",
584
+ },
585
+ },
586
+ }),
587
+ s3Entry("agent_message", {
588
+ content: {
589
+ type: "text",
590
+ text: "There are 3 files: index.ts, utils.ts and config.ts.",
591
+ },
592
+ }),
593
+ s3Entry("user_message", {
594
+ content: { type: "text", text: "Read index.ts" },
595
+ }),
596
+ s3Entry("agent_message_chunk", {
597
+ content: { type: "text", text: "Reading now." },
598
+ }),
599
+ s3Entry("tool_call", {
600
+ _meta: {
601
+ claudeCode: {
602
+ toolCallId: "toolu_02DEF",
603
+ toolName: "Read",
604
+ toolInput: { file_path: "/home/user/repo/src/index.ts" },
605
+ },
606
+ },
607
+ }),
608
+ s3Entry("tool_result", {
609
+ _meta: {
610
+ claudeCode: {
611
+ toolCallId: "toolu_02DEF",
612
+ toolResponse: 'export const main = () => console.log("hello");',
613
+ },
614
+ },
615
+ }),
616
+ s3Entry("agent_message", {
617
+ content: {
618
+ type: "text",
619
+ text: "The file exports a main function that logs hello.",
620
+ },
621
+ }),
622
+ ];
623
+
624
+ const turns = rebuildConversation(s3Logs);
625
+ const lines = conversationTurnsToJsonlEntries(turns, config);
626
+ const allParsed = lines.map((l) => JSON.parse(l));
627
+ const conv = filterConv(allParsed);
628
+ const queueOps = filterQueue(allParsed);
629
+
630
+ expect(turns).toHaveLength(4);
631
+ expect(turns.map((t) => t.role)).toEqual([
632
+ "user",
633
+ "assistant",
634
+ "user",
635
+ "assistant",
636
+ ]);
637
+
638
+ const firstAssistant = turns[1];
639
+ const thinkingBlocks = firstAssistant.content.filter(
640
+ (b) =>
641
+ typeof b === "object" &&
642
+ b !== null &&
643
+ "type" in b &&
644
+ (b as { type: string }).type === "thinking",
645
+ );
646
+ expect(thinkingBlocks).toHaveLength(1);
647
+
648
+ const textBlocks = firstAssistant.content.filter(
649
+ (b) =>
650
+ typeof b === "object" && b !== null && "type" in b && b.type === "text",
651
+ );
652
+ expect(textBlocks).toHaveLength(1);
653
+ const firstText = (textBlocks[0] as { type: "text"; text: string }).text;
654
+ expect(firstText).toContain("I'll list the files for you.");
655
+ expect(firstText).toContain("There are 3 files");
656
+
657
+ expect(firstAssistant.toolCalls).toHaveLength(1);
658
+ expect(firstAssistant.toolCalls?.[0].toolName).toBe("Bash");
659
+ expect(firstAssistant.toolCalls?.[0].result).toBe(
660
+ "index.ts\nutils.ts\nconfig.ts",
661
+ );
662
+
663
+ // 2 user turns → 4 queue-operation entries (enqueue + dequeue each)
664
+ expect(queueOps).toHaveLength(4);
665
+
666
+ // Conversation entries (excluding queue ops):
667
+ // user, thinking, text, tool_use(Bash), tool_result(Bash),
668
+ // user, text, tool_use(Read), tool_result(Read)
669
+ const types = conv.map((p) => p.type);
670
+ expect(types).toEqual([
671
+ "user",
672
+ "assistant",
673
+ "assistant",
674
+ "assistant",
675
+ "user",
676
+ "user",
677
+ "assistant",
678
+ "assistant",
679
+ "user",
680
+ ]);
681
+
682
+ // Verify parentUuid chaining (only conversation entries participate)
683
+ expect(conv[0].parentUuid).toBeNull();
684
+ for (let i = 1; i < conv.length; i++) {
685
+ expect(conv[i].parentUuid).toBe(conv[i - 1].uuid);
686
+ }
687
+
688
+ // Verify all conversation entries have required fields
689
+ for (const e of conv) {
690
+ expect(e.sessionId).toBe("sess-abc");
691
+ expect(e.cwd).toBe("/home/user/repo");
692
+ expect(e.isSidechain).toBe(false);
693
+ expect(e.uuid).toBeDefined();
694
+ expect(e.timestamp).toBeDefined();
695
+ expect(e.version).toBe("2.1.63");
696
+ expect(e.gitBranch).toBeDefined();
697
+ expect(e.slug).toBeDefined();
698
+ expect(typeof e.slug).toBe("string");
699
+ }
700
+
701
+ // Verify first user message content (array format)
702
+ expect((conv[0].message as Record<string, unknown>).content).toEqual([
703
+ { type: "text", text: "List the files in src/" },
704
+ ]);
705
+
706
+ // Verify thinking block: stop_reason null (intermediate)
707
+ const msg1 = conv[1].message as Record<string, unknown>;
708
+ expect((msg1.content as unknown[])[0]).toMatchObject({ type: "thinking" });
709
+ expect(msg1.stop_reason).toBeNull();
710
+
711
+ // Verify text block: stop_reason null (intermediate)
712
+ const msg2 = conv[2].message as Record<string, unknown>;
713
+ expect((msg2.content as unknown[])[0]).toMatchObject({ type: "text" });
714
+ expect(msg2.stop_reason).toBeNull();
715
+
716
+ // Verify tool_use block: stop_reason "tool_use" (last block in turn)
717
+ const msg3 = conv[3].message as Record<string, unknown>;
718
+ expect(msg3.content).toEqual([
719
+ {
720
+ type: "tool_use",
721
+ id: "toolu_01ABC",
722
+ name: "Bash",
723
+ input: { command: "ls src/" },
724
+ },
725
+ ]);
726
+ expect(msg3.stop_reason).toBe("tool_use");
727
+
728
+ // All assistant blocks in same turn share message.id
729
+ expect(msg1.id).toBe(msg2.id);
730
+ expect(msg2.id).toBe(msg3.id);
731
+ expect(msg3.model).toBe("claude-opus-4-6");
732
+ expect(msg3.id).toMatch(/^msg_01[A-Za-z0-9]{24}$/);
733
+
734
+ // Verify Bash tool_result entry
735
+ const msg4 = conv[4].message as {
736
+ content: { tool_use_id: string; content: string; type: string }[];
737
+ };
738
+ expect(msg4.content[0]).toEqual({
739
+ type: "tool_result",
740
+ tool_use_id: "toolu_01ABC",
741
+ content: "index.ts\nutils.ts\nconfig.ts",
742
+ });
743
+
744
+ // Verify second user message (array format)
745
+ expect((conv[5].message as Record<string, unknown>).content).toEqual([
746
+ { type: "text", text: "Read index.ts" },
747
+ ]);
748
+
749
+ // Second assistant turn blocks share a different message.id
750
+ const msg6 = conv[6].message as Record<string, unknown>;
751
+ const msg7 = conv[7].message as Record<string, unknown>;
752
+ expect(msg6.id).toBe(msg7.id);
753
+ expect(msg6.id).not.toBe(msg1.id);
754
+
755
+ // Verify Read tool_result entry
756
+ const msg8 = conv[8].message as {
757
+ content: { tool_use_id: string; content: string; type: string }[];
758
+ };
759
+ expect(msg8.content[0]).toEqual({
760
+ type: "tool_result",
761
+ tool_use_id: "toolu_02DEF",
762
+ content: 'export const main = () => console.log("hello");',
763
+ });
764
+ });
765
+
766
+ it("handles a session with only user messages and no agent response", () => {
767
+ const s3Logs: StoredEntry[] = [
768
+ s3Entry("user_message", {
769
+ content: { type: "text", text: "hello" },
770
+ }),
771
+ ];
772
+
773
+ const turns = rebuildConversation(s3Logs);
774
+ const lines = conversationTurnsToJsonlEntries(turns, config);
775
+ const conv = filterConv(lines.map((l) => JSON.parse(l)));
776
+
777
+ expect(turns).toHaveLength(1);
778
+ // enqueue + dequeue + user = 3 total lines, 1 conversation entry
779
+ expect(lines).toHaveLength(3);
780
+ expect(conv).toHaveLength(1);
781
+
782
+ expect(conv[0].type).toBe("user");
783
+ expect((conv[0].message as Record<string, unknown>).content).toEqual([
784
+ { type: "text", text: "hello" },
785
+ ]);
786
+ });
787
+
788
+ it("handles interleaved non-session/update entries gracefully", () => {
789
+ const s3Logs: StoredEntry[] = [
790
+ s3Entry("user_message", {
791
+ content: { type: "text", text: "hi" },
792
+ }),
793
+ {
794
+ type: "notification",
795
+ timestamp: "2026-03-03T12:00:01.000Z",
796
+ notification: {
797
+ jsonrpc: "2.0",
798
+ method: "_posthog/phase_start",
799
+ params: { phase: "research" },
800
+ },
801
+ },
802
+ s3Entry("agent_message", {
803
+ content: { type: "text", text: "hello back" },
804
+ }),
805
+ ];
806
+
807
+ const turns = rebuildConversation(s3Logs);
808
+ expect(turns).toHaveLength(2);
809
+ expect(turns[0].role).toBe("user");
810
+ expect(turns[1].role).toBe("assistant");
811
+
812
+ const lines = conversationTurnsToJsonlEntries(turns, config);
813
+ const conv = filterConv(lines.map((l) => JSON.parse(l)));
814
+ // 1 user turn → 2 queue ops + user + assistant = 4 total, 2 conversation
815
+ expect(lines).toHaveLength(4);
816
+ expect(conv).toHaveLength(2);
817
+ });
818
+
819
+ it("handles multiple tool calls in a single assistant turn", () => {
820
+ const s3Logs: StoredEntry[] = [
821
+ s3Entry("user_message", {
822
+ content: { type: "text", text: "check both files" },
823
+ }),
824
+ s3Entry("agent_message", {
825
+ content: { type: "text", text: "Reading both." },
826
+ }),
827
+ s3Entry("tool_call", {
828
+ _meta: {
829
+ claudeCode: {
830
+ toolCallId: "tc-a",
831
+ toolName: "Read",
832
+ toolInput: { file_path: "/a.ts" },
833
+ },
834
+ },
835
+ }),
836
+ s3Entry("tool_call", {
837
+ _meta: {
838
+ claudeCode: {
839
+ toolCallId: "tc-b",
840
+ toolName: "Read",
841
+ toolInput: { file_path: "/b.ts" },
842
+ },
843
+ },
844
+ }),
845
+ s3Entry("tool_result", {
846
+ _meta: { claudeCode: { toolCallId: "tc-a", toolResponse: "aaa" } },
847
+ }),
848
+ s3Entry("tool_result", {
849
+ _meta: { claudeCode: { toolCallId: "tc-b", toolResponse: "bbb" } },
850
+ }),
851
+ ];
852
+
853
+ const turns = rebuildConversation(s3Logs);
854
+ expect(turns).toHaveLength(2);
855
+
856
+ const assistant = turns[1];
857
+ expect(assistant.toolCalls).toHaveLength(2);
858
+ expect(assistant.toolCalls?.[0]).toMatchObject({
859
+ toolCallId: "tc-a",
860
+ result: "aaa",
861
+ });
862
+ expect(assistant.toolCalls?.[1]).toMatchObject({
863
+ toolCallId: "tc-b",
864
+ result: "bbb",
865
+ });
866
+
867
+ const lines = conversationTurnsToJsonlEntries(turns, config);
868
+ const conv = filterConv(lines.map((l) => JSON.parse(l)));
869
+
870
+ // user, text, tool_use(a), tool_use(b), tool_result(a), tool_result(b)
871
+ expect(conv).toHaveLength(6);
872
+ expect(conv.map((p) => p.type)).toEqual([
873
+ "user",
874
+ "assistant",
875
+ "assistant",
876
+ "assistant",
877
+ "user",
878
+ "user",
879
+ ]);
880
+
881
+ // Text block: stop_reason null (intermediate)
882
+ expect((conv[1].message as Record<string, unknown>).stop_reason).toBeNull();
883
+
884
+ // First tool_use: stop_reason null (intermediate)
885
+ const msg2 = conv[2].message as Record<string, unknown>;
886
+ expect(msg2.stop_reason).toBeNull();
887
+ expect(((msg2.content as unknown[])[0] as Record<string, unknown>).id).toBe(
888
+ "tc-a",
889
+ );
890
+
891
+ // Last tool_use: stop_reason "tool_use" (last block)
892
+ const msg3 = conv[3].message as Record<string, unknown>;
893
+ expect(msg3.stop_reason).toBe("tool_use");
894
+ expect(((msg3.content as unknown[])[0] as Record<string, unknown>).id).toBe(
895
+ "tc-b",
896
+ );
897
+
898
+ // All share same message.id
899
+ const msg1 = conv[1].message as Record<string, unknown>;
900
+ expect(msg1.id).toBe(msg2.id);
901
+ expect(msg2.id).toBe(msg3.id);
902
+ });
903
+ });