@katyella/legio 0.1.0
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/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Claude Code transcript JSONL parser.
|
|
3
|
+
*
|
|
4
|
+
* Uses temp files with real-format JSONL data. No mocks.
|
|
5
|
+
* Philosophy: "never mock what you can use for real" (mx-252b16).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
12
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
13
|
+
import {
|
|
14
|
+
estimateCost,
|
|
15
|
+
extractAssistantText,
|
|
16
|
+
parseTranscriptTexts,
|
|
17
|
+
parseTranscriptUsage,
|
|
18
|
+
} from "./transcript.ts";
|
|
19
|
+
|
|
20
|
+
let tempDir: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
tempDir = await mkdtemp(join(tmpdir(), "legio-transcript-test-"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await cleanupTempDir(tempDir);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Write a JSONL file with the given lines. */
|
|
31
|
+
async function writeJsonl(filename: string, lines: unknown[]): Promise<string> {
|
|
32
|
+
const path = join(tempDir, filename);
|
|
33
|
+
const content = `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`;
|
|
34
|
+
await writeFile(path, content, "utf8");
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// === parseTranscriptUsage ===
|
|
39
|
+
|
|
40
|
+
describe("parseTranscriptUsage", () => {
|
|
41
|
+
test("parses a single assistant entry with all usage fields", async () => {
|
|
42
|
+
const path = await writeJsonl("single.jsonl", [
|
|
43
|
+
{
|
|
44
|
+
type: "assistant",
|
|
45
|
+
message: {
|
|
46
|
+
model: "claude-opus-4-6",
|
|
47
|
+
usage: {
|
|
48
|
+
input_tokens: 100,
|
|
49
|
+
output_tokens: 50,
|
|
50
|
+
cache_read_input_tokens: 1000,
|
|
51
|
+
cache_creation_input_tokens: 500,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const usage = await parseTranscriptUsage(path);
|
|
58
|
+
|
|
59
|
+
expect(usage.inputTokens).toBe(100);
|
|
60
|
+
expect(usage.outputTokens).toBe(50);
|
|
61
|
+
expect(usage.cacheReadTokens).toBe(1000);
|
|
62
|
+
expect(usage.cacheCreationTokens).toBe(500);
|
|
63
|
+
expect(usage.modelUsed).toBe("claude-opus-4-6");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("aggregates usage across multiple assistant turns", async () => {
|
|
67
|
+
const path = await writeJsonl("multi.jsonl", [
|
|
68
|
+
{
|
|
69
|
+
type: "assistant",
|
|
70
|
+
message: {
|
|
71
|
+
model: "claude-sonnet-4-20250514",
|
|
72
|
+
usage: {
|
|
73
|
+
input_tokens: 100,
|
|
74
|
+
output_tokens: 50,
|
|
75
|
+
cache_read_input_tokens: 1000,
|
|
76
|
+
cache_creation_input_tokens: 500,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
type: "human",
|
|
82
|
+
message: { content: "follow-up question" },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: "assistant",
|
|
86
|
+
message: {
|
|
87
|
+
model: "claude-sonnet-4-20250514",
|
|
88
|
+
usage: {
|
|
89
|
+
input_tokens: 200,
|
|
90
|
+
output_tokens: 75,
|
|
91
|
+
cache_read_input_tokens: 2000,
|
|
92
|
+
cache_creation_input_tokens: 0,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const usage = await parseTranscriptUsage(path);
|
|
99
|
+
|
|
100
|
+
expect(usage.inputTokens).toBe(300);
|
|
101
|
+
expect(usage.outputTokens).toBe(125);
|
|
102
|
+
expect(usage.cacheReadTokens).toBe(3000);
|
|
103
|
+
expect(usage.cacheCreationTokens).toBe(500);
|
|
104
|
+
expect(usage.modelUsed).toBe("claude-sonnet-4-20250514");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("skips non-assistant entries (human, system, tool_use, etc.)", async () => {
|
|
108
|
+
const path = await writeJsonl("mixed.jsonl", [
|
|
109
|
+
{ type: "system", content: "system prompt" },
|
|
110
|
+
{
|
|
111
|
+
type: "assistant",
|
|
112
|
+
message: {
|
|
113
|
+
model: "claude-opus-4-6",
|
|
114
|
+
usage: {
|
|
115
|
+
input_tokens: 100,
|
|
116
|
+
output_tokens: 50,
|
|
117
|
+
cache_read_input_tokens: 0,
|
|
118
|
+
cache_creation_input_tokens: 0,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{ type: "human", message: { content: "hello" } },
|
|
123
|
+
{ type: "tool_result", content: "result" },
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const usage = await parseTranscriptUsage(path);
|
|
127
|
+
|
|
128
|
+
expect(usage.inputTokens).toBe(100);
|
|
129
|
+
expect(usage.outputTokens).toBe(50);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("returns zeros for empty file", async () => {
|
|
133
|
+
const path = join(tempDir, "empty.jsonl");
|
|
134
|
+
await writeFile(path, "", "utf8");
|
|
135
|
+
|
|
136
|
+
const usage = await parseTranscriptUsage(path);
|
|
137
|
+
|
|
138
|
+
expect(usage.inputTokens).toBe(0);
|
|
139
|
+
expect(usage.outputTokens).toBe(0);
|
|
140
|
+
expect(usage.cacheReadTokens).toBe(0);
|
|
141
|
+
expect(usage.cacheCreationTokens).toBe(0);
|
|
142
|
+
expect(usage.modelUsed).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("returns zeros for file with no assistant entries", async () => {
|
|
146
|
+
const path = await writeJsonl("no-assistant.jsonl", [
|
|
147
|
+
{ type: "human", message: { content: "hello" } },
|
|
148
|
+
{ type: "system", content: "system prompt" },
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
const usage = await parseTranscriptUsage(path);
|
|
152
|
+
|
|
153
|
+
expect(usage.inputTokens).toBe(0);
|
|
154
|
+
expect(usage.outputTokens).toBe(0);
|
|
155
|
+
expect(usage.modelUsed).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("gracefully handles malformed JSON lines", async () => {
|
|
159
|
+
const path = join(tempDir, "malformed.jsonl");
|
|
160
|
+
const content = [
|
|
161
|
+
'{"type":"assistant","message":{"model":"claude-opus-4-6","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}',
|
|
162
|
+
"this is not valid json",
|
|
163
|
+
"",
|
|
164
|
+
'{"type":"assistant","message":{"model":"claude-opus-4-6","usage":{"input_tokens":200,"output_tokens":75,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}',
|
|
165
|
+
].join("\n");
|
|
166
|
+
await writeFile(path, content, "utf8");
|
|
167
|
+
|
|
168
|
+
const usage = await parseTranscriptUsage(path);
|
|
169
|
+
|
|
170
|
+
// Should parse the two valid assistant entries, skip the malformed line
|
|
171
|
+
expect(usage.inputTokens).toBe(300);
|
|
172
|
+
expect(usage.outputTokens).toBe(125);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("handles assistant entries with missing usage fields (defaults to 0)", async () => {
|
|
176
|
+
const path = await writeJsonl("partial.jsonl", [
|
|
177
|
+
{
|
|
178
|
+
type: "assistant",
|
|
179
|
+
message: {
|
|
180
|
+
model: "claude-haiku-3-5-20241022",
|
|
181
|
+
usage: {
|
|
182
|
+
input_tokens: 100,
|
|
183
|
+
output_tokens: 50,
|
|
184
|
+
// No cache fields
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const usage = await parseTranscriptUsage(path);
|
|
191
|
+
|
|
192
|
+
expect(usage.inputTokens).toBe(100);
|
|
193
|
+
expect(usage.outputTokens).toBe(50);
|
|
194
|
+
expect(usage.cacheReadTokens).toBe(0);
|
|
195
|
+
expect(usage.cacheCreationTokens).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("handles assistant entries with no usage object", async () => {
|
|
199
|
+
const path = await writeJsonl("no-usage.jsonl", [
|
|
200
|
+
{
|
|
201
|
+
type: "assistant",
|
|
202
|
+
message: {
|
|
203
|
+
model: "claude-opus-4-6",
|
|
204
|
+
content: "response without usage",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
const usage = await parseTranscriptUsage(path);
|
|
210
|
+
|
|
211
|
+
expect(usage.inputTokens).toBe(0);
|
|
212
|
+
expect(usage.outputTokens).toBe(0);
|
|
213
|
+
expect(usage.modelUsed).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("captures model from first assistant turn only", async () => {
|
|
217
|
+
const path = await writeJsonl("model-change.jsonl", [
|
|
218
|
+
{
|
|
219
|
+
type: "assistant",
|
|
220
|
+
message: {
|
|
221
|
+
model: "claude-sonnet-4-20250514",
|
|
222
|
+
usage: {
|
|
223
|
+
input_tokens: 10,
|
|
224
|
+
output_tokens: 5,
|
|
225
|
+
cache_read_input_tokens: 0,
|
|
226
|
+
cache_creation_input_tokens: 0,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
type: "assistant",
|
|
232
|
+
message: {
|
|
233
|
+
model: "claude-opus-4-6",
|
|
234
|
+
usage: {
|
|
235
|
+
input_tokens: 20,
|
|
236
|
+
output_tokens: 10,
|
|
237
|
+
cache_read_input_tokens: 0,
|
|
238
|
+
cache_creation_input_tokens: 0,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
const usage = await parseTranscriptUsage(path);
|
|
245
|
+
|
|
246
|
+
expect(usage.modelUsed).toBe("claude-sonnet-4-20250514");
|
|
247
|
+
expect(usage.inputTokens).toBe(30);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("handles real-world transcript format with trailing newlines", async () => {
|
|
251
|
+
const path = join(tempDir, "trailing.jsonl");
|
|
252
|
+
const content =
|
|
253
|
+
'{"type":"assistant","message":{"model":"claude-opus-4-6","usage":{"input_tokens":3,"output_tokens":9,"cache_read_input_tokens":19401,"cache_creation_input_tokens":9918}}}\n\n\n';
|
|
254
|
+
await writeFile(path, content, "utf8");
|
|
255
|
+
|
|
256
|
+
const usage = await parseTranscriptUsage(path);
|
|
257
|
+
|
|
258
|
+
expect(usage.inputTokens).toBe(3);
|
|
259
|
+
expect(usage.outputTokens).toBe(9);
|
|
260
|
+
expect(usage.cacheReadTokens).toBe(19401);
|
|
261
|
+
expect(usage.cacheCreationTokens).toBe(9918);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// === estimateCost ===
|
|
266
|
+
|
|
267
|
+
describe("estimateCost", () => {
|
|
268
|
+
test("calculates cost for opus 4.6 model (new pricing)", () => {
|
|
269
|
+
const cost = estimateCost({
|
|
270
|
+
inputTokens: 1_000_000,
|
|
271
|
+
outputTokens: 1_000_000,
|
|
272
|
+
cacheReadTokens: 1_000_000,
|
|
273
|
+
cacheCreationTokens: 1_000_000,
|
|
274
|
+
modelUsed: "claude-opus-4-6",
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// opus 4.5+: input=$5, output=$25, cacheRead=$0.50, cacheCreation=$1.25
|
|
278
|
+
expect(cost).toBeCloseTo(31.75, 2);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("calculates cost for opus 4.5 model (new pricing)", () => {
|
|
282
|
+
const cost = estimateCost({
|
|
283
|
+
inputTokens: 1_000_000,
|
|
284
|
+
outputTokens: 1_000_000,
|
|
285
|
+
cacheReadTokens: 1_000_000,
|
|
286
|
+
cacheCreationTokens: 1_000_000,
|
|
287
|
+
modelUsed: "claude-opus-4-5",
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// opus 4.5+: input=$5, output=$25, cacheRead=$0.50, cacheCreation=$1.25
|
|
291
|
+
expect(cost).toBeCloseTo(31.75, 2);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("calculates cost for opus 4 model (legacy pricing)", () => {
|
|
295
|
+
const cost = estimateCost({
|
|
296
|
+
inputTokens: 1_000_000,
|
|
297
|
+
outputTokens: 1_000_000,
|
|
298
|
+
cacheReadTokens: 1_000_000,
|
|
299
|
+
cacheCreationTokens: 1_000_000,
|
|
300
|
+
modelUsed: "claude-opus-4",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// opus legacy: input=$15, output=$75, cacheRead=$1.50, cacheCreation=$3.75
|
|
304
|
+
expect(cost).toBeCloseTo(95.25, 2);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("calculates cost for opus 4.1 model (legacy pricing)", () => {
|
|
308
|
+
const cost = estimateCost({
|
|
309
|
+
inputTokens: 1_000_000,
|
|
310
|
+
outputTokens: 1_000_000,
|
|
311
|
+
cacheReadTokens: 1_000_000,
|
|
312
|
+
cacheCreationTokens: 1_000_000,
|
|
313
|
+
modelUsed: "claude-opus-4-1",
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// opus legacy: input=$15, output=$75, cacheRead=$1.50, cacheCreation=$3.75
|
|
317
|
+
expect(cost).toBeCloseTo(95.25, 2);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("calculates cost for sonnet model", () => {
|
|
321
|
+
const cost = estimateCost({
|
|
322
|
+
inputTokens: 1_000_000,
|
|
323
|
+
outputTokens: 1_000_000,
|
|
324
|
+
cacheReadTokens: 1_000_000,
|
|
325
|
+
cacheCreationTokens: 1_000_000,
|
|
326
|
+
modelUsed: "claude-sonnet-4-20250514",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// sonnet: input=$3, output=$15, cacheRead=$0.30, cacheCreation=$0.75
|
|
330
|
+
expect(cost).toBeCloseTo(19.05, 2);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("calculates cost for haiku 3.5 model (legacy pricing)", () => {
|
|
334
|
+
const cost = estimateCost({
|
|
335
|
+
inputTokens: 1_000_000,
|
|
336
|
+
outputTokens: 1_000_000,
|
|
337
|
+
cacheReadTokens: 1_000_000,
|
|
338
|
+
cacheCreationTokens: 1_000_000,
|
|
339
|
+
modelUsed: "claude-haiku-3-5-20241022",
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// haiku legacy: input=$0.80, output=$4, cacheRead=$0.08, cacheCreation=$0.20
|
|
343
|
+
expect(cost).toBeCloseTo(5.08, 2);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("calculates cost for haiku 4.5 model (new pricing)", () => {
|
|
347
|
+
const cost = estimateCost({
|
|
348
|
+
inputTokens: 1_000_000,
|
|
349
|
+
outputTokens: 1_000_000,
|
|
350
|
+
cacheReadTokens: 1_000_000,
|
|
351
|
+
cacheCreationTokens: 1_000_000,
|
|
352
|
+
modelUsed: "claude-haiku-4-5-20251001",
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// haiku 4.5+: input=$1, output=$5, cacheRead=$0.10, cacheCreation=$0.25
|
|
356
|
+
expect(cost).toBeCloseTo(6.35, 2);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("returns null for unknown model", () => {
|
|
360
|
+
const cost = estimateCost({
|
|
361
|
+
inputTokens: 1_000_000,
|
|
362
|
+
outputTokens: 1_000_000,
|
|
363
|
+
cacheReadTokens: 0,
|
|
364
|
+
cacheCreationTokens: 0,
|
|
365
|
+
modelUsed: "gpt-4o",
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(cost).toBeNull();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("returns null when modelUsed is null", () => {
|
|
372
|
+
const cost = estimateCost({
|
|
373
|
+
inputTokens: 1_000_000,
|
|
374
|
+
outputTokens: 1_000_000,
|
|
375
|
+
cacheReadTokens: 0,
|
|
376
|
+
cacheCreationTokens: 0,
|
|
377
|
+
modelUsed: null,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
expect(cost).toBeNull();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("zero tokens yields zero cost", () => {
|
|
384
|
+
const cost = estimateCost({
|
|
385
|
+
inputTokens: 0,
|
|
386
|
+
outputTokens: 0,
|
|
387
|
+
cacheReadTokens: 0,
|
|
388
|
+
cacheCreationTokens: 0,
|
|
389
|
+
modelUsed: "claude-opus-4-6",
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(cost).toBe(0);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("realistic session cost calculation", () => {
|
|
396
|
+
// A typical agent session: ~20K input, ~5K output, heavy cache reads
|
|
397
|
+
const cost = estimateCost({
|
|
398
|
+
inputTokens: 20_000,
|
|
399
|
+
outputTokens: 5_000,
|
|
400
|
+
cacheReadTokens: 100_000,
|
|
401
|
+
cacheCreationTokens: 15_000,
|
|
402
|
+
modelUsed: "claude-sonnet-4-20250514",
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// sonnet: (20K/1M)*3 + (5K/1M)*15 + (100K/1M)*0.30 + (15K/1M)*0.75
|
|
406
|
+
// = 0.06 + 0.075 + 0.03 + 0.01125 = $0.17625
|
|
407
|
+
expect(cost).not.toBeNull();
|
|
408
|
+
if (cost !== null) {
|
|
409
|
+
expect(cost).toBeGreaterThan(0.1);
|
|
410
|
+
expect(cost).toBeLessThan(1.0);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// === extractAssistantText ===
|
|
416
|
+
|
|
417
|
+
describe("extractAssistantText", () => {
|
|
418
|
+
test("returns text from assistant entry with text content block", () => {
|
|
419
|
+
const entry = {
|
|
420
|
+
type: "assistant",
|
|
421
|
+
message: {
|
|
422
|
+
model: "claude-opus-4-6",
|
|
423
|
+
content: [{ type: "text", text: "Hello, how can I help?" }],
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
expect(extractAssistantText(entry)).toBe("Hello, how can I help?");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("returns first text block when multiple content blocks", () => {
|
|
430
|
+
const entry = {
|
|
431
|
+
type: "assistant",
|
|
432
|
+
message: {
|
|
433
|
+
content: [
|
|
434
|
+
{ type: "tool_use", id: "abc", name: "Bash", input: { command: "ls" } },
|
|
435
|
+
{ type: "text", text: "I ran a command." },
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
expect(extractAssistantText(entry)).toBe("I ran a command.");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("returns null for non-assistant entry type", () => {
|
|
443
|
+
const entry = {
|
|
444
|
+
type: "human",
|
|
445
|
+
message: { content: [{ type: "text", text: "Hello" }] },
|
|
446
|
+
};
|
|
447
|
+
expect(extractAssistantText(entry)).toBeNull();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("returns null when content array has no text blocks", () => {
|
|
451
|
+
const entry = {
|
|
452
|
+
type: "assistant",
|
|
453
|
+
message: {
|
|
454
|
+
content: [{ type: "tool_use", id: "x", name: "Read", input: {} }],
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
expect(extractAssistantText(entry)).toBeNull();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("returns null for entry with no message", () => {
|
|
461
|
+
const entry = { type: "assistant" };
|
|
462
|
+
expect(extractAssistantText(entry)).toBeNull();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("returns null for null input", () => {
|
|
466
|
+
expect(extractAssistantText(null)).toBeNull();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("returns null for non-object input", () => {
|
|
470
|
+
expect(extractAssistantText("not an object")).toBeNull();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("skips empty text blocks and returns first non-empty one", () => {
|
|
474
|
+
const entry = {
|
|
475
|
+
type: "assistant",
|
|
476
|
+
message: {
|
|
477
|
+
content: [
|
|
478
|
+
{ type: "text", text: "" },
|
|
479
|
+
{ type: "text", text: "Actual response." },
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
expect(extractAssistantText(entry)).toBe("Actual response.");
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// === parseTranscriptTexts ===
|
|
488
|
+
|
|
489
|
+
describe("parseTranscriptTexts", () => {
|
|
490
|
+
test("extracts both user and assistant messages", async () => {
|
|
491
|
+
const path = await writeJsonl("mixed-roles.jsonl", [
|
|
492
|
+
{
|
|
493
|
+
type: "human",
|
|
494
|
+
message: { content: [{ type: "text", text: "What is 2+2?" }] },
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
type: "assistant",
|
|
498
|
+
message: {
|
|
499
|
+
model: "claude-opus-4-6",
|
|
500
|
+
content: [{ type: "text", text: "2+2 equals 4." }],
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
]);
|
|
504
|
+
|
|
505
|
+
const { messages, nextLine } = await parseTranscriptTexts(path);
|
|
506
|
+
|
|
507
|
+
expect(messages).toHaveLength(2);
|
|
508
|
+
expect(messages[0]).toEqual({ role: "user", text: "What is 2+2?" });
|
|
509
|
+
expect(messages[1]).toEqual({ role: "assistant", text: "2+2 equals 4." });
|
|
510
|
+
expect(nextLine).toBe(2); // 2 JSON lines; trailing empty excluded from watermark
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("returns empty array for file with no human or assistant entries", async () => {
|
|
514
|
+
const path = await writeJsonl("system-only.jsonl", [
|
|
515
|
+
{ type: "system", content: "system prompt" },
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
const { messages } = await parseTranscriptTexts(path);
|
|
519
|
+
expect(messages).toHaveLength(0);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("skips entries with no text content", async () => {
|
|
523
|
+
const path = await writeJsonl("no-text.jsonl", [
|
|
524
|
+
{
|
|
525
|
+
type: "assistant",
|
|
526
|
+
message: {
|
|
527
|
+
content: [{ type: "tool_use", name: "Bash", id: "x", input: {} }],
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
type: "assistant",
|
|
532
|
+
message: {
|
|
533
|
+
content: [{ type: "text", text: "Done." }],
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
const { messages } = await parseTranscriptTexts(path);
|
|
539
|
+
expect(messages).toHaveLength(1);
|
|
540
|
+
expect(messages[0]).toEqual({ role: "assistant", text: "Done." });
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("respects fromLine offset for incremental parsing", async () => {
|
|
544
|
+
const path = await writeJsonl("incremental.jsonl", [
|
|
545
|
+
{
|
|
546
|
+
type: "human",
|
|
547
|
+
message: { content: [{ type: "text", text: "First message" }] },
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
type: "assistant",
|
|
551
|
+
message: { content: [{ type: "text", text: "First response" }] },
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
type: "human",
|
|
555
|
+
message: { content: [{ type: "text", text: "Second message" }] },
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
type: "assistant",
|
|
559
|
+
message: { content: [{ type: "text", text: "Second response" }] },
|
|
560
|
+
},
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
// First call: get all
|
|
564
|
+
const first = await parseTranscriptTexts(path, 0);
|
|
565
|
+
expect(first.messages).toHaveLength(4);
|
|
566
|
+
|
|
567
|
+
// Second call: start from where first call ended
|
|
568
|
+
const second = await parseTranscriptTexts(path, first.nextLine);
|
|
569
|
+
expect(second.messages).toHaveLength(0);
|
|
570
|
+
expect(second.nextLine).toBe(first.nextLine);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("incremental parsing captures only new messages after offset", async () => {
|
|
574
|
+
const path = join(tempDir, "growing.jsonl");
|
|
575
|
+
|
|
576
|
+
// Write first two entries
|
|
577
|
+
const lines1 = [
|
|
578
|
+
{ type: "human", message: { content: [{ type: "text", text: "Hello" }] } },
|
|
579
|
+
{ type: "assistant", message: { content: [{ type: "text", text: "Hi!" }] } },
|
|
580
|
+
];
|
|
581
|
+
await writeFile(path, `${lines1.map((l) => JSON.stringify(l)).join("\n")}\n`, "utf8");
|
|
582
|
+
|
|
583
|
+
const first = await parseTranscriptTexts(path, 0);
|
|
584
|
+
expect(first.messages).toHaveLength(2);
|
|
585
|
+
|
|
586
|
+
// Append two more entries
|
|
587
|
+
const lines2 = [
|
|
588
|
+
{ type: "human", message: { content: [{ type: "text", text: "How are you?" }] } },
|
|
589
|
+
{ type: "assistant", message: { content: [{ type: "text", text: "I'm great!" }] } },
|
|
590
|
+
];
|
|
591
|
+
const { appendFile } = await import("node:fs/promises");
|
|
592
|
+
await appendFile(path, `${lines2.map((l) => JSON.stringify(l)).join("\n")}\n`, "utf8");
|
|
593
|
+
|
|
594
|
+
// Second call with offset from first call
|
|
595
|
+
const second = await parseTranscriptTexts(path, first.nextLine);
|
|
596
|
+
expect(second.messages).toHaveLength(2);
|
|
597
|
+
expect(second.messages[0]).toEqual({ role: "user", text: "How are you?" });
|
|
598
|
+
expect(second.messages[1]).toEqual({ role: "assistant", text: "I'm great!" });
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("returns empty array for empty file", async () => {
|
|
602
|
+
const path = join(tempDir, "empty-texts.jsonl");
|
|
603
|
+
await writeFile(path, "", "utf8");
|
|
604
|
+
|
|
605
|
+
const { messages, nextLine } = await parseTranscriptTexts(path);
|
|
606
|
+
expect(messages).toHaveLength(0);
|
|
607
|
+
expect(nextLine).toBe(0); // split("") gives [""] — trailing empty excluded → 0
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("handles human entry with string content", async () => {
|
|
611
|
+
const path = await writeJsonl("human-string.jsonl", [
|
|
612
|
+
{
|
|
613
|
+
type: "human",
|
|
614
|
+
message: { content: "Plain string message" },
|
|
615
|
+
},
|
|
616
|
+
]);
|
|
617
|
+
|
|
618
|
+
const { messages } = await parseTranscriptTexts(path);
|
|
619
|
+
expect(messages).toHaveLength(1);
|
|
620
|
+
expect(messages[0]).toEqual({ role: "user", text: "Plain string message" });
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("skips malformed JSON lines gracefully", async () => {
|
|
624
|
+
const path = join(tempDir, "malformed-texts.jsonl");
|
|
625
|
+
const content = [
|
|
626
|
+
JSON.stringify({
|
|
627
|
+
type: "assistant",
|
|
628
|
+
message: { content: [{ type: "text", text: "Valid" }] },
|
|
629
|
+
}),
|
|
630
|
+
"this is not json",
|
|
631
|
+
JSON.stringify({
|
|
632
|
+
type: "assistant",
|
|
633
|
+
message: { content: [{ type: "text", text: "Also valid" }] },
|
|
634
|
+
}),
|
|
635
|
+
].join("\n");
|
|
636
|
+
await writeFile(path, content, "utf8");
|
|
637
|
+
|
|
638
|
+
const { messages } = await parseTranscriptTexts(path);
|
|
639
|
+
expect(messages).toHaveLength(2);
|
|
640
|
+
expect(messages[0]?.text).toBe("Valid");
|
|
641
|
+
expect(messages[1]?.text).toBe("Also valid");
|
|
642
|
+
});
|
|
643
|
+
});
|