@os-eco/overstory-cli 0.7.7 → 0.7.9
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/README.md +105 -3
- package/package.json +1 -1
- package/src/agents/manifest.test.ts +168 -1
- package/src/agents/manifest.ts +23 -2
- package/src/commands/agents.ts +1 -0
- package/src/commands/coordinator.test.ts +131 -2
- package/src/commands/coordinator.ts +40 -9
- package/src/commands/costs.test.ts +5 -0
- package/src/commands/costs.ts +1 -1
- package/src/commands/init.test.ts +1 -0
- package/src/commands/init.ts +1 -0
- package/src/commands/log.ts +2 -0
- package/src/commands/prime.test.ts +1 -0
- package/src/commands/sling.test.ts +63 -1
- package/src/commands/sling.ts +37 -2
- package/src/config.test.ts +68 -0
- package/src/config.ts +16 -0
- package/src/doctor/structure.test.ts +1 -0
- package/src/doctor/structure.ts +1 -0
- package/src/index.ts +2 -1
- package/src/metrics/pricing.test.ts +258 -0
- package/src/metrics/store.test.ts +227 -0
- package/src/metrics/store.ts +40 -5
- package/src/runtimes/gemini.test.ts +537 -0
- package/src/runtimes/gemini.ts +235 -0
- package/src/runtimes/registry.test.ts +15 -1
- package/src/runtimes/registry.ts +2 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/types.ts +8 -0
- package/src/worktree/tmux.test.ts +49 -0
- package/src/worktree/tmux.ts +33 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
6
|
+
import type { ResolvedModel } from "../types.ts";
|
|
7
|
+
import { GeminiRuntime } from "./gemini.ts";
|
|
8
|
+
import type { SpawnOpts } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
describe("GeminiRuntime", () => {
|
|
11
|
+
const runtime = new GeminiRuntime();
|
|
12
|
+
|
|
13
|
+
describe("id and instructionPath", () => {
|
|
14
|
+
test("id is 'gemini'", () => {
|
|
15
|
+
expect(runtime.id).toBe("gemini");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("instructionPath is GEMINI.md", () => {
|
|
19
|
+
expect(runtime.instructionPath).toBe("GEMINI.md");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("buildSpawnCommand", () => {
|
|
24
|
+
test("bypass permission mode includes --approval-mode yolo", () => {
|
|
25
|
+
const opts: SpawnOpts = {
|
|
26
|
+
model: "gemini-2.5-pro",
|
|
27
|
+
permissionMode: "bypass",
|
|
28
|
+
cwd: "/tmp/worktree",
|
|
29
|
+
env: {},
|
|
30
|
+
};
|
|
31
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
32
|
+
expect(cmd).toBe("gemini -m gemini-2.5-pro --approval-mode yolo");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("ask permission mode omits approval flag", () => {
|
|
36
|
+
const opts: SpawnOpts = {
|
|
37
|
+
model: "gemini-2.5-flash",
|
|
38
|
+
permissionMode: "ask",
|
|
39
|
+
cwd: "/tmp/worktree",
|
|
40
|
+
env: {},
|
|
41
|
+
};
|
|
42
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
43
|
+
expect(cmd).toBe("gemini -m gemini-2.5-flash");
|
|
44
|
+
expect(cmd).not.toContain("--approval-mode");
|
|
45
|
+
expect(cmd).not.toContain("yolo");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("appendSystemPrompt is ignored (gemini has no such flag)", () => {
|
|
49
|
+
const opts: SpawnOpts = {
|
|
50
|
+
model: "gemini-2.5-pro",
|
|
51
|
+
permissionMode: "bypass",
|
|
52
|
+
cwd: "/tmp/worktree",
|
|
53
|
+
env: {},
|
|
54
|
+
appendSystemPrompt: "You are a builder agent.",
|
|
55
|
+
};
|
|
56
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
57
|
+
expect(cmd).toBe("gemini -m gemini-2.5-pro --approval-mode yolo");
|
|
58
|
+
expect(cmd).not.toContain("append-system-prompt");
|
|
59
|
+
expect(cmd).not.toContain("You are a builder agent");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("appendSystemPromptFile is ignored (gemini has no such flag)", () => {
|
|
63
|
+
const opts: SpawnOpts = {
|
|
64
|
+
model: "gemini-2.5-pro",
|
|
65
|
+
permissionMode: "bypass",
|
|
66
|
+
cwd: "/project",
|
|
67
|
+
env: {},
|
|
68
|
+
appendSystemPromptFile: "/project/.overstory/agent-defs/coordinator.md",
|
|
69
|
+
};
|
|
70
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
71
|
+
expect(cmd).toBe("gemini -m gemini-2.5-pro --approval-mode yolo");
|
|
72
|
+
expect(cmd).not.toContain("cat");
|
|
73
|
+
expect(cmd).not.toContain("coordinator.md");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("cwd and env are not embedded in command string", () => {
|
|
77
|
+
const opts: SpawnOpts = {
|
|
78
|
+
model: "gemini-2.5-pro",
|
|
79
|
+
permissionMode: "bypass",
|
|
80
|
+
cwd: "/some/specific/path",
|
|
81
|
+
env: { GEMINI_API_KEY: "test-key" },
|
|
82
|
+
};
|
|
83
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
84
|
+
expect(cmd).not.toContain("/some/specific/path");
|
|
85
|
+
expect(cmd).not.toContain("test-key");
|
|
86
|
+
expect(cmd).not.toContain("GEMINI_API_KEY");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("model alias is passed through unchanged", () => {
|
|
90
|
+
const opts: SpawnOpts = {
|
|
91
|
+
model: "flash",
|
|
92
|
+
permissionMode: "bypass",
|
|
93
|
+
cwd: "/tmp/worktree",
|
|
94
|
+
env: {},
|
|
95
|
+
};
|
|
96
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
97
|
+
expect(cmd).toContain("-m flash");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("buildPrintCommand", () => {
|
|
102
|
+
test("basic prompt produces gemini -p argv with --yolo", () => {
|
|
103
|
+
const cmd = runtime.buildPrintCommand("Resolve this conflict");
|
|
104
|
+
expect(cmd).toEqual(["gemini", "-p", "Resolve this conflict", "--yolo"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("with model override adds -m flag", () => {
|
|
108
|
+
const cmd = runtime.buildPrintCommand("Triage this failure", "gemini-2.5-flash");
|
|
109
|
+
expect(cmd).toEqual([
|
|
110
|
+
"gemini",
|
|
111
|
+
"-p",
|
|
112
|
+
"Triage this failure",
|
|
113
|
+
"--yolo",
|
|
114
|
+
"-m",
|
|
115
|
+
"gemini-2.5-flash",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("without model omits -m flag", () => {
|
|
120
|
+
const cmd = runtime.buildPrintCommand("Classify this error");
|
|
121
|
+
expect(cmd).not.toContain("-m");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("prompt with special characters is preserved", () => {
|
|
125
|
+
const prompt = 'Fix the "bug" in file\'s path & run tests';
|
|
126
|
+
const cmd = runtime.buildPrintCommand(prompt);
|
|
127
|
+
expect(cmd[2]).toBe(prompt);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("empty prompt is passed through", () => {
|
|
131
|
+
const cmd = runtime.buildPrintCommand("");
|
|
132
|
+
expect(cmd).toEqual(["gemini", "-p", "", "--yolo"]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("deployConfig", () => {
|
|
137
|
+
let tempDir: string;
|
|
138
|
+
|
|
139
|
+
beforeEach(async () => {
|
|
140
|
+
tempDir = await mkdtemp(join(tmpdir(), "ov-gemini-test-"));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterEach(async () => {
|
|
144
|
+
await cleanupTempDir(tempDir);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("writes GEMINI.md to worktree root", async () => {
|
|
148
|
+
await runtime.deployConfig(
|
|
149
|
+
tempDir,
|
|
150
|
+
{ content: "# Task\nBuild the feature." },
|
|
151
|
+
{ agentName: "test-agent", capability: "builder", worktreePath: tempDir },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const file = Bun.file(join(tempDir, "GEMINI.md"));
|
|
155
|
+
expect(await file.exists()).toBe(true);
|
|
156
|
+
expect(await file.text()).toBe("# Task\nBuild the feature.");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("no-op when overlay is undefined", async () => {
|
|
160
|
+
await runtime.deployConfig(tempDir, undefined, {
|
|
161
|
+
agentName: "test-agent",
|
|
162
|
+
capability: "coordinator",
|
|
163
|
+
worktreePath: tempDir,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const file = Bun.file(join(tempDir, "GEMINI.md"));
|
|
167
|
+
expect(await file.exists()).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("hooks parameter is unused (no guard deployment)", async () => {
|
|
171
|
+
await runtime.deployConfig(
|
|
172
|
+
tempDir,
|
|
173
|
+
{ content: "# Instructions" },
|
|
174
|
+
{
|
|
175
|
+
agentName: "my-builder",
|
|
176
|
+
capability: "builder",
|
|
177
|
+
worktreePath: tempDir,
|
|
178
|
+
qualityGates: [
|
|
179
|
+
{ command: "bun test", name: "tests", description: "all tests must pass" },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Only GEMINI.md should exist — no settings files or guard extensions.
|
|
185
|
+
const geminiFile = Bun.file(join(tempDir, "GEMINI.md"));
|
|
186
|
+
expect(await geminiFile.exists()).toBe(true);
|
|
187
|
+
|
|
188
|
+
// No Claude Code settings file.
|
|
189
|
+
const settingsFile = Bun.file(join(tempDir, ".claude", "settings.local.json"));
|
|
190
|
+
expect(await settingsFile.exists()).toBe(false);
|
|
191
|
+
|
|
192
|
+
// No Pi guard extension.
|
|
193
|
+
const piGuardFile = Bun.file(join(tempDir, ".pi", "extensions", "overstory-guard.ts"));
|
|
194
|
+
expect(await piGuardFile.exists()).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("overwrites existing GEMINI.md", async () => {
|
|
198
|
+
await Bun.write(join(tempDir, "GEMINI.md"), "# Old content");
|
|
199
|
+
|
|
200
|
+
await runtime.deployConfig(
|
|
201
|
+
tempDir,
|
|
202
|
+
{ content: "# New content" },
|
|
203
|
+
{ agentName: "test-agent", capability: "builder", worktreePath: tempDir },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const file = Bun.file(join(tempDir, "GEMINI.md"));
|
|
207
|
+
expect(await file.text()).toBe("# New content");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("creates parent directories if needed", async () => {
|
|
211
|
+
const nestedDir = join(tempDir, "nested", "deep");
|
|
212
|
+
|
|
213
|
+
await runtime.deployConfig(
|
|
214
|
+
nestedDir,
|
|
215
|
+
{ content: "# Nested" },
|
|
216
|
+
{ agentName: "test-agent", capability: "builder", worktreePath: nestedDir },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const file = Bun.file(join(nestedDir, "GEMINI.md"));
|
|
220
|
+
expect(await file.exists()).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("detectReady", () => {
|
|
225
|
+
test("returns ready when placeholder and gemini branding visible", () => {
|
|
226
|
+
const pane = "✨ Gemini CLI v1.0.0\n\n> Type your message or @path/to/file";
|
|
227
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("returns ready with > prefix and gemini text", () => {
|
|
231
|
+
const pane = "gemini-2.5-pro | model: gemini\n> ";
|
|
232
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("returns ready with ❯ prompt and gemini text", () => {
|
|
236
|
+
const pane = "Gemini CLI\n❯ ";
|
|
237
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("returns loading when no prompt indicator", () => {
|
|
241
|
+
const pane = "Starting Gemini CLI...";
|
|
242
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "loading" });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("returns loading when no gemini branding", () => {
|
|
246
|
+
const pane = "> Type your message or @path/to/file";
|
|
247
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "loading" });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("returns loading for empty pane", () => {
|
|
251
|
+
expect(runtime.detectReady("")).toEqual({ phase: "loading" });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("returns loading during initialization", () => {
|
|
255
|
+
const pane = "Loading model...";
|
|
256
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "loading" });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("case-insensitive gemini detection", () => {
|
|
260
|
+
const pane = "GEMINI CLI v1.0\n> ready";
|
|
261
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("case-insensitive placeholder detection", () => {
|
|
265
|
+
const pane = "Gemini\nType Your Message here";
|
|
266
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("never returns dialog phase (gemini has no trust dialog)", () => {
|
|
270
|
+
// Try various pane contents — should never get "dialog" phase.
|
|
271
|
+
const panes = ["", "Gemini CLI", "> ready", "Gemini\n> ", "Loading...", "trust this folder"];
|
|
272
|
+
for (const pane of panes) {
|
|
273
|
+
const result = runtime.detectReady(pane);
|
|
274
|
+
expect(result.phase).not.toBe("dialog");
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("requiresBeaconVerification", () => {
|
|
280
|
+
test("not defined — defaults to true (gets resend loop)", () => {
|
|
281
|
+
// GeminiRuntime does not override requiresBeaconVerification.
|
|
282
|
+
// When omitted, the orchestrator defaults to true (resend loop enabled).
|
|
283
|
+
// Verify the method is not present on the instance.
|
|
284
|
+
expect("requiresBeaconVerification" in runtime).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("parseTranscript", () => {
|
|
289
|
+
let tempDir: string;
|
|
290
|
+
|
|
291
|
+
beforeEach(async () => {
|
|
292
|
+
tempDir = await mkdtemp(join(tmpdir(), "ov-gemini-transcript-"));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
afterEach(async () => {
|
|
296
|
+
await cleanupTempDir(tempDir);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("returns null for missing file", async () => {
|
|
300
|
+
const result = await runtime.parseTranscript(join(tempDir, "nonexistent.jsonl"));
|
|
301
|
+
expect(result).toBeNull();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("parses init event for model", async () => {
|
|
305
|
+
const transcript = [
|
|
306
|
+
'{"type":"init","timestamp":"2026-01-01T00:00:00Z","session_id":"abc","model":"gemini-2.5-pro"}',
|
|
307
|
+
'{"type":"result","timestamp":"2026-01-01T00:01:00Z","status":"success","stats":{"input_tokens":100,"output_tokens":50,"total_tokens":150,"cached":0,"input":100,"duration_ms":1000,"tool_calls":0}}',
|
|
308
|
+
].join("\n");
|
|
309
|
+
|
|
310
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
311
|
+
await Bun.write(path, transcript);
|
|
312
|
+
|
|
313
|
+
const result = await runtime.parseTranscript(path);
|
|
314
|
+
expect(result).toEqual({
|
|
315
|
+
inputTokens: 100,
|
|
316
|
+
outputTokens: 50,
|
|
317
|
+
model: "gemini-2.5-pro",
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("aggregates token usage from multiple result events", async () => {
|
|
322
|
+
const transcript = [
|
|
323
|
+
'{"type":"init","model":"gemini-2.5-flash"}',
|
|
324
|
+
'{"type":"result","stats":{"input_tokens":200,"output_tokens":100}}',
|
|
325
|
+
'{"type":"result","stats":{"input_tokens":150,"output_tokens":75}}',
|
|
326
|
+
].join("\n");
|
|
327
|
+
|
|
328
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
329
|
+
await Bun.write(path, transcript);
|
|
330
|
+
|
|
331
|
+
const result = await runtime.parseTranscript(path);
|
|
332
|
+
expect(result).toEqual({
|
|
333
|
+
inputTokens: 350,
|
|
334
|
+
outputTokens: 175,
|
|
335
|
+
model: "gemini-2.5-flash",
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("handles transcript with no token usage", async () => {
|
|
340
|
+
const transcript = [
|
|
341
|
+
'{"type":"init","model":"gemini-2.5-pro"}',
|
|
342
|
+
'{"type":"message","role":"user","content":"hello"}',
|
|
343
|
+
'{"type":"message","role":"assistant","content":"hi","delta":true}',
|
|
344
|
+
].join("\n");
|
|
345
|
+
|
|
346
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
347
|
+
await Bun.write(path, transcript);
|
|
348
|
+
|
|
349
|
+
const result = await runtime.parseTranscript(path);
|
|
350
|
+
expect(result).toEqual({
|
|
351
|
+
inputTokens: 0,
|
|
352
|
+
outputTokens: 0,
|
|
353
|
+
model: "gemini-2.5-pro",
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("skips malformed JSON lines", async () => {
|
|
358
|
+
const transcript = [
|
|
359
|
+
'{"type":"init","model":"gemini-2.5-pro"}',
|
|
360
|
+
"this is not json",
|
|
361
|
+
'{"type":"result","stats":{"input_tokens":500,"output_tokens":200}}',
|
|
362
|
+
"{broken json",
|
|
363
|
+
].join("\n");
|
|
364
|
+
|
|
365
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
366
|
+
await Bun.write(path, transcript);
|
|
367
|
+
|
|
368
|
+
const result = await runtime.parseTranscript(path);
|
|
369
|
+
expect(result).toEqual({
|
|
370
|
+
inputTokens: 500,
|
|
371
|
+
outputTokens: 200,
|
|
372
|
+
model: "gemini-2.5-pro",
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("returns empty model when no init event", async () => {
|
|
377
|
+
const transcript = '{"type":"result","stats":{"input_tokens":100,"output_tokens":50}}';
|
|
378
|
+
|
|
379
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
380
|
+
await Bun.write(path, transcript);
|
|
381
|
+
|
|
382
|
+
const result = await runtime.parseTranscript(path);
|
|
383
|
+
expect(result).toEqual({
|
|
384
|
+
inputTokens: 100,
|
|
385
|
+
outputTokens: 50,
|
|
386
|
+
model: "",
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("handles empty file", async () => {
|
|
391
|
+
const path = join(tempDir, "empty.jsonl");
|
|
392
|
+
await Bun.write(path, "");
|
|
393
|
+
|
|
394
|
+
const result = await runtime.parseTranscript(path);
|
|
395
|
+
expect(result).toEqual({
|
|
396
|
+
inputTokens: 0,
|
|
397
|
+
outputTokens: 0,
|
|
398
|
+
model: "",
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("handles result event with missing stats", async () => {
|
|
403
|
+
const transcript = [
|
|
404
|
+
'{"type":"init","model":"gemini-2.5-pro"}',
|
|
405
|
+
'{"type":"result","status":"error"}',
|
|
406
|
+
].join("\n");
|
|
407
|
+
|
|
408
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
409
|
+
await Bun.write(path, transcript);
|
|
410
|
+
|
|
411
|
+
const result = await runtime.parseTranscript(path);
|
|
412
|
+
expect(result).toEqual({
|
|
413
|
+
inputTokens: 0,
|
|
414
|
+
outputTokens: 0,
|
|
415
|
+
model: "gemini-2.5-pro",
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("handles result event with partial stats", async () => {
|
|
420
|
+
const transcript = [
|
|
421
|
+
'{"type":"init","model":"gemini-2.5-pro"}',
|
|
422
|
+
'{"type":"result","stats":{"input_tokens":300}}',
|
|
423
|
+
].join("\n");
|
|
424
|
+
|
|
425
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
426
|
+
await Bun.write(path, transcript);
|
|
427
|
+
|
|
428
|
+
const result = await runtime.parseTranscript(path);
|
|
429
|
+
expect(result).toEqual({
|
|
430
|
+
inputTokens: 300,
|
|
431
|
+
outputTokens: 0,
|
|
432
|
+
model: "gemini-2.5-pro",
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("fallback model from any event with model field", async () => {
|
|
437
|
+
const transcript = [
|
|
438
|
+
'{"type":"message","role":"assistant","model":"gemini-2.5-pro","content":"hello"}',
|
|
439
|
+
'{"type":"result","stats":{"input_tokens":50,"output_tokens":25}}',
|
|
440
|
+
].join("\n");
|
|
441
|
+
|
|
442
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
443
|
+
await Bun.write(path, transcript);
|
|
444
|
+
|
|
445
|
+
const result = await runtime.parseTranscript(path);
|
|
446
|
+
expect(result).toEqual({
|
|
447
|
+
inputTokens: 50,
|
|
448
|
+
outputTokens: 25,
|
|
449
|
+
model: "gemini-2.5-pro",
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("init event model takes precedence over fallback", async () => {
|
|
454
|
+
const transcript = [
|
|
455
|
+
'{"type":"message","model":"gemini-2.5-flash"}',
|
|
456
|
+
'{"type":"init","model":"gemini-2.5-pro"}',
|
|
457
|
+
'{"type":"result","stats":{"input_tokens":10,"output_tokens":5}}',
|
|
458
|
+
].join("\n");
|
|
459
|
+
|
|
460
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
461
|
+
await Bun.write(path, transcript);
|
|
462
|
+
|
|
463
|
+
const result = await runtime.parseTranscript(path);
|
|
464
|
+
expect(result).toEqual({
|
|
465
|
+
inputTokens: 10,
|
|
466
|
+
outputTokens: 5,
|
|
467
|
+
model: "gemini-2.5-pro",
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("handles trailing newline", async () => {
|
|
472
|
+
const transcript =
|
|
473
|
+
'{"type":"init","model":"gemini-2.5-pro"}\n{"type":"result","stats":{"input_tokens":100,"output_tokens":50}}\n';
|
|
474
|
+
|
|
475
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
476
|
+
await Bun.write(path, transcript);
|
|
477
|
+
|
|
478
|
+
const result = await runtime.parseTranscript(path);
|
|
479
|
+
expect(result).toEqual({
|
|
480
|
+
inputTokens: 100,
|
|
481
|
+
outputTokens: 50,
|
|
482
|
+
model: "gemini-2.5-pro",
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("full stream-json transcript with all event types", async () => {
|
|
487
|
+
const transcript = [
|
|
488
|
+
'{"type":"init","timestamp":"2026-03-01T12:00:00Z","session_id":"sess-123","model":"gemini-2.5-pro"}',
|
|
489
|
+
'{"type":"message","timestamp":"2026-03-01T12:00:01Z","role":"user","content":"Fix the bug"}',
|
|
490
|
+
'{"type":"message","timestamp":"2026-03-01T12:00:02Z","role":"assistant","content":"I will","delta":true}',
|
|
491
|
+
'{"type":"tool_use","timestamp":"2026-03-01T12:00:03Z","tool_name":"Read","tool_id":"read-1","parameters":{"file_path":"/src/main.ts"}}',
|
|
492
|
+
'{"type":"tool_result","timestamp":"2026-03-01T12:00:04Z","tool_id":"read-1","status":"success","output":"contents"}',
|
|
493
|
+
'{"type":"message","timestamp":"2026-03-01T12:00:05Z","role":"assistant","content":"Fixed it","delta":true}',
|
|
494
|
+
'{"type":"result","timestamp":"2026-03-01T12:00:06Z","status":"success","stats":{"total_tokens":1500,"input_tokens":1000,"output_tokens":500,"cached":200,"input":800,"duration_ms":5000,"tool_calls":1}}',
|
|
495
|
+
].join("\n");
|
|
496
|
+
|
|
497
|
+
const path = join(tempDir, "full-session.jsonl");
|
|
498
|
+
await Bun.write(path, transcript);
|
|
499
|
+
|
|
500
|
+
const result = await runtime.parseTranscript(path);
|
|
501
|
+
expect(result).toEqual({
|
|
502
|
+
inputTokens: 1000,
|
|
503
|
+
outputTokens: 500,
|
|
504
|
+
model: "gemini-2.5-pro",
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("buildEnv", () => {
|
|
510
|
+
test("returns model env vars when present", () => {
|
|
511
|
+
const model: ResolvedModel = {
|
|
512
|
+
model: "gemini-2.5-pro",
|
|
513
|
+
env: { GEMINI_API_KEY: "test-key-123" },
|
|
514
|
+
};
|
|
515
|
+
expect(runtime.buildEnv(model)).toEqual({ GEMINI_API_KEY: "test-key-123" });
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("returns empty object when model has no env", () => {
|
|
519
|
+
const model: ResolvedModel = { model: "gemini-2.5-pro" };
|
|
520
|
+
expect(runtime.buildEnv(model)).toEqual({});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("passes through multiple env vars", () => {
|
|
524
|
+
const model: ResolvedModel = {
|
|
525
|
+
model: "gemini-2.5-pro",
|
|
526
|
+
env: {
|
|
527
|
+
GEMINI_API_KEY: "key",
|
|
528
|
+
GOOGLE_CLOUD_PROJECT: "my-project",
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
expect(runtime.buildEnv(model)).toEqual({
|
|
532
|
+
GEMINI_API_KEY: "key",
|
|
533
|
+
GOOGLE_CLOUD_PROJECT: "my-project",
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
});
|