@towles/tool 0.0.87 → 0.0.89
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/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
|
|
3
4
|
import consola from "consola";
|
|
4
5
|
import pc from "picocolors";
|
|
5
6
|
|
|
7
|
+
import { readFile } from "../../utils/fs.js";
|
|
6
8
|
import { getConfig } from "./config.js";
|
|
7
9
|
import { sleep } from "./shell.js";
|
|
8
10
|
import { spawnClaude as defaultSpawnClaude } from "./spawn-claude.js";
|
|
9
11
|
import type { SpawnClaudeFn } from "./spawn-claude.js";
|
|
12
|
+
import { parseStreamLine } from "./stream-parser.js";
|
|
10
13
|
|
|
11
14
|
// ── Claude CLI ──
|
|
12
15
|
|
|
@@ -52,6 +55,20 @@ export async function runClaude(opts: {
|
|
|
52
55
|
|
|
53
56
|
log.info(`${pc.dim("▶")} Calling Claude${opts.maxTurns ? ` (max ${opts.maxTurns} turns)` : ""}…`);
|
|
54
57
|
|
|
58
|
+
try {
|
|
59
|
+
const systemPrompt = readFile(join(process.cwd(), "CLAUDE.md"));
|
|
60
|
+
log.info(
|
|
61
|
+
`\n${pc.bold(pc.cyan("── System Prompt (CLAUDE.md) ──"))}\n${pc.dim(systemPrompt.trimEnd())}\n`,
|
|
62
|
+
);
|
|
63
|
+
} catch { /* CLAUDE.md not present */ }
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const promptContent = readFile(join(process.cwd(), opts.promptFile));
|
|
67
|
+
log.info(
|
|
68
|
+
`\n${pc.bold(pc.cyan(`── Prompt (${opts.promptFile}) ──`))}\n${pc.dim(promptContent.trimEnd())}\n`,
|
|
69
|
+
);
|
|
70
|
+
} catch { /* prompt file not present */ }
|
|
71
|
+
|
|
55
72
|
let lastError: Error | undefined;
|
|
56
73
|
for (let attempt = 1; attempt <= PROCESS_RETRIES; attempt++) {
|
|
57
74
|
try {
|
|
@@ -74,6 +91,31 @@ export async function runClaude(opts: {
|
|
|
74
91
|
throw lastError ?? new Error("runClaude failed after all retries");
|
|
75
92
|
}
|
|
76
93
|
|
|
94
|
+
function logActivityEvent(event: ReturnType<typeof parseStreamLine>, log: ClaudeLogger): void {
|
|
95
|
+
if (!event) return;
|
|
96
|
+
|
|
97
|
+
switch (event.kind) {
|
|
98
|
+
case "tool_use":
|
|
99
|
+
log.info(
|
|
100
|
+
` ${pc.dim("\u21B3")} ${event.name}${event.detail ? pc.dim(` ${event.detail}`) : ""}`,
|
|
101
|
+
);
|
|
102
|
+
break;
|
|
103
|
+
case "thinking":
|
|
104
|
+
log.info(
|
|
105
|
+
` ${pc.dim("\u21B3")} ${pc.italic("thinking")}${event.summary ? pc.dim(` ${event.summary}`) : ""}`,
|
|
106
|
+
);
|
|
107
|
+
break;
|
|
108
|
+
case "text":
|
|
109
|
+
if (event.content.trim()) {
|
|
110
|
+
log.info(` ${pc.dim("\u21B3")} ${pc.dim(event.content.split("\n")[0].trim())}`);
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case "result":
|
|
114
|
+
// Handled separately via capturedResult
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
77
119
|
function runClaudeStreaming(
|
|
78
120
|
args: string[],
|
|
79
121
|
spawnFn: SpawnClaudeFn,
|
|
@@ -93,19 +135,28 @@ function runClaudeStreaming(
|
|
|
93
135
|
|
|
94
136
|
rl.on("line", (line) => {
|
|
95
137
|
if (!line.trim()) return;
|
|
138
|
+
|
|
139
|
+
const activity = parseStreamLine(line);
|
|
140
|
+
logActivityEvent(activity, log);
|
|
141
|
+
|
|
142
|
+
if (activity?.kind === "result") {
|
|
143
|
+
capturedResult = {
|
|
144
|
+
result: "",
|
|
145
|
+
is_error: activity.isError,
|
|
146
|
+
total_cost_usd: activity.costUsd,
|
|
147
|
+
num_turns: activity.numTurns,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Track turn count from intermediate events (parser returns null for these)
|
|
96
152
|
try {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
turnCount =
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if ("result" in
|
|
103
|
-
capturedResult =
|
|
104
|
-
result: String(event.result ?? ""),
|
|
105
|
-
is_error: Boolean(event.is_error),
|
|
106
|
-
total_cost_usd: Number(event.total_cost_usd ?? 0),
|
|
107
|
-
num_turns: Number(event.num_turns),
|
|
108
|
-
};
|
|
153
|
+
const raw = JSON.parse(line) as Record<string, unknown>;
|
|
154
|
+
if (typeof raw.num_turns === "number" && !("result" in raw)) {
|
|
155
|
+
turnCount = raw.num_turns as number;
|
|
156
|
+
}
|
|
157
|
+
// Capture the result text from the final event
|
|
158
|
+
if ("result" in raw && capturedResult) {
|
|
159
|
+
capturedResult.result = String(raw.result ?? "");
|
|
109
160
|
}
|
|
110
161
|
} catch {
|
|
111
162
|
// Skip non-JSON lines
|
|
@@ -129,74 +180,3 @@ function runClaudeStreaming(
|
|
|
129
180
|
});
|
|
130
181
|
});
|
|
131
182
|
}
|
|
132
|
-
|
|
133
|
-
function truncate(s: string, max: number): string {
|
|
134
|
-
return s.length > max ? s.slice(0, max) + "\u2026" : s;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function toolDetail(block: Record<string, unknown>): string {
|
|
138
|
-
const input =
|
|
139
|
-
typeof block.input === "object" && block.input !== null
|
|
140
|
-
? (block.input as Record<string, unknown>)
|
|
141
|
-
: null;
|
|
142
|
-
if (!input) return "";
|
|
143
|
-
|
|
144
|
-
const filePath = input.file_path ?? input.path;
|
|
145
|
-
if (typeof filePath === "string") {
|
|
146
|
-
let detail = pc.dim(` ${filePath}`);
|
|
147
|
-
// Show edit context for Edit tool
|
|
148
|
-
if (typeof input.old_string === "string" && typeof input.new_string === "string") {
|
|
149
|
-
const old = truncate(input.old_string.split("\n")[0].trim(), 40);
|
|
150
|
-
const replacement = truncate(input.new_string.split("\n")[0].trim(), 40);
|
|
151
|
-
detail += pc.dim(` "${old}" → "${replacement}"`);
|
|
152
|
-
}
|
|
153
|
-
return detail;
|
|
154
|
-
}
|
|
155
|
-
if (typeof input.pattern === "string") return pc.dim(` ${input.pattern}`);
|
|
156
|
-
if (typeof input.command === "string") {
|
|
157
|
-
return pc.dim(` ${truncate(input.command, 60)}`);
|
|
158
|
-
}
|
|
159
|
-
// TodoWrite/TaskCreate — show subject
|
|
160
|
-
if (typeof input.subject === "string") return pc.dim(` ${truncate(input.subject, 60)}`);
|
|
161
|
-
return "";
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function logToolUse(block: Record<string, unknown>, log: ClaudeLogger): void {
|
|
165
|
-
const name = block.name;
|
|
166
|
-
if (typeof name === "string") {
|
|
167
|
-
log.info(` ${pc.dim("\u21B3")} ${name}${toolDetail(block)}`);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function handleStreamEvent(
|
|
172
|
-
event: Record<string, unknown>,
|
|
173
|
-
log: ClaudeLogger,
|
|
174
|
-
onTurn: (count: number) => void,
|
|
175
|
-
): void {
|
|
176
|
-
// Only handle stream_event — assistant turn events duplicate the same tools
|
|
177
|
-
if (event.type === "stream_event" && typeof event.event === "object" && event.event !== null) {
|
|
178
|
-
const inner = event.event as Record<string, unknown>;
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
inner.type === "content_block_start" &&
|
|
182
|
-
typeof inner.content_block === "object" &&
|
|
183
|
-
inner.content_block !== null
|
|
184
|
-
) {
|
|
185
|
-
const block = inner.content_block as Record<string, unknown>;
|
|
186
|
-
if (block.type === "tool_use") {
|
|
187
|
-
logToolUse(block, log);
|
|
188
|
-
} else if (block.type === "thinking") {
|
|
189
|
-
const thinkingText =
|
|
190
|
-
typeof block.thinking === "string" && block.thinking.length > 0
|
|
191
|
-
? pc.dim(` ${truncate(block.thinking.split("\n")[0].trim(), 60)}`)
|
|
192
|
-
: "";
|
|
193
|
-
log.info(` ${pc.dim("\u21B3")} ${pc.italic("thinking")}${thinkingText}`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Turn count tracking
|
|
199
|
-
if (typeof event.num_turns === "number" && !("result" in event)) {
|
|
200
|
-
onTurn(event.num_turns as number);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseStreamLine } from "./stream-parser";
|
|
3
|
+
|
|
4
|
+
describe("parseStreamLine", () => {
|
|
5
|
+
it("returns null for empty lines", () => {
|
|
6
|
+
expect(parseStreamLine("")).toBeNull();
|
|
7
|
+
expect(parseStreamLine(" ")).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns null for invalid JSON", () => {
|
|
11
|
+
expect(parseStreamLine("not json")).toBeNull();
|
|
12
|
+
expect(parseStreamLine("{broken")).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns null for unrecognized event types", () => {
|
|
16
|
+
expect(parseStreamLine(JSON.stringify({ type: "ping" }))).toBeNull();
|
|
17
|
+
expect(parseStreamLine(JSON.stringify({ type: "system", data: {} }))).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("result events", () => {
|
|
21
|
+
it("parses a result event", () => {
|
|
22
|
+
const line = JSON.stringify({
|
|
23
|
+
result: "Task completed",
|
|
24
|
+
is_error: false,
|
|
25
|
+
num_turns: 5,
|
|
26
|
+
cost_usd: 0.0342,
|
|
27
|
+
duration_ms: 12000,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(parseStreamLine(line)).toEqual({
|
|
31
|
+
kind: "result",
|
|
32
|
+
costUsd: 0.0342,
|
|
33
|
+
durationMs: 12000,
|
|
34
|
+
numTurns: 5,
|
|
35
|
+
isError: false,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("parses an error result", () => {
|
|
40
|
+
const line = JSON.stringify({
|
|
41
|
+
result: "Failed",
|
|
42
|
+
is_error: true,
|
|
43
|
+
num_turns: 1,
|
|
44
|
+
cost_usd: 0.001,
|
|
45
|
+
duration_ms: 500,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(parseStreamLine(line)).toEqual({
|
|
49
|
+
kind: "result",
|
|
50
|
+
costUsd: 0.001,
|
|
51
|
+
durationMs: 500,
|
|
52
|
+
numTurns: 1,
|
|
53
|
+
isError: true,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("defaults cost and duration to 0 when missing", () => {
|
|
58
|
+
const line = JSON.stringify({
|
|
59
|
+
result: "done",
|
|
60
|
+
is_error: false,
|
|
61
|
+
num_turns: 3,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const event = parseStreamLine(line);
|
|
65
|
+
expect(event?.kind).toBe("result");
|
|
66
|
+
if (event?.kind === "result") {
|
|
67
|
+
expect(event.costUsd).toBe(0);
|
|
68
|
+
expect(event.durationMs).toBe(0);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("handles total_cost_usd field name", () => {
|
|
73
|
+
const line = JSON.stringify({
|
|
74
|
+
result: "done",
|
|
75
|
+
is_error: false,
|
|
76
|
+
num_turns: 2,
|
|
77
|
+
total_cost_usd: 0.05,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const event = parseStreamLine(line);
|
|
81
|
+
if (event?.kind === "result") {
|
|
82
|
+
expect(event.costUsd).toBe(0.05);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("stream_event tool_use", () => {
|
|
88
|
+
it("parses a tool_use event with file_path input", () => {
|
|
89
|
+
const line = JSON.stringify({
|
|
90
|
+
type: "stream_event",
|
|
91
|
+
event: {
|
|
92
|
+
type: "content_block_start",
|
|
93
|
+
content_block: {
|
|
94
|
+
type: "tool_use",
|
|
95
|
+
name: "Read",
|
|
96
|
+
input: { file_path: "/home/user/code/main.ts" },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(parseStreamLine(line)).toEqual({
|
|
102
|
+
kind: "tool_use",
|
|
103
|
+
name: "Read",
|
|
104
|
+
detail: "/home/user/code/main.ts",
|
|
105
|
+
input: { file_path: "/home/user/code/main.ts" },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("parses Edit tool with old/new string detail", () => {
|
|
110
|
+
const line = JSON.stringify({
|
|
111
|
+
type: "stream_event",
|
|
112
|
+
event: {
|
|
113
|
+
type: "content_block_start",
|
|
114
|
+
content_block: {
|
|
115
|
+
type: "tool_use",
|
|
116
|
+
name: "Edit",
|
|
117
|
+
input: {
|
|
118
|
+
file_path: "src/index.ts",
|
|
119
|
+
old_string: "const x = 1;",
|
|
120
|
+
new_string: "const x = 2;",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const event = parseStreamLine(line);
|
|
127
|
+
expect(event?.kind).toBe("tool_use");
|
|
128
|
+
if (event?.kind === "tool_use") {
|
|
129
|
+
expect(event.name).toBe("Edit");
|
|
130
|
+
expect(event.detail).toContain("src/index.ts");
|
|
131
|
+
expect(event.detail).toContain('"const x = 1;"');
|
|
132
|
+
expect(event.detail).toContain('"const x = 2;"');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("parses Bash tool with command detail", () => {
|
|
137
|
+
const line = JSON.stringify({
|
|
138
|
+
type: "stream_event",
|
|
139
|
+
event: {
|
|
140
|
+
type: "content_block_start",
|
|
141
|
+
content_block: {
|
|
142
|
+
type: "tool_use",
|
|
143
|
+
name: "Bash",
|
|
144
|
+
input: { command: "pnpm test" },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const event = parseStreamLine(line);
|
|
150
|
+
if (event?.kind === "tool_use") {
|
|
151
|
+
expect(event.name).toBe("Bash");
|
|
152
|
+
expect(event.detail).toBe("pnpm test");
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("parses Grep tool with pattern detail", () => {
|
|
157
|
+
const line = JSON.stringify({
|
|
158
|
+
type: "stream_event",
|
|
159
|
+
event: {
|
|
160
|
+
type: "content_block_start",
|
|
161
|
+
content_block: {
|
|
162
|
+
type: "tool_use",
|
|
163
|
+
name: "Grep",
|
|
164
|
+
input: { pattern: "parseStreamLine" },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const event = parseStreamLine(line);
|
|
170
|
+
if (event?.kind === "tool_use") {
|
|
171
|
+
expect(event.name).toBe("Grep");
|
|
172
|
+
expect(event.detail).toBe("parseStreamLine");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("returns empty detail for tool with no recognized input fields", () => {
|
|
177
|
+
const line = JSON.stringify({
|
|
178
|
+
type: "stream_event",
|
|
179
|
+
event: {
|
|
180
|
+
type: "content_block_start",
|
|
181
|
+
content_block: {
|
|
182
|
+
type: "tool_use",
|
|
183
|
+
name: "CustomTool",
|
|
184
|
+
input: { some_field: "value" },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const event = parseStreamLine(line);
|
|
190
|
+
if (event?.kind === "tool_use") {
|
|
191
|
+
expect(event.detail).toBe("");
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("handles tool_use with no input", () => {
|
|
196
|
+
const line = JSON.stringify({
|
|
197
|
+
type: "stream_event",
|
|
198
|
+
event: {
|
|
199
|
+
type: "content_block_start",
|
|
200
|
+
content_block: { type: "tool_use", name: "SomeTool" },
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const event = parseStreamLine(line);
|
|
205
|
+
if (event?.kind === "tool_use") {
|
|
206
|
+
expect(event.input).toEqual({});
|
|
207
|
+
expect(event.detail).toBe("");
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("stream_event thinking", () => {
|
|
213
|
+
it("parses a thinking event", () => {
|
|
214
|
+
const line = JSON.stringify({
|
|
215
|
+
type: "stream_event",
|
|
216
|
+
event: {
|
|
217
|
+
type: "content_block_start",
|
|
218
|
+
content_block: {
|
|
219
|
+
type: "thinking",
|
|
220
|
+
thinking: "I need to read the file first.",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(parseStreamLine(line)).toEqual({
|
|
226
|
+
kind: "thinking",
|
|
227
|
+
summary: "I need to read the file first.",
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("truncates long thinking to 120 chars", () => {
|
|
232
|
+
const longThinking = "A".repeat(200);
|
|
233
|
+
const line = JSON.stringify({
|
|
234
|
+
type: "stream_event",
|
|
235
|
+
event: {
|
|
236
|
+
type: "content_block_start",
|
|
237
|
+
content_block: { type: "thinking", thinking: longThinking },
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const event = parseStreamLine(line);
|
|
242
|
+
if (event?.kind === "thinking") {
|
|
243
|
+
expect(event.summary.length).toBeLessThanOrEqual(121);
|
|
244
|
+
expect(event.summary).toContain("\u2026");
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("uses only first line of multi-line thinking", () => {
|
|
249
|
+
const line = JSON.stringify({
|
|
250
|
+
type: "stream_event",
|
|
251
|
+
event: {
|
|
252
|
+
type: "content_block_start",
|
|
253
|
+
content_block: {
|
|
254
|
+
type: "thinking",
|
|
255
|
+
thinking: "First line\nSecond line\nThird line",
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const event = parseStreamLine(line);
|
|
261
|
+
if (event?.kind === "thinking") {
|
|
262
|
+
expect(event.summary).toBe("First line");
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("handles empty thinking", () => {
|
|
267
|
+
const line = JSON.stringify({
|
|
268
|
+
type: "stream_event",
|
|
269
|
+
event: {
|
|
270
|
+
type: "content_block_start",
|
|
271
|
+
content_block: { type: "thinking" },
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const event = parseStreamLine(line);
|
|
276
|
+
if (event?.kind === "thinking") {
|
|
277
|
+
expect(event.summary).toBe("");
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("stream_event text", () => {
|
|
283
|
+
it("parses a text event", () => {
|
|
284
|
+
const line = JSON.stringify({
|
|
285
|
+
type: "stream_event",
|
|
286
|
+
event: {
|
|
287
|
+
type: "content_block_start",
|
|
288
|
+
content_block: {
|
|
289
|
+
type: "text",
|
|
290
|
+
text: "Here is the implementation plan:",
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(parseStreamLine(line)).toEqual({
|
|
296
|
+
kind: "text",
|
|
297
|
+
content: "Here is the implementation plan:",
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("assistant message format", () => {
|
|
303
|
+
it("parses tool_use from assistant message content array", () => {
|
|
304
|
+
const line = JSON.stringify({
|
|
305
|
+
type: "assistant",
|
|
306
|
+
message: {
|
|
307
|
+
content: [
|
|
308
|
+
{
|
|
309
|
+
type: "tool_use",
|
|
310
|
+
name: "Write",
|
|
311
|
+
input: { file_path: "/tmp/test.ts", content: "hello" },
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const event = parseStreamLine(line);
|
|
318
|
+
expect(event?.kind).toBe("tool_use");
|
|
319
|
+
if (event?.kind === "tool_use") {
|
|
320
|
+
expect(event.name).toBe("Write");
|
|
321
|
+
expect(event.detail).toBe("/tmp/test.ts");
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("parses thinking from assistant message content array", () => {
|
|
326
|
+
const line = JSON.stringify({
|
|
327
|
+
type: "assistant",
|
|
328
|
+
message: {
|
|
329
|
+
content: [{ type: "thinking", thinking: "Let me think about this" }],
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
expect(parseStreamLine(line)).toEqual({
|
|
334
|
+
kind: "thinking",
|
|
335
|
+
summary: "Let me think about this",
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("parses text from assistant message content array", () => {
|
|
340
|
+
const line = JSON.stringify({
|
|
341
|
+
type: "assistant",
|
|
342
|
+
message: {
|
|
343
|
+
content: [{ type: "text", text: "I will now implement the feature." }],
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(parseStreamLine(line)).toEqual({
|
|
348
|
+
kind: "text",
|
|
349
|
+
content: "I will now implement the feature.",
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("returns null for empty content array", () => {
|
|
354
|
+
const line = JSON.stringify({
|
|
355
|
+
type: "assistant",
|
|
356
|
+
message: { content: [] },
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
expect(parseStreamLine(line)).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("skips unrecognized blocks and returns first recognized one", () => {
|
|
363
|
+
const line = JSON.stringify({
|
|
364
|
+
type: "assistant",
|
|
365
|
+
message: {
|
|
366
|
+
content: [
|
|
367
|
+
{ type: "unknown_block", data: "ignored" },
|
|
368
|
+
{ type: "tool_use", name: "Read", input: { file_path: "src/main.ts" } },
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const event = parseStreamLine(line);
|
|
374
|
+
expect(event?.kind).toBe("tool_use");
|
|
375
|
+
if (event?.kind === "tool_use") {
|
|
376
|
+
expect(event.name).toBe("Read");
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("ignores non-interesting events", () => {
|
|
382
|
+
it("ignores content_block_delta", () => {
|
|
383
|
+
const line = JSON.stringify({
|
|
384
|
+
type: "stream_event",
|
|
385
|
+
event: {
|
|
386
|
+
type: "content_block_delta",
|
|
387
|
+
delta: { type: "text_delta", text: "partial" },
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(parseStreamLine(line)).toBeNull();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("ignores message_start", () => {
|
|
395
|
+
const line = JSON.stringify({
|
|
396
|
+
type: "stream_event",
|
|
397
|
+
event: { type: "message_start", message: {} },
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(parseStreamLine(line)).toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("ignores message_stop", () => {
|
|
404
|
+
const line = JSON.stringify({
|
|
405
|
+
type: "stream_event",
|
|
406
|
+
event: { type: "message_stop" },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
expect(parseStreamLine(line)).toBeNull();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export interface AgentToolEvent {
|
|
2
|
+
kind: "tool_use";
|
|
3
|
+
name: string;
|
|
4
|
+
detail: string;
|
|
5
|
+
input: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AgentThinkingEvent {
|
|
9
|
+
kind: "thinking";
|
|
10
|
+
summary: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AgentTextEvent {
|
|
14
|
+
kind: "text";
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AgentResultEvent {
|
|
19
|
+
kind: "result";
|
|
20
|
+
costUsd: number;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
numTurns: number;
|
|
23
|
+
isError: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AgentActivityEvent =
|
|
27
|
+
| AgentToolEvent
|
|
28
|
+
| AgentThinkingEvent
|
|
29
|
+
| AgentTextEvent
|
|
30
|
+
| AgentResultEvent;
|
|
31
|
+
|
|
32
|
+
function truncate(s: string, max: number): string {
|
|
33
|
+
return s.length > max ? s.slice(0, max) + "\u2026" : s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toolDetail(block: Record<string, unknown>): string {
|
|
37
|
+
const input =
|
|
38
|
+
typeof block.input === "object" && block.input !== null
|
|
39
|
+
? (block.input as Record<string, unknown>)
|
|
40
|
+
: null;
|
|
41
|
+
if (!input) return "";
|
|
42
|
+
|
|
43
|
+
const filePath = input.file_path ?? input.path;
|
|
44
|
+
if (typeof filePath === "string") {
|
|
45
|
+
let detail = filePath;
|
|
46
|
+
if (typeof input.old_string === "string" && typeof input.new_string === "string") {
|
|
47
|
+
const old = truncate(input.old_string.split("\n")[0].trim(), 40);
|
|
48
|
+
const replacement = truncate(input.new_string.split("\n")[0].trim(), 40);
|
|
49
|
+
detail += ` "${old}" -> "${replacement}"`;
|
|
50
|
+
}
|
|
51
|
+
return detail;
|
|
52
|
+
}
|
|
53
|
+
if (typeof input.pattern === "string") return input.pattern;
|
|
54
|
+
if (typeof input.command === "string") return truncate(input.command, 60);
|
|
55
|
+
if (typeof input.subject === "string") return truncate(input.subject, 60);
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseContentBlock(block: Record<string, unknown>): AgentActivityEvent | null {
|
|
60
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
61
|
+
return {
|
|
62
|
+
kind: "tool_use",
|
|
63
|
+
name: block.name,
|
|
64
|
+
detail: toolDetail(block),
|
|
65
|
+
input:
|
|
66
|
+
typeof block.input === "object" && block.input !== null
|
|
67
|
+
? (block.input as Record<string, unknown>)
|
|
68
|
+
: {},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (block.type === "thinking") {
|
|
73
|
+
const text = typeof block.thinking === "string" ? block.thinking : "";
|
|
74
|
+
return {
|
|
75
|
+
kind: "thinking",
|
|
76
|
+
summary: truncate(text.split("\n")[0].trim(), 120),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
81
|
+
return {
|
|
82
|
+
kind: "text",
|
|
83
|
+
content: block.text,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Parse a single NDJSON line from Claude Code's stream-json output */
|
|
91
|
+
export function parseStreamLine(line: string): AgentActivityEvent | null {
|
|
92
|
+
if (!line.trim()) return null;
|
|
93
|
+
|
|
94
|
+
let event: Record<string, unknown>;
|
|
95
|
+
try {
|
|
96
|
+
event = JSON.parse(line) as Record<string, unknown>;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Result event — top-level with result/is_error/num_turns
|
|
102
|
+
if ("result" in event && "is_error" in event && "num_turns" in event) {
|
|
103
|
+
return {
|
|
104
|
+
kind: "result",
|
|
105
|
+
costUsd: Number(event.cost_usd ?? event.total_cost_usd ?? 0),
|
|
106
|
+
durationMs: Number(event.duration_ms ?? 0),
|
|
107
|
+
numTurns: Number(event.num_turns),
|
|
108
|
+
isError: Boolean(event.is_error),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Stream events — tool_use, thinking, text inside content_block_start
|
|
113
|
+
if (event.type === "stream_event" && typeof event.event === "object" && event.event !== null) {
|
|
114
|
+
const inner = event.event as Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
if (
|
|
117
|
+
inner.type === "content_block_start" &&
|
|
118
|
+
typeof inner.content_block === "object" &&
|
|
119
|
+
inner.content_block !== null
|
|
120
|
+
) {
|
|
121
|
+
return parseContentBlock(inner.content_block as Record<string, unknown>);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Assistant message events — content array with tool_use/thinking/text
|
|
126
|
+
if (event.type === "assistant" && typeof event.message === "object" && event.message !== null) {
|
|
127
|
+
const message = event.message as Record<string, unknown>;
|
|
128
|
+
if (Array.isArray(message.content)) {
|
|
129
|
+
for (const block of message.content) {
|
|
130
|
+
if (typeof block === "object" && block !== null) {
|
|
131
|
+
const parsed = parseContentBlock(block as Record<string, unknown>);
|
|
132
|
+
if (parsed) return parsed;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|