@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,1015 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `legio costs` command.
|
|
3
|
+
*
|
|
4
|
+
* Uses real better-sqlite3 (temp files) to test the costs command end-to-end.
|
|
5
|
+
* Captures process.stdout.write to verify output formatting.
|
|
6
|
+
*
|
|
7
|
+
* Real implementations used for: filesystem (temp dirs), SQLite (MetricsStore,
|
|
8
|
+
* SessionStore). No mocks needed -- all dependencies are cheap and local.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
15
|
+
import { ValidationError } from "../errors.ts";
|
|
16
|
+
import { createMetricsStore } from "../metrics/store.ts";
|
|
17
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
18
|
+
import type { SessionMetrics } from "../types.ts";
|
|
19
|
+
import { costsCommand } from "./costs.ts";
|
|
20
|
+
|
|
21
|
+
/** Helper to create a SessionMetrics with sensible defaults. */
|
|
22
|
+
function makeMetrics(overrides: Partial<SessionMetrics> = {}): SessionMetrics {
|
|
23
|
+
return {
|
|
24
|
+
agentName: "builder-1",
|
|
25
|
+
beadId: "task-001",
|
|
26
|
+
capability: "builder",
|
|
27
|
+
startedAt: new Date().toISOString(),
|
|
28
|
+
completedAt: new Date().toISOString(),
|
|
29
|
+
durationMs: 60000,
|
|
30
|
+
exitCode: 0,
|
|
31
|
+
mergeResult: null,
|
|
32
|
+
parentAgent: null,
|
|
33
|
+
inputTokens: 12345,
|
|
34
|
+
outputTokens: 5678,
|
|
35
|
+
cacheReadTokens: 8000,
|
|
36
|
+
cacheCreationTokens: 901,
|
|
37
|
+
estimatedCostUsd: 0.42,
|
|
38
|
+
modelUsed: "claude-sonnet-4-20250514",
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("costsCommand", () => {
|
|
44
|
+
let chunks: string[];
|
|
45
|
+
let originalWrite: typeof process.stdout.write;
|
|
46
|
+
let tempDir: string;
|
|
47
|
+
let originalCwd: string;
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
// Spy on stdout
|
|
51
|
+
chunks = [];
|
|
52
|
+
originalWrite = process.stdout.write;
|
|
53
|
+
process.stdout.write = ((chunk: string) => {
|
|
54
|
+
chunks.push(chunk);
|
|
55
|
+
return true;
|
|
56
|
+
}) as typeof process.stdout.write;
|
|
57
|
+
|
|
58
|
+
// Create temp dir with .legio/config.yaml structure
|
|
59
|
+
tempDir = await mkdtemp(join(tmpdir(), "costs-test-"));
|
|
60
|
+
const legioDir = join(tempDir, ".legio");
|
|
61
|
+
await mkdir(legioDir, { recursive: true });
|
|
62
|
+
await writeFile(
|
|
63
|
+
join(legioDir, "config.yaml"),
|
|
64
|
+
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Change to temp dir so loadConfig() works
|
|
68
|
+
originalCwd = process.cwd();
|
|
69
|
+
process.chdir(tempDir);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(async () => {
|
|
73
|
+
process.stdout.write = originalWrite;
|
|
74
|
+
process.chdir(originalCwd);
|
|
75
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function output(): string {
|
|
79
|
+
return chunks.join("");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// === Help flag ===
|
|
83
|
+
|
|
84
|
+
describe("help flag", () => {
|
|
85
|
+
test("--help shows help text", async () => {
|
|
86
|
+
await costsCommand(["--help"]);
|
|
87
|
+
const out = output();
|
|
88
|
+
|
|
89
|
+
expect(out).toContain("legio costs");
|
|
90
|
+
expect(out).toContain("--agent");
|
|
91
|
+
expect(out).toContain("--run");
|
|
92
|
+
expect(out).toContain("--by-capability");
|
|
93
|
+
expect(out).toContain("--last");
|
|
94
|
+
expect(out).toContain("--json");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("-h shows help text", async () => {
|
|
98
|
+
await costsCommand(["-h"]);
|
|
99
|
+
const out = output();
|
|
100
|
+
|
|
101
|
+
expect(out).toContain("legio costs");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// === Missing metrics.db (graceful handling) ===
|
|
106
|
+
|
|
107
|
+
describe("missing metrics.db", () => {
|
|
108
|
+
test("text mode outputs friendly message when no metrics.db exists", async () => {
|
|
109
|
+
await costsCommand([]);
|
|
110
|
+
const out = output();
|
|
111
|
+
|
|
112
|
+
expect(out).toBe("No metrics data yet.\n");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("JSON mode outputs empty array when no metrics.db exists", async () => {
|
|
116
|
+
await costsCommand(["--json"]);
|
|
117
|
+
const out = output();
|
|
118
|
+
|
|
119
|
+
expect(out).toBe("[]\n");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// === Argument validation ===
|
|
124
|
+
|
|
125
|
+
describe("argument validation", () => {
|
|
126
|
+
test("--last with non-numeric value throws ValidationError", async () => {
|
|
127
|
+
await expect(costsCommand(["--last", "abc"])).rejects.toThrow(ValidationError);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("--last with zero throws ValidationError", async () => {
|
|
131
|
+
await expect(costsCommand(["--last", "0"])).rejects.toThrow(ValidationError);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("--last with negative value throws ValidationError", async () => {
|
|
135
|
+
await expect(costsCommand(["--last", "-5"])).rejects.toThrow(ValidationError);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// === JSON output mode ===
|
|
140
|
+
|
|
141
|
+
describe("JSON output mode", () => {
|
|
142
|
+
test("outputs valid JSON array with sessions", async () => {
|
|
143
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
144
|
+
const store = createMetricsStore(dbPath);
|
|
145
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
146
|
+
store.recordSession(makeMetrics({ agentName: "scout-1", beadId: "t2", capability: "scout" }));
|
|
147
|
+
store.close();
|
|
148
|
+
|
|
149
|
+
await costsCommand(["--json"]);
|
|
150
|
+
const out = output();
|
|
151
|
+
|
|
152
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
153
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
154
|
+
expect(parsed).toHaveLength(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("JSON output includes expected token fields", async () => {
|
|
158
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
159
|
+
const store = createMetricsStore(dbPath);
|
|
160
|
+
store.recordSession(
|
|
161
|
+
makeMetrics({
|
|
162
|
+
agentName: "builder-1",
|
|
163
|
+
beadId: "t1",
|
|
164
|
+
inputTokens: 100,
|
|
165
|
+
outputTokens: 50,
|
|
166
|
+
cacheReadTokens: 30,
|
|
167
|
+
cacheCreationTokens: 10,
|
|
168
|
+
estimatedCostUsd: 0.15,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
store.close();
|
|
172
|
+
|
|
173
|
+
await costsCommand(["--json"]);
|
|
174
|
+
const out = output();
|
|
175
|
+
|
|
176
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
|
|
177
|
+
expect(parsed).toHaveLength(1);
|
|
178
|
+
const session = parsed[0];
|
|
179
|
+
expect(session).toBeDefined();
|
|
180
|
+
expect(session?.inputTokens).toBe(100);
|
|
181
|
+
expect(session?.outputTokens).toBe(50);
|
|
182
|
+
expect(session?.cacheReadTokens).toBe(30);
|
|
183
|
+
expect(session?.cacheCreationTokens).toBe(10);
|
|
184
|
+
expect(session?.estimatedCostUsd).toBe(0.15);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("JSON output returns empty array when no sessions match", async () => {
|
|
188
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
189
|
+
const store = createMetricsStore(dbPath);
|
|
190
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
191
|
+
store.close();
|
|
192
|
+
|
|
193
|
+
await costsCommand(["--json", "--agent", "nonexistent"]);
|
|
194
|
+
const out = output();
|
|
195
|
+
|
|
196
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
197
|
+
expect(parsed).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("JSON --by-capability outputs grouped object", async () => {
|
|
201
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
202
|
+
const store = createMetricsStore(dbPath);
|
|
203
|
+
store.recordSession(
|
|
204
|
+
makeMetrics({
|
|
205
|
+
agentName: "builder-1",
|
|
206
|
+
beadId: "t1",
|
|
207
|
+
capability: "builder",
|
|
208
|
+
inputTokens: 100,
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
store.recordSession(
|
|
212
|
+
makeMetrics({
|
|
213
|
+
agentName: "scout-1",
|
|
214
|
+
beadId: "t2",
|
|
215
|
+
capability: "scout",
|
|
216
|
+
inputTokens: 50,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
store.close();
|
|
220
|
+
|
|
221
|
+
await costsCommand(["--json", "--by-capability"]);
|
|
222
|
+
const out = output();
|
|
223
|
+
|
|
224
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
|
|
225
|
+
expect(parsed).toBeDefined();
|
|
226
|
+
expect(parsed.builder).toBeDefined();
|
|
227
|
+
expect(parsed.scout).toBeDefined();
|
|
228
|
+
|
|
229
|
+
const builderGroup = parsed.builder as Record<string, unknown>;
|
|
230
|
+
expect(builderGroup.sessions).toBeDefined();
|
|
231
|
+
expect(builderGroup.totals).toBeDefined();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// === Human output format ===
|
|
236
|
+
|
|
237
|
+
describe("human output format", () => {
|
|
238
|
+
test("shows Cost Summary header", async () => {
|
|
239
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
240
|
+
const store = createMetricsStore(dbPath);
|
|
241
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
242
|
+
store.close();
|
|
243
|
+
|
|
244
|
+
await costsCommand([]);
|
|
245
|
+
const out = output();
|
|
246
|
+
|
|
247
|
+
expect(out).toContain("Cost Summary");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("shows column headers", async () => {
|
|
251
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
252
|
+
const store = createMetricsStore(dbPath);
|
|
253
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
254
|
+
store.close();
|
|
255
|
+
|
|
256
|
+
await costsCommand([]);
|
|
257
|
+
const out = output();
|
|
258
|
+
|
|
259
|
+
expect(out).toContain("Agent");
|
|
260
|
+
expect(out).toContain("Capability");
|
|
261
|
+
expect(out).toContain("Input");
|
|
262
|
+
expect(out).toContain("Output");
|
|
263
|
+
expect(out).toContain("Cache");
|
|
264
|
+
expect(out).toContain("Cost");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("shows agent name and capability in output", async () => {
|
|
268
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
269
|
+
const store = createMetricsStore(dbPath);
|
|
270
|
+
store.recordSession(
|
|
271
|
+
makeMetrics({
|
|
272
|
+
agentName: "builder-1",
|
|
273
|
+
beadId: "t1",
|
|
274
|
+
capability: "builder",
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
store.close();
|
|
278
|
+
|
|
279
|
+
await costsCommand([]);
|
|
280
|
+
const out = output();
|
|
281
|
+
|
|
282
|
+
expect(out).toContain("builder-1");
|
|
283
|
+
expect(out).toContain("builder");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("shows separator line", async () => {
|
|
287
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
288
|
+
const store = createMetricsStore(dbPath);
|
|
289
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
290
|
+
store.close();
|
|
291
|
+
|
|
292
|
+
await costsCommand([]);
|
|
293
|
+
const out = output();
|
|
294
|
+
|
|
295
|
+
expect(out).toContain("=".repeat(70));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("shows Total row", async () => {
|
|
299
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
300
|
+
const store = createMetricsStore(dbPath);
|
|
301
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
302
|
+
store.close();
|
|
303
|
+
|
|
304
|
+
await costsCommand([]);
|
|
305
|
+
const out = output();
|
|
306
|
+
|
|
307
|
+
expect(out).toContain("Total");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("no sessions shows 'No session data found' message", async () => {
|
|
311
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
312
|
+
const store = createMetricsStore(dbPath);
|
|
313
|
+
// Create DB but don't insert anything
|
|
314
|
+
store.close();
|
|
315
|
+
|
|
316
|
+
await costsCommand([]);
|
|
317
|
+
const out = output();
|
|
318
|
+
|
|
319
|
+
expect(out).toContain("No session data found");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// === Number formatting ===
|
|
324
|
+
|
|
325
|
+
describe("number formatting", () => {
|
|
326
|
+
test("formats numbers with thousands separators", async () => {
|
|
327
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
328
|
+
const store = createMetricsStore(dbPath);
|
|
329
|
+
store.recordSession(
|
|
330
|
+
makeMetrics({
|
|
331
|
+
agentName: "builder-1",
|
|
332
|
+
beadId: "t1",
|
|
333
|
+
inputTokens: 12345,
|
|
334
|
+
outputTokens: 5678,
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
store.close();
|
|
338
|
+
|
|
339
|
+
await costsCommand([]);
|
|
340
|
+
const out = output();
|
|
341
|
+
|
|
342
|
+
expect(out).toContain("12,345");
|
|
343
|
+
expect(out).toContain("5,678");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("formats cost with dollar sign and 2 decimal places", async () => {
|
|
347
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
348
|
+
const store = createMetricsStore(dbPath);
|
|
349
|
+
store.recordSession(
|
|
350
|
+
makeMetrics({
|
|
351
|
+
agentName: "builder-1",
|
|
352
|
+
beadId: "t1",
|
|
353
|
+
estimatedCostUsd: 0.42,
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
store.close();
|
|
357
|
+
|
|
358
|
+
await costsCommand([]);
|
|
359
|
+
const out = output();
|
|
360
|
+
|
|
361
|
+
expect(out).toContain("$0.42");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("handles zero tokens correctly", async () => {
|
|
365
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
366
|
+
const store = createMetricsStore(dbPath);
|
|
367
|
+
store.recordSession(
|
|
368
|
+
makeMetrics({
|
|
369
|
+
agentName: "builder-1",
|
|
370
|
+
beadId: "t1",
|
|
371
|
+
inputTokens: 0,
|
|
372
|
+
outputTokens: 0,
|
|
373
|
+
cacheReadTokens: 0,
|
|
374
|
+
cacheCreationTokens: 0,
|
|
375
|
+
estimatedCostUsd: 0,
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
store.close();
|
|
379
|
+
|
|
380
|
+
await costsCommand([]);
|
|
381
|
+
const out = output();
|
|
382
|
+
|
|
383
|
+
expect(out).toContain("$0.00");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("handles null cost correctly", async () => {
|
|
387
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
388
|
+
const store = createMetricsStore(dbPath);
|
|
389
|
+
store.recordSession(
|
|
390
|
+
makeMetrics({
|
|
391
|
+
agentName: "builder-1",
|
|
392
|
+
beadId: "t1",
|
|
393
|
+
estimatedCostUsd: null,
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
store.close();
|
|
397
|
+
|
|
398
|
+
await costsCommand([]);
|
|
399
|
+
const out = output();
|
|
400
|
+
|
|
401
|
+
expect(out).toContain("$0.00");
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// === --agent filter ===
|
|
406
|
+
|
|
407
|
+
describe("--agent filter", () => {
|
|
408
|
+
test("filters sessions by agent name", async () => {
|
|
409
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
410
|
+
const store = createMetricsStore(dbPath);
|
|
411
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1", inputTokens: 100 }));
|
|
412
|
+
store.recordSession(makeMetrics({ agentName: "scout-1", beadId: "t2", inputTokens: 200 }));
|
|
413
|
+
store.close();
|
|
414
|
+
|
|
415
|
+
await costsCommand(["--json", "--agent", "builder-1"]);
|
|
416
|
+
const out = output();
|
|
417
|
+
|
|
418
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
|
|
419
|
+
expect(parsed).toHaveLength(1);
|
|
420
|
+
expect(parsed[0]?.agentName).toBe("builder-1");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("returns empty for non-existent agent", async () => {
|
|
424
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
425
|
+
const store = createMetricsStore(dbPath);
|
|
426
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
427
|
+
store.close();
|
|
428
|
+
|
|
429
|
+
await costsCommand(["--json", "--agent", "nonexistent"]);
|
|
430
|
+
const out = output();
|
|
431
|
+
|
|
432
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
433
|
+
expect(parsed).toEqual([]);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// === --run filter ===
|
|
438
|
+
|
|
439
|
+
describe("--run filter", () => {
|
|
440
|
+
test("filters sessions by run ID via direct MetricsStore query", async () => {
|
|
441
|
+
const legioDir = join(tempDir, ".legio");
|
|
442
|
+
|
|
443
|
+
// Seed metrics with run_id directly — no SessionStore cross-reference needed
|
|
444
|
+
const metricsDbPath = join(legioDir, "metrics.db");
|
|
445
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
446
|
+
metricsStore.recordSession(
|
|
447
|
+
makeMetrics({ agentName: "builder-1", beadId: "task-001", runId: "run-2026-01-01" }),
|
|
448
|
+
);
|
|
449
|
+
metricsStore.recordSession(
|
|
450
|
+
makeMetrics({
|
|
451
|
+
agentName: "scout-1",
|
|
452
|
+
beadId: "task-002",
|
|
453
|
+
capability: "scout",
|
|
454
|
+
runId: "run-other",
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
metricsStore.close();
|
|
458
|
+
|
|
459
|
+
await costsCommand(["--json", "--run", "run-2026-01-01"]);
|
|
460
|
+
const out = output();
|
|
461
|
+
|
|
462
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
|
|
463
|
+
expect(parsed).toHaveLength(1);
|
|
464
|
+
expect(parsed[0]?.agentName).toBe("builder-1");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("returns empty when no sessions match run ID", async () => {
|
|
468
|
+
const legioDir = join(tempDir, ".legio");
|
|
469
|
+
|
|
470
|
+
// Create metrics store with data but no matching run_id
|
|
471
|
+
const metricsDbPath = join(legioDir, "metrics.db");
|
|
472
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
473
|
+
metricsStore.recordSession(
|
|
474
|
+
makeMetrics({ agentName: "builder-1", beadId: "t1", runId: "run-other" }),
|
|
475
|
+
);
|
|
476
|
+
metricsStore.close();
|
|
477
|
+
|
|
478
|
+
await costsCommand(["--json", "--run", "run-nonexistent"]);
|
|
479
|
+
const out = output();
|
|
480
|
+
|
|
481
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
482
|
+
expect(parsed).toEqual([]);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// === --by-capability grouping ===
|
|
487
|
+
|
|
488
|
+
describe("--by-capability grouping", () => {
|
|
489
|
+
test("shows capability header", async () => {
|
|
490
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
491
|
+
const store = createMetricsStore(dbPath);
|
|
492
|
+
store.recordSession(
|
|
493
|
+
makeMetrics({ agentName: "builder-1", beadId: "t1", capability: "builder" }),
|
|
494
|
+
);
|
|
495
|
+
store.close();
|
|
496
|
+
|
|
497
|
+
await costsCommand(["--by-capability"]);
|
|
498
|
+
const out = output();
|
|
499
|
+
|
|
500
|
+
expect(out).toContain("Cost by Capability");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("shows Sessions column header", async () => {
|
|
504
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
505
|
+
const store = createMetricsStore(dbPath);
|
|
506
|
+
store.recordSession(
|
|
507
|
+
makeMetrics({ agentName: "builder-1", beadId: "t1", capability: "builder" }),
|
|
508
|
+
);
|
|
509
|
+
store.close();
|
|
510
|
+
|
|
511
|
+
await costsCommand(["--by-capability"]);
|
|
512
|
+
const out = output();
|
|
513
|
+
|
|
514
|
+
expect(out).toContain("Sessions");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("groups multiple sessions by capability", async () => {
|
|
518
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
519
|
+
const store = createMetricsStore(dbPath);
|
|
520
|
+
store.recordSession(
|
|
521
|
+
makeMetrics({
|
|
522
|
+
agentName: "builder-1",
|
|
523
|
+
beadId: "t1",
|
|
524
|
+
capability: "builder",
|
|
525
|
+
inputTokens: 1000,
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
store.recordSession(
|
|
529
|
+
makeMetrics({
|
|
530
|
+
agentName: "builder-2",
|
|
531
|
+
beadId: "t2",
|
|
532
|
+
capability: "builder",
|
|
533
|
+
inputTokens: 2000,
|
|
534
|
+
}),
|
|
535
|
+
);
|
|
536
|
+
store.recordSession(
|
|
537
|
+
makeMetrics({
|
|
538
|
+
agentName: "scout-1",
|
|
539
|
+
beadId: "t3",
|
|
540
|
+
capability: "scout",
|
|
541
|
+
inputTokens: 500,
|
|
542
|
+
}),
|
|
543
|
+
);
|
|
544
|
+
store.close();
|
|
545
|
+
|
|
546
|
+
await costsCommand(["--by-capability"]);
|
|
547
|
+
const out = output();
|
|
548
|
+
|
|
549
|
+
expect(out).toContain("builder");
|
|
550
|
+
expect(out).toContain("scout");
|
|
551
|
+
expect(out).toContain("Total");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("shows correct session count per capability", async () => {
|
|
555
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
556
|
+
const store = createMetricsStore(dbPath);
|
|
557
|
+
store.recordSession(makeMetrics({ agentName: "b1", beadId: "t1", capability: "builder" }));
|
|
558
|
+
store.recordSession(makeMetrics({ agentName: "b2", beadId: "t2", capability: "builder" }));
|
|
559
|
+
store.recordSession(makeMetrics({ agentName: "b3", beadId: "t3", capability: "builder" }));
|
|
560
|
+
store.recordSession(makeMetrics({ agentName: "s1", beadId: "t4", capability: "scout" }));
|
|
561
|
+
store.close();
|
|
562
|
+
|
|
563
|
+
await costsCommand(["--json", "--by-capability"]);
|
|
564
|
+
const out = output();
|
|
565
|
+
|
|
566
|
+
const parsed = JSON.parse(out.trim()) as Record<
|
|
567
|
+
string,
|
|
568
|
+
{ sessions: unknown[]; totals: Record<string, unknown> }
|
|
569
|
+
>;
|
|
570
|
+
expect(parsed.builder?.sessions).toHaveLength(3);
|
|
571
|
+
expect(parsed.scout?.sessions).toHaveLength(1);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("empty data shows no session data message", async () => {
|
|
575
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
576
|
+
const store = createMetricsStore(dbPath);
|
|
577
|
+
store.close();
|
|
578
|
+
|
|
579
|
+
await costsCommand(["--by-capability"]);
|
|
580
|
+
const out = output();
|
|
581
|
+
|
|
582
|
+
expect(out).toContain("No session data found");
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// === --last flag ===
|
|
587
|
+
|
|
588
|
+
describe("--last flag", () => {
|
|
589
|
+
test("limits the number of sessions returned", async () => {
|
|
590
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
591
|
+
const store = createMetricsStore(dbPath);
|
|
592
|
+
for (let i = 0; i < 10; i++) {
|
|
593
|
+
store.recordSession(makeMetrics({ agentName: `agent-${i}`, beadId: `t-${i}` }));
|
|
594
|
+
}
|
|
595
|
+
store.close();
|
|
596
|
+
|
|
597
|
+
await costsCommand(["--json", "--last", "3"]);
|
|
598
|
+
const out = output();
|
|
599
|
+
|
|
600
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
601
|
+
expect(parsed).toHaveLength(3);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("default limit is 20", async () => {
|
|
605
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
606
|
+
const store = createMetricsStore(dbPath);
|
|
607
|
+
for (let i = 0; i < 25; i++) {
|
|
608
|
+
store.recordSession(makeMetrics({ agentName: `agent-${i}`, beadId: `t-${i}` }));
|
|
609
|
+
}
|
|
610
|
+
store.close();
|
|
611
|
+
|
|
612
|
+
await costsCommand(["--json"]);
|
|
613
|
+
const out = output();
|
|
614
|
+
|
|
615
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
616
|
+
expect(parsed).toHaveLength(20);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// === Edge cases ===
|
|
621
|
+
|
|
622
|
+
describe("edge cases", () => {
|
|
623
|
+
test("handles session with all zero tokens", async () => {
|
|
624
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
625
|
+
const store = createMetricsStore(dbPath);
|
|
626
|
+
store.recordSession(
|
|
627
|
+
makeMetrics({
|
|
628
|
+
agentName: "builder-1",
|
|
629
|
+
beadId: "t1",
|
|
630
|
+
inputTokens: 0,
|
|
631
|
+
outputTokens: 0,
|
|
632
|
+
cacheReadTokens: 0,
|
|
633
|
+
cacheCreationTokens: 0,
|
|
634
|
+
estimatedCostUsd: 0,
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
store.close();
|
|
638
|
+
|
|
639
|
+
// Should not throw
|
|
640
|
+
await costsCommand([]);
|
|
641
|
+
const out = output();
|
|
642
|
+
|
|
643
|
+
expect(out).toContain("Cost Summary");
|
|
644
|
+
expect(out).toContain("builder-1");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("handles session with null cost", async () => {
|
|
648
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
649
|
+
const store = createMetricsStore(dbPath);
|
|
650
|
+
store.recordSession(
|
|
651
|
+
makeMetrics({
|
|
652
|
+
agentName: "builder-1",
|
|
653
|
+
beadId: "t1",
|
|
654
|
+
estimatedCostUsd: null,
|
|
655
|
+
}),
|
|
656
|
+
);
|
|
657
|
+
store.close();
|
|
658
|
+
|
|
659
|
+
// Should not throw
|
|
660
|
+
await costsCommand([]);
|
|
661
|
+
const out = output();
|
|
662
|
+
|
|
663
|
+
expect(out).toContain("Cost Summary");
|
|
664
|
+
expect(out).toContain("$0.00");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("cache column sums cacheRead + cacheCreation tokens", async () => {
|
|
668
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
669
|
+
const store = createMetricsStore(dbPath);
|
|
670
|
+
store.recordSession(
|
|
671
|
+
makeMetrics({
|
|
672
|
+
agentName: "builder-1",
|
|
673
|
+
beadId: "t1",
|
|
674
|
+
cacheReadTokens: 8000,
|
|
675
|
+
cacheCreationTokens: 901,
|
|
676
|
+
}),
|
|
677
|
+
);
|
|
678
|
+
store.close();
|
|
679
|
+
|
|
680
|
+
await costsCommand([]);
|
|
681
|
+
const out = output();
|
|
682
|
+
|
|
683
|
+
// 8000 + 901 = 8,901
|
|
684
|
+
expect(out).toContain("8,901");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("total row sums across all sessions", async () => {
|
|
688
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
689
|
+
const store = createMetricsStore(dbPath);
|
|
690
|
+
store.recordSession(
|
|
691
|
+
makeMetrics({
|
|
692
|
+
agentName: "builder-1",
|
|
693
|
+
beadId: "t1",
|
|
694
|
+
inputTokens: 100,
|
|
695
|
+
outputTokens: 50,
|
|
696
|
+
estimatedCostUsd: 0.1,
|
|
697
|
+
}),
|
|
698
|
+
);
|
|
699
|
+
store.recordSession(
|
|
700
|
+
makeMetrics({
|
|
701
|
+
agentName: "scout-1",
|
|
702
|
+
beadId: "t2",
|
|
703
|
+
capability: "scout",
|
|
704
|
+
inputTokens: 200,
|
|
705
|
+
outputTokens: 100,
|
|
706
|
+
estimatedCostUsd: 0.2,
|
|
707
|
+
}),
|
|
708
|
+
);
|
|
709
|
+
store.close();
|
|
710
|
+
|
|
711
|
+
await costsCommand(["--json"]);
|
|
712
|
+
const out = output();
|
|
713
|
+
|
|
714
|
+
const parsed = JSON.parse(out.trim()) as SessionMetrics[];
|
|
715
|
+
const totalInput = parsed.reduce((sum, s) => sum + s.inputTokens, 0);
|
|
716
|
+
const totalOutput = parsed.reduce((sum, s) => sum + s.outputTokens, 0);
|
|
717
|
+
expect(totalInput).toBe(300);
|
|
718
|
+
expect(totalOutput).toBe(150);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("multiple flags work together", async () => {
|
|
722
|
+
const dbPath = join(tempDir, ".legio", "metrics.db");
|
|
723
|
+
const store = createMetricsStore(dbPath);
|
|
724
|
+
store.recordSession(
|
|
725
|
+
makeMetrics({ agentName: "builder-1", beadId: "t1", capability: "builder" }),
|
|
726
|
+
);
|
|
727
|
+
store.recordSession(
|
|
728
|
+
makeMetrics({ agentName: "builder-2", beadId: "t2", capability: "builder" }),
|
|
729
|
+
);
|
|
730
|
+
store.close();
|
|
731
|
+
|
|
732
|
+
await costsCommand(["--by-capability", "--last", "10"]);
|
|
733
|
+
const out = output();
|
|
734
|
+
|
|
735
|
+
expect(out).toContain("Cost by Capability");
|
|
736
|
+
expect(out).toContain("builder");
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// === --live flag ===
|
|
741
|
+
|
|
742
|
+
describe("--live flag", () => {
|
|
743
|
+
test("shows 'No live data available' when no snapshots exist", async () => {
|
|
744
|
+
const legioDir = join(tempDir, ".legio");
|
|
745
|
+
const metricsDbPath = join(legioDir, "metrics.db");
|
|
746
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
747
|
+
metricsStore.close();
|
|
748
|
+
|
|
749
|
+
await costsCommand(["--live"]);
|
|
750
|
+
const out = output();
|
|
751
|
+
|
|
752
|
+
expect(out).toContain("No live data available");
|
|
753
|
+
expect(out).toContain("Token snapshots begin after first tool call");
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("shows live table when snapshots exist with active sessions", async () => {
|
|
757
|
+
const legioDir = join(tempDir, ".legio");
|
|
758
|
+
|
|
759
|
+
// Create active sessions
|
|
760
|
+
const sessDbPath = join(legioDir, "sessions.db");
|
|
761
|
+
const sessionStore = createSessionStore(sessDbPath);
|
|
762
|
+
sessionStore.upsert({
|
|
763
|
+
id: "sess-001",
|
|
764
|
+
agentName: "builder-1",
|
|
765
|
+
capability: "builder",
|
|
766
|
+
worktreePath: "/tmp/wt1",
|
|
767
|
+
branchName: "feat/task1",
|
|
768
|
+
beadId: "task-001",
|
|
769
|
+
tmuxSession: "tmux-001",
|
|
770
|
+
state: "working",
|
|
771
|
+
pid: 12345,
|
|
772
|
+
parentAgent: null,
|
|
773
|
+
depth: 0,
|
|
774
|
+
runId: "run-001",
|
|
775
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
|
|
776
|
+
lastActivity: new Date().toISOString(),
|
|
777
|
+
escalationLevel: 0,
|
|
778
|
+
stalledSince: null,
|
|
779
|
+
});
|
|
780
|
+
sessionStore.close();
|
|
781
|
+
|
|
782
|
+
// Create snapshots
|
|
783
|
+
const metricsDbPath = join(legioDir, "metrics.db");
|
|
784
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
785
|
+
metricsStore.recordSnapshot({
|
|
786
|
+
agentName: "builder-1",
|
|
787
|
+
inputTokens: 1000,
|
|
788
|
+
outputTokens: 500,
|
|
789
|
+
cacheReadTokens: 200,
|
|
790
|
+
cacheCreationTokens: 100,
|
|
791
|
+
estimatedCostUsd: 0.15,
|
|
792
|
+
modelUsed: "claude-sonnet-4-5",
|
|
793
|
+
createdAt: new Date().toISOString(),
|
|
794
|
+
});
|
|
795
|
+
metricsStore.close();
|
|
796
|
+
|
|
797
|
+
await costsCommand(["--live"]);
|
|
798
|
+
const out = output();
|
|
799
|
+
|
|
800
|
+
expect(out).toContain("Live Token Usage");
|
|
801
|
+
expect(out).toContain("1 active agents");
|
|
802
|
+
expect(out).toContain("builder-1");
|
|
803
|
+
expect(out).toContain("builder");
|
|
804
|
+
expect(out).toContain("1,000"); // inputTokens
|
|
805
|
+
expect(out).toContain("500"); // outputTokens
|
|
806
|
+
expect(out).toContain("300"); // cache total (200 + 100)
|
|
807
|
+
expect(out).toContain("$0.15");
|
|
808
|
+
expect(out).toContain("Burn rate");
|
|
809
|
+
expect(out).toContain("tokens/min");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("JSON output with --live returns expected structure", async () => {
|
|
813
|
+
const legioDir = join(tempDir, ".legio");
|
|
814
|
+
|
|
815
|
+
// Create active sessions
|
|
816
|
+
const sessDbPath = join(legioDir, "sessions.db");
|
|
817
|
+
const sessionStore = createSessionStore(sessDbPath);
|
|
818
|
+
sessionStore.upsert({
|
|
819
|
+
id: "sess-001",
|
|
820
|
+
agentName: "builder-1",
|
|
821
|
+
capability: "builder",
|
|
822
|
+
worktreePath: "/tmp/wt1",
|
|
823
|
+
branchName: "feat/task1",
|
|
824
|
+
beadId: "task-001",
|
|
825
|
+
tmuxSession: "tmux-001",
|
|
826
|
+
state: "working",
|
|
827
|
+
pid: 12345,
|
|
828
|
+
parentAgent: null,
|
|
829
|
+
depth: 0,
|
|
830
|
+
runId: "run-001",
|
|
831
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
|
|
832
|
+
lastActivity: new Date().toISOString(),
|
|
833
|
+
escalationLevel: 0,
|
|
834
|
+
stalledSince: null,
|
|
835
|
+
});
|
|
836
|
+
sessionStore.close();
|
|
837
|
+
|
|
838
|
+
// Create snapshots
|
|
839
|
+
const metricsDbPath = join(legioDir, "metrics.db");
|
|
840
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
841
|
+
metricsStore.recordSnapshot({
|
|
842
|
+
agentName: "builder-1",
|
|
843
|
+
inputTokens: 1000,
|
|
844
|
+
outputTokens: 500,
|
|
845
|
+
cacheReadTokens: 200,
|
|
846
|
+
cacheCreationTokens: 100,
|
|
847
|
+
estimatedCostUsd: 0.15,
|
|
848
|
+
modelUsed: "claude-sonnet-4-5",
|
|
849
|
+
createdAt: new Date().toISOString(),
|
|
850
|
+
});
|
|
851
|
+
metricsStore.close();
|
|
852
|
+
|
|
853
|
+
await costsCommand(["--live", "--json"]);
|
|
854
|
+
const out = output();
|
|
855
|
+
|
|
856
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
857
|
+
agents: unknown[];
|
|
858
|
+
totals: Record<string, unknown>;
|
|
859
|
+
};
|
|
860
|
+
expect(parsed.agents).toHaveLength(1);
|
|
861
|
+
expect(parsed.totals).toBeDefined();
|
|
862
|
+
expect(parsed.totals.inputTokens).toBe(1000);
|
|
863
|
+
expect(parsed.totals.outputTokens).toBe(500);
|
|
864
|
+
expect(parsed.totals.cacheTokens).toBe(300);
|
|
865
|
+
expect(parsed.totals.costUsd).toBe(0.15);
|
|
866
|
+
expect(parsed.totals.burnRatePerMin).toBeGreaterThan(0);
|
|
867
|
+
expect(parsed.totals.tokensPerMin).toBeGreaterThan(0);
|
|
868
|
+
|
|
869
|
+
const agent = parsed.agents[0] as Record<string, unknown>;
|
|
870
|
+
expect(agent.agentName).toBe("builder-1");
|
|
871
|
+
expect(agent.capability).toBe("builder");
|
|
872
|
+
expect(agent.inputTokens).toBe(1000);
|
|
873
|
+
expect(agent.outputTokens).toBe(500);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("--live with --agent filters by agent", async () => {
|
|
877
|
+
const legioDir = join(tempDir, ".legio");
|
|
878
|
+
|
|
879
|
+
// Create active sessions
|
|
880
|
+
const sessDbPath = join(legioDir, "sessions.db");
|
|
881
|
+
const sessionStore = createSessionStore(sessDbPath);
|
|
882
|
+
sessionStore.upsert({
|
|
883
|
+
id: "sess-001",
|
|
884
|
+
agentName: "builder-1",
|
|
885
|
+
capability: "builder",
|
|
886
|
+
worktreePath: "/tmp/wt1",
|
|
887
|
+
branchName: "feat/task1",
|
|
888
|
+
beadId: "task-001",
|
|
889
|
+
tmuxSession: "tmux-001",
|
|
890
|
+
state: "working",
|
|
891
|
+
pid: 12345,
|
|
892
|
+
parentAgent: null,
|
|
893
|
+
depth: 0,
|
|
894
|
+
runId: "run-001",
|
|
895
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
896
|
+
lastActivity: new Date().toISOString(),
|
|
897
|
+
escalationLevel: 0,
|
|
898
|
+
stalledSince: null,
|
|
899
|
+
});
|
|
900
|
+
sessionStore.upsert({
|
|
901
|
+
id: "sess-002",
|
|
902
|
+
agentName: "scout-1",
|
|
903
|
+
capability: "scout",
|
|
904
|
+
worktreePath: "/tmp/wt2",
|
|
905
|
+
branchName: "feat/task2",
|
|
906
|
+
beadId: "task-002",
|
|
907
|
+
tmuxSession: "tmux-002",
|
|
908
|
+
state: "working",
|
|
909
|
+
pid: 12346,
|
|
910
|
+
parentAgent: null,
|
|
911
|
+
depth: 0,
|
|
912
|
+
runId: "run-001",
|
|
913
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
914
|
+
lastActivity: new Date().toISOString(),
|
|
915
|
+
escalationLevel: 0,
|
|
916
|
+
stalledSince: null,
|
|
917
|
+
});
|
|
918
|
+
sessionStore.close();
|
|
919
|
+
|
|
920
|
+
// Create snapshots
|
|
921
|
+
const metricsDbPath = join(legioDir, "metrics.db");
|
|
922
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
923
|
+
metricsStore.recordSnapshot({
|
|
924
|
+
agentName: "builder-1",
|
|
925
|
+
inputTokens: 1000,
|
|
926
|
+
outputTokens: 500,
|
|
927
|
+
cacheReadTokens: 0,
|
|
928
|
+
cacheCreationTokens: 0,
|
|
929
|
+
estimatedCostUsd: 0.15,
|
|
930
|
+
modelUsed: "claude-sonnet-4-5",
|
|
931
|
+
createdAt: new Date().toISOString(),
|
|
932
|
+
});
|
|
933
|
+
metricsStore.recordSnapshot({
|
|
934
|
+
agentName: "scout-1",
|
|
935
|
+
inputTokens: 2000,
|
|
936
|
+
outputTokens: 1000,
|
|
937
|
+
cacheReadTokens: 0,
|
|
938
|
+
cacheCreationTokens: 0,
|
|
939
|
+
estimatedCostUsd: 0.25,
|
|
940
|
+
modelUsed: "claude-sonnet-4-5",
|
|
941
|
+
createdAt: new Date().toISOString(),
|
|
942
|
+
});
|
|
943
|
+
metricsStore.close();
|
|
944
|
+
|
|
945
|
+
await costsCommand(["--live", "--json", "--agent", "builder-1"]);
|
|
946
|
+
const out = output();
|
|
947
|
+
|
|
948
|
+
const parsed = JSON.parse(out.trim()) as { agents: Record<string, unknown>[] };
|
|
949
|
+
expect(parsed.agents).toHaveLength(1);
|
|
950
|
+
expect(parsed.agents[0]?.agentName).toBe("builder-1");
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test("--live shows burn rate in output", async () => {
|
|
954
|
+
const legioDir = join(tempDir, ".legio");
|
|
955
|
+
|
|
956
|
+
// Create active sessions
|
|
957
|
+
const sessDbPath = join(legioDir, "sessions.db");
|
|
958
|
+
const sessionStore = createSessionStore(sessDbPath);
|
|
959
|
+
sessionStore.upsert({
|
|
960
|
+
id: "sess-001",
|
|
961
|
+
agentName: "builder-1",
|
|
962
|
+
capability: "builder",
|
|
963
|
+
worktreePath: "/tmp/wt1",
|
|
964
|
+
branchName: "feat/task1",
|
|
965
|
+
beadId: "task-001",
|
|
966
|
+
tmuxSession: "tmux-001",
|
|
967
|
+
state: "working",
|
|
968
|
+
pid: 12345,
|
|
969
|
+
parentAgent: null,
|
|
970
|
+
depth: 0,
|
|
971
|
+
runId: "run-001",
|
|
972
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
|
|
973
|
+
lastActivity: new Date().toISOString(),
|
|
974
|
+
escalationLevel: 0,
|
|
975
|
+
stalledSince: null,
|
|
976
|
+
});
|
|
977
|
+
sessionStore.close();
|
|
978
|
+
|
|
979
|
+
// Create snapshots
|
|
980
|
+
const metricsDbPath = join(legioDir, "metrics.db");
|
|
981
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
982
|
+
metricsStore.recordSnapshot({
|
|
983
|
+
agentName: "builder-1",
|
|
984
|
+
inputTokens: 1000,
|
|
985
|
+
outputTokens: 500,
|
|
986
|
+
cacheReadTokens: 0,
|
|
987
|
+
cacheCreationTokens: 0,
|
|
988
|
+
estimatedCostUsd: 0.3,
|
|
989
|
+
modelUsed: "claude-sonnet-4-5",
|
|
990
|
+
createdAt: new Date().toISOString(),
|
|
991
|
+
});
|
|
992
|
+
metricsStore.close();
|
|
993
|
+
|
|
994
|
+
await costsCommand(["--live"]);
|
|
995
|
+
const out = output();
|
|
996
|
+
|
|
997
|
+
expect(out).toContain("Burn rate:");
|
|
998
|
+
expect(out).toContain("/min");
|
|
999
|
+
expect(out).toContain("tokens/min");
|
|
1000
|
+
expect(out).toContain("Elapsed:");
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
test("--live with no metrics.db shows empty JSON or message", async () => {
|
|
1004
|
+
await costsCommand(["--live", "--json"]);
|
|
1005
|
+
const out = output();
|
|
1006
|
+
|
|
1007
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
1008
|
+
agents: unknown[];
|
|
1009
|
+
totals: Record<string, unknown>;
|
|
1010
|
+
};
|
|
1011
|
+
expect(parsed.agents).toEqual([]);
|
|
1012
|
+
expect(parsed.totals.costUsd).toBe(0);
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
});
|