@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.
- package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +14 -28
- package/dist/adapters/claude/conversion/tool-use-to-acp.js +118 -165
- package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
- package/dist/adapters/claude/permissions/permission-options.js +33 -0
- package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
- package/dist/adapters/claude/session/jsonl-hydration.d.ts +45 -0
- package/dist/adapters/claude/session/jsonl-hydration.js +444 -0
- package/dist/adapters/claude/session/jsonl-hydration.js.map +1 -0
- package/dist/adapters/claude/tools.js +21 -11
- package/dist/adapters/claude/tools.js.map +1 -1
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +1261 -608
- package/dist/agent.js.map +1 -1
- package/dist/posthog-api.js +6 -2
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +1307 -657
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +1285 -637
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +8 -4
- package/src/adapters/base-acp-agent.ts +6 -3
- package/src/adapters/claude/UPSTREAM.md +63 -0
- package/src/adapters/claude/claude-agent.ts +682 -421
- package/src/adapters/claude/conversion/sdk-to-acp.ts +249 -85
- package/src/adapters/claude/conversion/tool-use-to-acp.ts +176 -150
- package/src/adapters/claude/hooks.ts +53 -1
- package/src/adapters/claude/permissions/permission-handlers.ts +39 -21
- package/src/adapters/claude/session/commands.ts +13 -9
- package/src/adapters/claude/session/jsonl-hydration.test.ts +903 -0
- package/src/adapters/claude/session/jsonl-hydration.ts +581 -0
- package/src/adapters/claude/session/mcp-config.ts +2 -5
- package/src/adapters/claude/session/options.ts +58 -6
- package/src/adapters/claude/session/settings.ts +326 -0
- package/src/adapters/claude/tools.ts +1 -0
- package/src/adapters/claude/types.ts +38 -0
- package/src/adapters/codex/spawn.ts +1 -1
- package/src/agent.ts +4 -0
- package/src/execution-mode.ts +26 -10
- package/src/server/agent-server.test.ts +41 -1
- 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
|
+
});
|