@os-eco/overstory-cli 0.7.6 → 0.7.7

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.
@@ -0,0 +1,741 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ResolvedModel } from "../types.ts";
6
+ import { CodexRuntime } from "./codex.ts";
7
+ import type { SpawnOpts } from "./types.ts";
8
+
9
+ describe("CodexRuntime", () => {
10
+ const runtime = new CodexRuntime();
11
+
12
+ describe("id and instructionPath", () => {
13
+ test("id is 'codex'", () => {
14
+ expect(runtime.id).toBe("codex");
15
+ });
16
+
17
+ test("instructionPath is AGENTS.md", () => {
18
+ expect(runtime.instructionPath).toBe("AGENTS.md");
19
+ });
20
+ });
21
+
22
+ describe("buildSpawnCommand", () => {
23
+ test("basic command uses codex exec with --full-auto and --json", () => {
24
+ const opts: SpawnOpts = {
25
+ model: "gpt-5-codex",
26
+ permissionMode: "bypass",
27
+ cwd: "/tmp/worktree",
28
+ env: {},
29
+ };
30
+ const cmd = runtime.buildSpawnCommand(opts);
31
+ expect(cmd).toContain("codex exec --full-auto --json");
32
+ expect(cmd).toContain("--model gpt-5-codex");
33
+ expect(cmd).toContain("Read AGENTS.md");
34
+ });
35
+
36
+ test("permissionMode is NOT included in command (Codex uses OS sandbox)", () => {
37
+ const opts: SpawnOpts = {
38
+ model: "gpt-5-codex",
39
+ permissionMode: "bypass",
40
+ cwd: "/tmp/worktree",
41
+ env: {},
42
+ };
43
+ const cmd = runtime.buildSpawnCommand(opts);
44
+ expect(cmd).not.toContain("--permission-mode");
45
+ expect(cmd).not.toContain("bypassPermissions");
46
+ });
47
+
48
+ test("ask permissionMode also excluded (OS sandbox enforces security)", () => {
49
+ const opts: SpawnOpts = {
50
+ model: "gpt-5-codex",
51
+ permissionMode: "ask",
52
+ cwd: "/tmp/worktree",
53
+ env: {},
54
+ };
55
+ const cmd = runtime.buildSpawnCommand(opts);
56
+ expect(cmd).not.toContain("--permission-mode");
57
+ });
58
+
59
+ test("with appendSystemPrompt prepends to the exec prompt", () => {
60
+ const opts: SpawnOpts = {
61
+ model: "gpt-5-codex",
62
+ permissionMode: "bypass",
63
+ cwd: "/tmp/worktree",
64
+ env: {},
65
+ appendSystemPrompt: "You are a builder agent.",
66
+ };
67
+ const cmd = runtime.buildSpawnCommand(opts);
68
+ expect(cmd).toContain("You are a builder agent.");
69
+ expect(cmd).toContain("Read AGENTS.md");
70
+ });
71
+
72
+ test("with appendSystemPrompt containing single quotes (POSIX escape)", () => {
73
+ const opts: SpawnOpts = {
74
+ model: "gpt-5-codex",
75
+ permissionMode: "bypass",
76
+ cwd: "/tmp/worktree",
77
+ env: {},
78
+ appendSystemPrompt: "Don't touch the user's files",
79
+ };
80
+ const cmd = runtime.buildSpawnCommand(opts);
81
+ expect(cmd).toContain("Don'\\''t touch the user'\\''s files");
82
+ expect(cmd).toContain("Read AGENTS.md");
83
+ });
84
+
85
+ test("with appendSystemPromptFile uses $(cat ...) expansion", () => {
86
+ const opts: SpawnOpts = {
87
+ model: "gpt-5-codex",
88
+ permissionMode: "bypass",
89
+ cwd: "/project",
90
+ env: {},
91
+ appendSystemPromptFile: "/project/.overstory/agent-defs/coordinator.md",
92
+ };
93
+ const cmd = runtime.buildSpawnCommand(opts);
94
+ expect(cmd).toContain("$(cat '/project/.overstory/agent-defs/coordinator.md')");
95
+ expect(cmd).toContain("Read AGENTS.md");
96
+ });
97
+
98
+ test("appendSystemPromptFile with single quotes in path", () => {
99
+ const opts: SpawnOpts = {
100
+ model: "gpt-5-codex",
101
+ permissionMode: "bypass",
102
+ cwd: "/project",
103
+ env: {},
104
+ appendSystemPromptFile: "/project/it's a path/agent.md",
105
+ };
106
+ const cmd = runtime.buildSpawnCommand(opts);
107
+ expect(cmd).toContain("$(cat '/project/it'\\''s a path/agent.md')");
108
+ });
109
+
110
+ test("appendSystemPromptFile suffix is single-quoted (prevents shell expansion)", () => {
111
+ const opts: SpawnOpts = {
112
+ model: "gpt-5-codex",
113
+ permissionMode: "bypass",
114
+ cwd: "/project",
115
+ env: {},
116
+ appendSystemPromptFile: "/project/agent.md",
117
+ };
118
+ const cmd = runtime.buildSpawnCommand(opts);
119
+ // The suffix text must be in single quotes, NOT double quotes.
120
+ // Double quotes would allow $, backticks, and " in the cat output
121
+ // to be interpreted by the shell.
122
+ expect(cmd).toContain("')\"' Read AGENTS.md");
123
+ expect(cmd).toEndWith("begin immediately.'");
124
+ });
125
+
126
+ test("appendSystemPromptFile takes precedence over appendSystemPrompt", () => {
127
+ const opts: SpawnOpts = {
128
+ model: "gpt-5-codex",
129
+ permissionMode: "bypass",
130
+ cwd: "/project",
131
+ env: {},
132
+ appendSystemPromptFile: "/project/.overstory/agent-defs/coordinator.md",
133
+ appendSystemPrompt: "This inline content should be ignored",
134
+ };
135
+ const cmd = runtime.buildSpawnCommand(opts);
136
+ expect(cmd).toContain("$(cat ");
137
+ expect(cmd).not.toContain("This inline content should be ignored");
138
+ });
139
+
140
+ test("without appendSystemPrompt uses default AGENTS.md prompt", () => {
141
+ const opts: SpawnOpts = {
142
+ model: "gpt-5-codex",
143
+ permissionMode: "bypass",
144
+ cwd: "/tmp/worktree",
145
+ env: {},
146
+ };
147
+ const cmd = runtime.buildSpawnCommand(opts);
148
+ expect(cmd).toBe(
149
+ "codex exec --full-auto --json --model gpt-5-codex 'Read AGENTS.md for your task assignment and begin immediately.'",
150
+ );
151
+ });
152
+
153
+ test("cwd and env are not embedded in command string", () => {
154
+ const opts: SpawnOpts = {
155
+ model: "gpt-5-codex",
156
+ permissionMode: "bypass",
157
+ cwd: "/some/specific/path",
158
+ env: { OPENAI_API_KEY: "sk-test-123" },
159
+ };
160
+ const cmd = runtime.buildSpawnCommand(opts);
161
+ expect(cmd).not.toContain("/some/specific/path");
162
+ expect(cmd).not.toContain("sk-test-123");
163
+ expect(cmd).not.toContain("OPENAI_API_KEY");
164
+ });
165
+
166
+ test("produces identical output for the same inputs (deterministic)", () => {
167
+ const opts: SpawnOpts = {
168
+ model: "gpt-5-codex",
169
+ permissionMode: "bypass",
170
+ cwd: "/tmp/worktree",
171
+ env: {},
172
+ appendSystemPrompt: "You are a builder.",
173
+ };
174
+ const cmd1 = runtime.buildSpawnCommand(opts);
175
+ const cmd2 = runtime.buildSpawnCommand(opts);
176
+ expect(cmd1).toBe(cmd2);
177
+ });
178
+
179
+ test("all model names pass through unchanged", () => {
180
+ for (const model of ["gpt-5-codex", "gpt-4o", "o3", "custom-model-v2"]) {
181
+ const opts: SpawnOpts = {
182
+ model,
183
+ permissionMode: "bypass",
184
+ cwd: "/tmp",
185
+ env: {},
186
+ };
187
+ const cmd = runtime.buildSpawnCommand(opts);
188
+ expect(cmd).toContain(`--model ${model}`);
189
+ }
190
+ });
191
+
192
+ test("systemPrompt field is ignored", () => {
193
+ const opts: SpawnOpts = {
194
+ model: "gpt-5-codex",
195
+ permissionMode: "bypass",
196
+ cwd: "/tmp",
197
+ env: {},
198
+ systemPrompt: "This should not appear",
199
+ };
200
+ const cmd = runtime.buildSpawnCommand(opts);
201
+ expect(cmd).not.toContain("This should not appear");
202
+ });
203
+ });
204
+
205
+ describe("buildPrintCommand", () => {
206
+ test("basic command without model", () => {
207
+ const argv = runtime.buildPrintCommand("Summarize this diff");
208
+ expect(argv).toEqual(["codex", "exec", "--full-auto", "--ephemeral", "Summarize this diff"]);
209
+ });
210
+
211
+ test("command with model override", () => {
212
+ const argv = runtime.buildPrintCommand("Classify this error", "gpt-5-codex");
213
+ expect(argv).toEqual([
214
+ "codex",
215
+ "exec",
216
+ "--full-auto",
217
+ "--ephemeral",
218
+ "--model",
219
+ "gpt-5-codex",
220
+ "Classify this error",
221
+ ]);
222
+ });
223
+
224
+ test("model undefined omits --model flag", () => {
225
+ const argv = runtime.buildPrintCommand("Hello", undefined);
226
+ expect(argv).not.toContain("--model");
227
+ });
228
+
229
+ test("prompt is the last element (positional argument)", () => {
230
+ const prompt = "My test prompt";
231
+ const argv = runtime.buildPrintCommand(prompt, "gpt-5-codex");
232
+ expect(argv[argv.length - 1]).toBe(prompt);
233
+ });
234
+
235
+ test("without model, argv has exactly 5 elements", () => {
236
+ const argv = runtime.buildPrintCommand("prompt text");
237
+ expect(argv.length).toBe(5);
238
+ });
239
+
240
+ test("with model, argv has exactly 7 elements", () => {
241
+ const argv = runtime.buildPrintCommand("prompt text", "gpt-5-codex");
242
+ expect(argv.length).toBe(7);
243
+ });
244
+
245
+ test("does not include --json (print expects plain text stdout)", () => {
246
+ const argv = runtime.buildPrintCommand("Summarize");
247
+ expect(argv).not.toContain("--json");
248
+ });
249
+
250
+ test("includes --ephemeral (no session persistence for one-shot calls)", () => {
251
+ const argv = runtime.buildPrintCommand("Summarize");
252
+ expect(argv).toContain("--ephemeral");
253
+ });
254
+ });
255
+
256
+ describe("detectReady", () => {
257
+ test("returns ready for empty pane (headless — always ready)", () => {
258
+ const state = runtime.detectReady("");
259
+ expect(state).toEqual({ phase: "ready" });
260
+ });
261
+
262
+ test("returns ready for any pane content", () => {
263
+ const state = runtime.detectReady("Loading Codex...\nPlease wait");
264
+ expect(state).toEqual({ phase: "ready" });
265
+ });
266
+
267
+ test("returns ready for NDJSON output", () => {
268
+ const state = runtime.detectReady(
269
+ '{"type":"thread.started","thread_id":"abc"}\n{"type":"turn.started"}',
270
+ );
271
+ expect(state).toEqual({ phase: "ready" });
272
+ });
273
+
274
+ test("no dialog phase — Codex has no trust dialog", () => {
275
+ const state = runtime.detectReady("trust this folder");
276
+ expect(state.phase).not.toBe("dialog");
277
+ expect(state.phase).toBe("ready");
278
+ });
279
+ });
280
+
281
+ describe("requiresBeaconVerification", () => {
282
+ test("returns false (headless — no beacon needed)", () => {
283
+ expect(runtime.requiresBeaconVerification()).toBe(false);
284
+ });
285
+ });
286
+
287
+ describe("buildEnv", () => {
288
+ test("returns empty object when model has no env", () => {
289
+ const model: ResolvedModel = { model: "gpt-5-codex" };
290
+ const env = runtime.buildEnv(model);
291
+ expect(env).toEqual({});
292
+ });
293
+
294
+ test("returns model.env when present", () => {
295
+ const model: ResolvedModel = {
296
+ model: "gpt-5-codex",
297
+ env: { OPENAI_API_KEY: "sk-test-123", OPENAI_BASE_URL: "https://api.example.com" },
298
+ };
299
+ const env = runtime.buildEnv(model);
300
+ expect(env).toEqual({
301
+ OPENAI_API_KEY: "sk-test-123",
302
+ OPENAI_BASE_URL: "https://api.example.com",
303
+ });
304
+ });
305
+
306
+ test("returns empty object when model.env is undefined", () => {
307
+ const model: ResolvedModel = { model: "gpt-5-codex", env: undefined };
308
+ const env = runtime.buildEnv(model);
309
+ expect(env).toEqual({});
310
+ });
311
+
312
+ test("result is safe to spread", () => {
313
+ const model: ResolvedModel = { model: "gpt-5-codex" };
314
+ const env = runtime.buildEnv(model);
315
+ const combined = { ...env, OVERSTORY_AGENT_NAME: "builder-1" };
316
+ expect(combined).toEqual({ OVERSTORY_AGENT_NAME: "builder-1" });
317
+ });
318
+ });
319
+
320
+ describe("deployConfig", () => {
321
+ let tempDir: string;
322
+
323
+ beforeEach(async () => {
324
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-codex-test-"));
325
+ });
326
+
327
+ afterEach(async () => {
328
+ await rm(tempDir, { recursive: true, force: true });
329
+ });
330
+
331
+ test("writes overlay to AGENTS.md (not .claude/CLAUDE.md)", async () => {
332
+ const worktreePath = join(tempDir, "worktree");
333
+
334
+ await runtime.deployConfig(
335
+ worktreePath,
336
+ { content: "# Agent Overlay\nThis is the task specification." },
337
+ { agentName: "test-builder", capability: "builder", worktreePath },
338
+ );
339
+
340
+ const agentsPath = join(worktreePath, "AGENTS.md");
341
+ const content = await Bun.file(agentsPath).text();
342
+ expect(content).toBe("# Agent Overlay\nThis is the task specification.");
343
+
344
+ // .claude/CLAUDE.md should NOT exist
345
+ const claudeMdPath = join(worktreePath, ".claude", "CLAUDE.md");
346
+ const claudeExists = await Bun.file(claudeMdPath).exists();
347
+ expect(claudeExists).toBe(false);
348
+ });
349
+
350
+ test("no hooks or guard extensions are deployed (OS sandbox)", async () => {
351
+ const worktreePath = join(tempDir, "worktree");
352
+
353
+ await runtime.deployConfig(
354
+ worktreePath,
355
+ { content: "# Overlay" },
356
+ { agentName: "test-builder", capability: "builder", worktreePath },
357
+ );
358
+
359
+ // No .claude/settings.local.json (Claude hooks)
360
+ const settingsPath = join(worktreePath, ".claude", "settings.local.json");
361
+ const settingsExists = await Bun.file(settingsPath).exists();
362
+ expect(settingsExists).toBe(false);
363
+
364
+ // No .pi/extensions/ (Pi guard extensions)
365
+ const piGuardPath = join(worktreePath, ".pi", "extensions", "overstory-guard.ts");
366
+ const piGuardExists = await Bun.file(piGuardPath).exists();
367
+ expect(piGuardExists).toBe(false);
368
+ });
369
+
370
+ test("no-op when overlay is undefined (Codex has no hooks to deploy)", async () => {
371
+ const worktreePath = join(tempDir, "worktree");
372
+
373
+ await runtime.deployConfig(worktreePath, undefined, {
374
+ agentName: "coordinator",
375
+ capability: "coordinator",
376
+ worktreePath,
377
+ });
378
+
379
+ // AGENTS.md should NOT exist
380
+ const agentsPath = join(worktreePath, "AGENTS.md");
381
+ const agentsExists = await Bun.file(agentsPath).exists();
382
+ expect(agentsExists).toBe(false);
383
+ });
384
+
385
+ test("overlay content is written verbatim", async () => {
386
+ const worktreePath = join(tempDir, "worktree");
387
+ const content = "# Task\n\n## Acceptance Criteria\n\n- [ ] Tests pass\n- [ ] Lint clean\n";
388
+
389
+ await runtime.deployConfig(
390
+ worktreePath,
391
+ { content },
392
+ { agentName: "builder-1", capability: "builder", worktreePath },
393
+ );
394
+
395
+ const agentsPath = join(worktreePath, "AGENTS.md");
396
+ const written = await Bun.file(agentsPath).text();
397
+ expect(written).toBe(content);
398
+ });
399
+
400
+ test("creates worktree directory if it does not exist", async () => {
401
+ const worktreePath = join(tempDir, "deep", "nested", "worktree");
402
+
403
+ await runtime.deployConfig(
404
+ worktreePath,
405
+ { content: "# Overlay" },
406
+ { agentName: "builder-1", capability: "builder", worktreePath },
407
+ );
408
+
409
+ const agentsPath = join(worktreePath, "AGENTS.md");
410
+ const exists = await Bun.file(agentsPath).exists();
411
+ expect(exists).toBe(true);
412
+ });
413
+
414
+ test("different capabilities produce the same output (no hook differentiation)", async () => {
415
+ const builderPath = join(tempDir, "builder-wt");
416
+ const scoutPath = join(tempDir, "scout-wt");
417
+
418
+ await runtime.deployConfig(
419
+ builderPath,
420
+ { content: "# Builder" },
421
+ { agentName: "test-builder", capability: "builder", worktreePath: builderPath },
422
+ );
423
+
424
+ await runtime.deployConfig(
425
+ scoutPath,
426
+ { content: "# Scout" },
427
+ { agentName: "test-scout", capability: "scout", worktreePath: scoutPath },
428
+ );
429
+
430
+ // Both should only have AGENTS.md with their respective content
431
+ const builderAgents = await Bun.file(join(builderPath, "AGENTS.md")).text();
432
+ const scoutAgents = await Bun.file(join(scoutPath, "AGENTS.md")).text();
433
+ expect(builderAgents).toBe("# Builder");
434
+ expect(scoutAgents).toBe("# Scout");
435
+ });
436
+ });
437
+
438
+ describe("parseTranscript", () => {
439
+ let tempDir: string;
440
+
441
+ beforeEach(async () => {
442
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-codex-transcript-test-"));
443
+ });
444
+
445
+ afterEach(async () => {
446
+ await rm(tempDir, { recursive: true, force: true });
447
+ });
448
+
449
+ test("returns null for non-existent file", async () => {
450
+ const result = await runtime.parseTranscript(join(tempDir, "does-not-exist.jsonl"));
451
+ expect(result).toBeNull();
452
+ });
453
+
454
+ test("parses turn.completed event with usage.input_tokens/output_tokens", async () => {
455
+ const transcriptPath = join(tempDir, "session.jsonl");
456
+ const event = JSON.stringify({
457
+ type: "turn.completed",
458
+ usage: { input_tokens: 24763, output_tokens: 122 },
459
+ });
460
+ await Bun.write(transcriptPath, `${event}\n`);
461
+
462
+ const result = await runtime.parseTranscript(transcriptPath);
463
+ expect(result).not.toBeNull();
464
+ expect(result?.inputTokens).toBe(24763);
465
+ expect(result?.outputTokens).toBe(122);
466
+ });
467
+
468
+ test("aggregates multiple turn.completed events", async () => {
469
+ const transcriptPath = join(tempDir, "session.jsonl");
470
+ const turn1 = JSON.stringify({
471
+ type: "turn.completed",
472
+ usage: { input_tokens: 1000, output_tokens: 200 },
473
+ });
474
+ const turn2 = JSON.stringify({
475
+ type: "turn.completed",
476
+ usage: { input_tokens: 2000, output_tokens: 300 },
477
+ });
478
+ await Bun.write(transcriptPath, `${turn1}\n${turn2}\n`);
479
+
480
+ const result = await runtime.parseTranscript(transcriptPath);
481
+ expect(result).not.toBeNull();
482
+ expect(result?.inputTokens).toBe(3000);
483
+ expect(result?.outputTokens).toBe(500);
484
+ });
485
+
486
+ test("captures model from event with model field", async () => {
487
+ const transcriptPath = join(tempDir, "session.jsonl");
488
+ const started = JSON.stringify({
489
+ type: "thread.started",
490
+ model: "gpt-5-codex",
491
+ thread_id: "abc",
492
+ });
493
+ const turn = JSON.stringify({
494
+ type: "turn.completed",
495
+ usage: { input_tokens: 100, output_tokens: 50 },
496
+ });
497
+ await Bun.write(transcriptPath, `${started}\n${turn}\n`);
498
+
499
+ const result = await runtime.parseTranscript(transcriptPath);
500
+ expect(result?.model).toBe("gpt-5-codex");
501
+ });
502
+
503
+ test("last event with model field wins", async () => {
504
+ const transcriptPath = join(tempDir, "session.jsonl");
505
+ const event1 = JSON.stringify({ type: "thread.started", model: "gpt-4o" });
506
+ const event2 = JSON.stringify({
507
+ type: "turn.completed",
508
+ model: "gpt-5-codex",
509
+ usage: { input_tokens: 10, output_tokens: 5 },
510
+ });
511
+ await Bun.write(transcriptPath, `${event1}\n${event2}\n`);
512
+
513
+ const result = await runtime.parseTranscript(transcriptPath);
514
+ expect(result?.model).toBe("gpt-5-codex");
515
+ });
516
+
517
+ test("defaults model to empty string when no model field in events", async () => {
518
+ const transcriptPath = join(tempDir, "session.jsonl");
519
+ const event = JSON.stringify({
520
+ type: "turn.completed",
521
+ usage: { input_tokens: 10, output_tokens: 5 },
522
+ });
523
+ await Bun.write(transcriptPath, `${event}\n`);
524
+
525
+ const result = await runtime.parseTranscript(transcriptPath);
526
+ expect(result?.model).toBe("");
527
+ });
528
+
529
+ test("handles turn.completed with cached_input_tokens", async () => {
530
+ const transcriptPath = join(tempDir, "session.jsonl");
531
+ const event = JSON.stringify({
532
+ type: "turn.completed",
533
+ usage: {
534
+ input_tokens: 24763,
535
+ cached_input_tokens: 24448,
536
+ output_tokens: 122,
537
+ },
538
+ });
539
+ await Bun.write(transcriptPath, `${event}\n`);
540
+
541
+ const result = await runtime.parseTranscript(transcriptPath);
542
+ // cached_input_tokens is metadata — we only count input_tokens
543
+ expect(result?.inputTokens).toBe(24763);
544
+ expect(result?.outputTokens).toBe(122);
545
+ });
546
+
547
+ test("skips non-turn.completed events for token counting", async () => {
548
+ const transcriptPath = join(tempDir, "session.jsonl");
549
+ const threadStarted = JSON.stringify({
550
+ type: "thread.started",
551
+ thread_id: "abc",
552
+ });
553
+ const itemCreated = JSON.stringify({
554
+ type: "item.created",
555
+ item: { type: "message", role: "assistant" },
556
+ });
557
+ const turnCompleted = JSON.stringify({
558
+ type: "turn.completed",
559
+ usage: { input_tokens: 100, output_tokens: 50 },
560
+ });
561
+ await Bun.write(transcriptPath, `${threadStarted}\n${itemCreated}\n${turnCompleted}\n`);
562
+
563
+ const result = await runtime.parseTranscript(transcriptPath);
564
+ expect(result?.inputTokens).toBe(100);
565
+ expect(result?.outputTokens).toBe(50);
566
+ });
567
+
568
+ test("returns zero counts for file with no turn.completed events", async () => {
569
+ const transcriptPath = join(tempDir, "session.jsonl");
570
+ const event = JSON.stringify({
571
+ type: "thread.started",
572
+ thread_id: "abc",
573
+ });
574
+ await Bun.write(transcriptPath, `${event}\n`);
575
+
576
+ const result = await runtime.parseTranscript(transcriptPath);
577
+ expect(result).not.toBeNull();
578
+ expect(result?.inputTokens).toBe(0);
579
+ expect(result?.outputTokens).toBe(0);
580
+ });
581
+
582
+ test("skips malformed lines and parses valid ones", async () => {
583
+ const transcriptPath = join(tempDir, "mixed.jsonl");
584
+ const bad = "not json at all";
585
+ const good = JSON.stringify({
586
+ type: "turn.completed",
587
+ usage: { input_tokens: 42, output_tokens: 7 },
588
+ });
589
+ await Bun.write(transcriptPath, `${bad}\n${good}\n`);
590
+
591
+ const result = await runtime.parseTranscript(transcriptPath);
592
+ expect(result?.inputTokens).toBe(42);
593
+ expect(result?.outputTokens).toBe(7);
594
+ });
595
+
596
+ test("handles empty file (returns zero counts)", async () => {
597
+ const transcriptPath = join(tempDir, "empty.jsonl");
598
+ await Bun.write(transcriptPath, "");
599
+
600
+ const result = await runtime.parseTranscript(transcriptPath);
601
+ expect(result).not.toBeNull();
602
+ expect(result?.inputTokens).toBe(0);
603
+ expect(result?.outputTokens).toBe(0);
604
+ });
605
+
606
+ test("handles turn.completed without usage field", async () => {
607
+ const transcriptPath = join(tempDir, "session.jsonl");
608
+ const event = JSON.stringify({ type: "turn.completed" });
609
+ await Bun.write(transcriptPath, `${event}\n`);
610
+
611
+ const result = await runtime.parseTranscript(transcriptPath);
612
+ expect(result).not.toBeNull();
613
+ expect(result?.inputTokens).toBe(0);
614
+ expect(result?.outputTokens).toBe(0);
615
+ });
616
+
617
+ test("does not count Claude-style assistant events", async () => {
618
+ const transcriptPath = join(tempDir, "session.jsonl");
619
+ // Claude-style entries should NOT be counted
620
+ const claudeStyleEntry = JSON.stringify({
621
+ type: "assistant",
622
+ message: { usage: { input_tokens: 999, output_tokens: 999 } },
623
+ });
624
+ const codexEntry = JSON.stringify({
625
+ type: "turn.completed",
626
+ usage: { input_tokens: 10, output_tokens: 5 },
627
+ });
628
+ await Bun.write(transcriptPath, `${claudeStyleEntry}\n${codexEntry}\n`);
629
+
630
+ const result = await runtime.parseTranscript(transcriptPath);
631
+ expect(result?.inputTokens).toBe(10);
632
+ expect(result?.outputTokens).toBe(5);
633
+ });
634
+
635
+ test("does not count Pi-style message_end events", async () => {
636
+ const transcriptPath = join(tempDir, "session.jsonl");
637
+ // Pi-style entries should NOT be counted
638
+ const piStyleEntry = JSON.stringify({
639
+ type: "message_end",
640
+ inputTokens: 999,
641
+ outputTokens: 999,
642
+ });
643
+ const codexEntry = JSON.stringify({
644
+ type: "turn.completed",
645
+ usage: { input_tokens: 10, output_tokens: 5 },
646
+ });
647
+ await Bun.write(transcriptPath, `${piStyleEntry}\n${codexEntry}\n`);
648
+
649
+ const result = await runtime.parseTranscript(transcriptPath);
650
+ expect(result?.inputTokens).toBe(10);
651
+ expect(result?.outputTokens).toBe(5);
652
+ });
653
+ });
654
+ });
655
+
656
+ describe("CodexRuntime integration: spawn command structure", () => {
657
+ const runtime = new CodexRuntime();
658
+
659
+ test("sling-style spawn: bypass mode, no system prompt", () => {
660
+ const cmd = runtime.buildSpawnCommand({
661
+ model: "gpt-5-codex",
662
+ permissionMode: "bypass",
663
+ cwd: "/project/.overstory/worktrees/builder-1",
664
+ env: { OVERSTORY_AGENT_NAME: "builder-1" },
665
+ });
666
+ expect(cmd).toBe(
667
+ "codex exec --full-auto --json --model gpt-5-codex 'Read AGENTS.md for your task assignment and begin immediately.'",
668
+ );
669
+ });
670
+
671
+ test("coordinator-style spawn: bypass mode with appendSystemPrompt", () => {
672
+ const baseDefinition = "# Coordinator\nYou are the coordinator agent.";
673
+ const cmd = runtime.buildSpawnCommand({
674
+ model: "gpt-5-codex",
675
+ permissionMode: "bypass",
676
+ cwd: "/project",
677
+ appendSystemPrompt: baseDefinition,
678
+ env: { OVERSTORY_AGENT_NAME: "coordinator" },
679
+ });
680
+ expect(cmd).toContain("codex exec --full-auto --json --model gpt-5-codex");
681
+ expect(cmd).toContain("# Coordinator");
682
+ expect(cmd).toContain("You are the coordinator agent.");
683
+ expect(cmd).toContain("Read AGENTS.md");
684
+ });
685
+
686
+ test("coordinator-style spawn: with appendSystemPromptFile", () => {
687
+ const cmd = runtime.buildSpawnCommand({
688
+ model: "gpt-5-codex",
689
+ permissionMode: "bypass",
690
+ cwd: "/project",
691
+ appendSystemPromptFile: "/project/.overstory/agent-defs/coordinator.md",
692
+ env: { OVERSTORY_AGENT_NAME: "coordinator" },
693
+ });
694
+ expect(cmd).toContain("codex exec --full-auto --json --model gpt-5-codex");
695
+ expect(cmd).toContain("$(cat '/project/.overstory/agent-defs/coordinator.md')");
696
+ expect(cmd).toContain("Read AGENTS.md");
697
+ });
698
+ });
699
+
700
+ describe("CodexRuntime integration: buildEnv matches provider pattern", () => {
701
+ const runtime = new CodexRuntime();
702
+
703
+ test("OpenAI model: passes env through", () => {
704
+ const model: ResolvedModel = {
705
+ model: "gpt-5-codex",
706
+ env: { OPENAI_API_KEY: "sk-test-123" },
707
+ };
708
+ const env = runtime.buildEnv(model);
709
+ expect(env).toEqual({ OPENAI_API_KEY: "sk-test-123" });
710
+ });
711
+
712
+ test("custom provider: passes env through", () => {
713
+ const model: ResolvedModel = {
714
+ model: "custom-model",
715
+ env: { OPENAI_API_KEY: "sk-test", OPENAI_BASE_URL: "https://custom.api/v1" },
716
+ };
717
+ const env = runtime.buildEnv(model);
718
+ expect(env).toEqual({
719
+ OPENAI_API_KEY: "sk-test",
720
+ OPENAI_BASE_URL: "https://custom.api/v1",
721
+ });
722
+ });
723
+
724
+ test("model without env: returns empty object (safe to spread)", () => {
725
+ const model: ResolvedModel = { model: "gpt-5-codex" };
726
+ const env = runtime.buildEnv(model);
727
+ expect(env).toEqual({});
728
+ const combined = { ...env, OVERSTORY_AGENT_NAME: "builder-1" };
729
+ expect(combined).toEqual({ OVERSTORY_AGENT_NAME: "builder-1" });
730
+ });
731
+ });
732
+
733
+ describe("CodexRuntime integration: registry resolves 'codex'", () => {
734
+ test("getRuntime('codex') returns CodexRuntime", async () => {
735
+ const { getRuntime } = await import("./registry.ts");
736
+ const rt = getRuntime("codex");
737
+ expect(rt).toBeInstanceOf(CodexRuntime);
738
+ expect(rt.id).toBe("codex");
739
+ expect(rt.instructionPath).toBe("AGENTS.md");
740
+ });
741
+ });