@elvatis_com/openclaw-cli-bridge-elvatis 1.9.0 → 2.0.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.
@@ -12,10 +12,54 @@
12
12
  * routeToCliRunner is mocked so we don't need real CLIs installed.
13
13
  */
14
14
 
15
- import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
15
+ import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest";
16
16
  import http from "node:http";
17
17
  import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
18
18
 
19
+ // Mock session-manager so we don't spawn real CLIs for session endpoints
20
+ const mockSessions = new Map<string, { model: string; status: string; stdout: string; stderr: string; exitCode: number | null; startTime: number }>();
21
+ let nextSessionId = "aabbccdd11223344";
22
+
23
+ vi.mock("../src/session-manager.js", () => ({
24
+ sessionManager: {
25
+ spawn: vi.fn((model: string, _messages: unknown[]) => {
26
+ const id = nextSessionId;
27
+ mockSessions.set(id, { model, status: "running", stdout: "", stderr: "", exitCode: null, startTime: Date.now() });
28
+ // Generate a different ID next time
29
+ nextSessionId = Math.random().toString(16).slice(2, 18).padEnd(16, "0");
30
+ return id;
31
+ }),
32
+ poll: vi.fn((sessionId: string) => {
33
+ const entry = mockSessions.get(sessionId);
34
+ if (!entry) return null;
35
+ return { running: entry.status === "running", exitCode: entry.exitCode, status: entry.status };
36
+ }),
37
+ log: vi.fn((sessionId: string, offset = 0) => {
38
+ const entry = mockSessions.get(sessionId);
39
+ if (!entry) return null;
40
+ return { stdout: entry.stdout.slice(offset), stderr: entry.stderr.slice(offset), offset: entry.stdout.length };
41
+ }),
42
+ write: vi.fn((sessionId: string) => {
43
+ return mockSessions.has(sessionId);
44
+ }),
45
+ kill: vi.fn((sessionId: string) => {
46
+ const entry = mockSessions.get(sessionId);
47
+ if (!entry || entry.status !== "running") return false;
48
+ entry.status = "killed";
49
+ return true;
50
+ }),
51
+ list: vi.fn(() => {
52
+ const result: { sessionId: string; model: string; status: string; startTime: number; exitCode: number | null }[] = [];
53
+ for (const [sessionId, entry] of mockSessions) {
54
+ result.push({ sessionId, model: entry.model, status: entry.status, startTime: entry.startTime, exitCode: entry.exitCode });
55
+ }
56
+ return result;
57
+ }),
58
+ stop: vi.fn(),
59
+ cleanup: vi.fn(),
60
+ },
61
+ }));
62
+
19
63
  // Mock cli-runner so we don't spawn real CLIs
20
64
  vi.mock("../src/cli-runner.js", async (importOriginal) => {
21
65
  const orig = await importOriginal<typeof import("../src/cli-runner.js")>();
@@ -24,7 +68,7 @@ vi.mock("../src/cli-runner.js", async (importOriginal) => {
24
68
  routeToCliRunner: vi.fn(async (model: string, _messages: unknown[], _timeout: number) => {
25
69
  // Simulate the real router: strip vllm/ prefix, validate model
26
70
  const normalized = model.startsWith("vllm/") ? model.slice(5) : model;
27
- if (!normalized.startsWith("cli-gemini/") && !normalized.startsWith("cli-claude/")) {
71
+ if (!normalized.startsWith("cli-gemini/") && !normalized.startsWith("cli-claude/") && !normalized.startsWith("openai-codex/") && !normalized.startsWith("opencode/") && !normalized.startsWith("pi/")) {
28
72
  throw new Error(`Unknown CLI bridge model: "${model}"`);
29
73
  }
30
74
  return `Mock response from ${normalized}`;
@@ -474,4 +518,232 @@ describe("Model capabilities", () => {
474
518
  expect(m.capabilities.tools).toBe(true);
475
519
  }
476
520
  });
521
+
522
+ it("openai-codex models have capabilities.tools===false", async () => {
523
+ const res = await fetch("/v1/models");
524
+ const body = JSON.parse(res.body);
525
+ const codexModels = body.data.filter((m: { id: string }) => m.id.startsWith("openai-codex/"));
526
+ expect(codexModels.length).toBeGreaterThan(0);
527
+ for (const m of codexModels) {
528
+ expect(m.capabilities.tools).toBe(false);
529
+ }
530
+ });
531
+
532
+ it("opencode models have capabilities.tools===false", async () => {
533
+ const res = await fetch("/v1/models");
534
+ const body = JSON.parse(res.body);
535
+ const ocModels = body.data.filter((m: { id: string }) => m.id.startsWith("opencode/"));
536
+ expect(ocModels.length).toBeGreaterThan(0);
537
+ for (const m of ocModels) {
538
+ expect(m.capabilities.tools).toBe(false);
539
+ }
540
+ });
541
+
542
+ it("pi models have capabilities.tools===false", async () => {
543
+ const res = await fetch("/v1/models");
544
+ const body = JSON.parse(res.body);
545
+ const piModels = body.data.filter((m: { id: string }) => m.id.startsWith("pi/"));
546
+ expect(piModels.length).toBeGreaterThan(0);
547
+ for (const m of piModels) {
548
+ expect(m.capabilities.tools).toBe(false);
549
+ }
550
+ });
551
+ });
552
+
553
+ // ──────────────────────────────────────────────────────────────────────────────
554
+ // Chat completions — new model prefixes (codex, opencode, pi)
555
+ // ──────────────────────────────────────────────────────────────────────────────
556
+
557
+ describe("POST /v1/chat/completions — new model prefixes", () => {
558
+ it("returns completion for openai-codex model", async () => {
559
+ const res = await json("/v1/chat/completions", {
560
+ model: "openai-codex/gpt-5.3-codex",
561
+ messages: [{ role: "user", content: "hello" }],
562
+ });
563
+ expect(res.status).toBe(200);
564
+ const body = JSON.parse(res.body);
565
+ expect(body.choices[0].message.content).toBe("Mock response from openai-codex/gpt-5.3-codex");
566
+ });
567
+
568
+ it("returns completion for opencode model", async () => {
569
+ const res = await json("/v1/chat/completions", {
570
+ model: "opencode/default",
571
+ messages: [{ role: "user", content: "hello" }],
572
+ });
573
+ expect(res.status).toBe(200);
574
+ const body = JSON.parse(res.body);
575
+ expect(body.choices[0].message.content).toBe("Mock response from opencode/default");
576
+ });
577
+
578
+ it("returns completion for pi model", async () => {
579
+ const res = await json("/v1/chat/completions", {
580
+ model: "pi/default",
581
+ messages: [{ role: "user", content: "hello" }],
582
+ });
583
+ expect(res.status).toBe(200);
584
+ const body = JSON.parse(res.body);
585
+ expect(body.choices[0].message.content).toBe("Mock response from pi/default");
586
+ });
587
+
588
+ it("rejects tools for openai-codex models", async () => {
589
+ const res = await json("/v1/chat/completions", {
590
+ model: "openai-codex/gpt-5.3-codex",
591
+ messages: [{ role: "user", content: "hi" }],
592
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
593
+ });
594
+ expect(res.status).toBe(400);
595
+ expect(JSON.parse(res.body).error.code).toBe("tools_not_supported");
596
+ });
597
+
598
+ it("rejects tools for opencode models", async () => {
599
+ const res = await json("/v1/chat/completions", {
600
+ model: "opencode/default",
601
+ messages: [{ role: "user", content: "hi" }],
602
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
603
+ });
604
+ expect(res.status).toBe(400);
605
+ expect(JSON.parse(res.body).error.code).toBe("tools_not_supported");
606
+ });
607
+
608
+ it("rejects tools for pi models", async () => {
609
+ const res = await json("/v1/chat/completions", {
610
+ model: "pi/default",
611
+ messages: [{ role: "user", content: "hi" }],
612
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
613
+ });
614
+ expect(res.status).toBe(400);
615
+ expect(JSON.parse(res.body).error.code).toBe("tools_not_supported");
616
+ });
617
+ });
618
+
619
+ // ──────────────────────────────────────────────────────────────────────────────
620
+ // Session Manager endpoints
621
+ // ──────────────────────────────────────────────────────────────────────────────
622
+
623
+ describe("Session Manager endpoints", () => {
624
+ beforeEach(() => {
625
+ mockSessions.clear();
626
+ nextSessionId = "aabbccdd11223344";
627
+ });
628
+
629
+ it("POST /v1/sessions/spawn returns sessionId", async () => {
630
+ const res = await json("/v1/sessions/spawn", {
631
+ model: "cli-gemini/gemini-2.5-pro",
632
+ messages: [{ role: "user", content: "hello" }],
633
+ });
634
+ expect(res.status).toBe(200);
635
+ const body = JSON.parse(res.body);
636
+ expect(body.sessionId).toBe("aabbccdd11223344");
637
+ });
638
+
639
+ it("POST /v1/sessions/spawn rejects missing model", async () => {
640
+ const res = await json("/v1/sessions/spawn", {
641
+ messages: [{ role: "user", content: "hello" }],
642
+ });
643
+ expect(res.status).toBe(400);
644
+ expect(JSON.parse(res.body).error.message).toContain("model and messages are required");
645
+ });
646
+
647
+ it("POST /v1/sessions/spawn rejects missing messages", async () => {
648
+ const res = await json("/v1/sessions/spawn", {
649
+ model: "cli-gemini/gemini-2.5-pro",
650
+ messages: [],
651
+ });
652
+ expect(res.status).toBe(400);
653
+ });
654
+
655
+ it("GET /v1/sessions lists sessions", async () => {
656
+ // Spawn one session first
657
+ await json("/v1/sessions/spawn", {
658
+ model: "cli-gemini/gemini-2.5-pro",
659
+ messages: [{ role: "user", content: "hello" }],
660
+ });
661
+
662
+ const res = await fetch("/v1/sessions");
663
+ expect(res.status).toBe(200);
664
+ const body = JSON.parse(res.body);
665
+ expect(body.sessions).toHaveLength(1);
666
+ expect(body.sessions[0].model).toBe("cli-gemini/gemini-2.5-pro");
667
+ expect(body.sessions[0].status).toBe("running");
668
+ });
669
+
670
+ it("GET /v1/sessions/:id/poll returns status", async () => {
671
+ const spawnRes = await json("/v1/sessions/spawn", {
672
+ model: "cli-gemini/gemini-2.5-pro",
673
+ messages: [{ role: "user", content: "hello" }],
674
+ });
675
+ const { sessionId } = JSON.parse(spawnRes.body);
676
+
677
+ const res = await fetch(`/v1/sessions/${sessionId}/poll`);
678
+ expect(res.status).toBe(200);
679
+ const body = JSON.parse(res.body);
680
+ expect(body.running).toBe(true);
681
+ expect(body.status).toBe("running");
682
+ });
683
+
684
+ it("GET /v1/sessions/:id/poll returns 404 for unknown session", async () => {
685
+ const res = await fetch("/v1/sessions/0000000000000000/poll");
686
+ expect(res.status).toBe(404);
687
+ });
688
+
689
+ it("GET /v1/sessions/:id/log returns output", async () => {
690
+ const spawnRes = await json("/v1/sessions/spawn", {
691
+ model: "cli-gemini/gemini-2.5-pro",
692
+ messages: [{ role: "user", content: "hello" }],
693
+ });
694
+ const { sessionId } = JSON.parse(spawnRes.body);
695
+
696
+ const res = await fetch(`/v1/sessions/${sessionId}/log`);
697
+ expect(res.status).toBe(200);
698
+ const body = JSON.parse(res.body);
699
+ expect(typeof body.stdout).toBe("string");
700
+ expect(typeof body.stderr).toBe("string");
701
+ expect(typeof body.offset).toBe("number");
702
+ });
703
+
704
+ it("GET /v1/sessions/:id/log returns 404 for unknown session", async () => {
705
+ const res = await fetch("/v1/sessions/0000000000000000/log");
706
+ expect(res.status).toBe(404);
707
+ });
708
+
709
+ it("POST /v1/sessions/:id/write sends data", async () => {
710
+ const spawnRes = await json("/v1/sessions/spawn", {
711
+ model: "cli-gemini/gemini-2.5-pro",
712
+ messages: [{ role: "user", content: "hello" }],
713
+ });
714
+ const { sessionId } = JSON.parse(spawnRes.body);
715
+
716
+ const res = await json(`/v1/sessions/${sessionId}/write`, { data: "input" });
717
+ expect(res.status).toBe(200);
718
+ const body = JSON.parse(res.body);
719
+ expect(body.ok).toBe(true);
720
+ });
721
+
722
+ it("POST /v1/sessions/:id/kill terminates session", async () => {
723
+ const spawnRes = await json("/v1/sessions/spawn", {
724
+ model: "cli-gemini/gemini-2.5-pro",
725
+ messages: [{ role: "user", content: "hello" }],
726
+ });
727
+ const { sessionId } = JSON.parse(spawnRes.body);
728
+
729
+ const res = await json(`/v1/sessions/${sessionId}/kill`, {});
730
+ expect(res.status).toBe(200);
731
+ const body = JSON.parse(res.body);
732
+ expect(body.ok).toBe(true);
733
+ });
734
+
735
+ it("POST /v1/sessions/:id/kill returns false for already-killed session", async () => {
736
+ const spawnRes = await json("/v1/sessions/spawn", {
737
+ model: "cli-gemini/gemini-2.5-pro",
738
+ messages: [{ role: "user", content: "hello" }],
739
+ });
740
+ const { sessionId } = JSON.parse(spawnRes.body);
741
+
742
+ // Kill once
743
+ await json(`/v1/sessions/${sessionId}/kill`, {});
744
+ // Kill again
745
+ const res = await json(`/v1/sessions/${sessionId}/kill`, {});
746
+ expect(res.status).toBe(404);
747
+ expect(JSON.parse(res.body).ok).toBe(false);
748
+ });
477
749
  });
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Unit tests for SessionManager.
3
+ *
4
+ * Mocks child_process.spawn so no real CLIs are executed.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
8
+ import { EventEmitter } from "node:events";
9
+ import type { ChildProcess } from "node:child_process";
10
+
11
+ // ── Mock child_process.spawn ────────────────────────────────────────────────
12
+
13
+ /** Minimal fake ChildProcess for testing. */
14
+ function makeFakeProc(): ChildProcess & {
15
+ _stdout: EventEmitter;
16
+ _stderr: EventEmitter;
17
+ _emit: (event: string, ...args: unknown[]) => void;
18
+ } {
19
+ const proc = new EventEmitter() as any;
20
+ proc._stdout = new EventEmitter();
21
+ proc._stderr = new EventEmitter();
22
+ proc.stdout = proc._stdout;
23
+ proc.stderr = proc._stderr;
24
+ proc.stdin = {
25
+ write: vi.fn((_data: string, _enc: string, cb?: () => void) => { cb?.(); }),
26
+ end: vi.fn(),
27
+ };
28
+ proc.kill = vi.fn(() => true);
29
+ proc.pid = 12345;
30
+ proc._emit = (event: string, ...args: unknown[]) => proc.emit(event, ...args);
31
+ return proc;
32
+ }
33
+
34
+ // vi.hoisted variables are available inside vi.mock factories
35
+ const { mockSpawn, mockExecSync, latestProcRef } = vi.hoisted(() => ({
36
+ mockSpawn: vi.fn(),
37
+ mockExecSync: vi.fn(),
38
+ latestProcRef: { current: null as any },
39
+ }));
40
+
41
+ vi.mock("node:child_process", async (importOriginal) => {
42
+ const orig = await importOriginal<typeof import("node:child_process")>();
43
+ return { ...orig, spawn: mockSpawn, execSync: mockExecSync };
44
+ });
45
+
46
+ // Mock claude-auth to prevent real token operations
47
+ vi.mock("../src/claude-auth.js", () => ({
48
+ ensureClaudeToken: vi.fn(async () => {}),
49
+ refreshClaudeToken: vi.fn(async () => {}),
50
+ scheduleTokenRefresh: vi.fn(async () => {}),
51
+ stopTokenRefresh: vi.fn(),
52
+ setAuthLogger: vi.fn(),
53
+ }));
54
+
55
+ // Now import SessionManager (uses the mocked spawn)
56
+ import { SessionManager } from "../src/session-manager.js";
57
+
58
+ // ──────────────────────────────────────────────────────────────────────────────
59
+
60
+ describe("SessionManager", () => {
61
+ let mgr: SessionManager;
62
+
63
+ beforeEach(() => {
64
+ mgr = new SessionManager();
65
+ mockSpawn.mockImplementation(() => {
66
+ const proc = makeFakeProc();
67
+ latestProcRef.current = proc;
68
+ return proc;
69
+ });
70
+ });
71
+
72
+ afterEach(() => {
73
+ mgr.stop();
74
+ });
75
+
76
+ // ── spawn() ──────────────────────────────────────────────────────────────
77
+
78
+ describe("spawn()", () => {
79
+ it("returns a hex sessionId", () => {
80
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
81
+ expect(id).toMatch(/^[a-f0-9]{16}$/);
82
+ });
83
+
84
+ it("returns unique sessionIds on repeated calls", () => {
85
+ const id1 = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "a" }]);
86
+ const id2 = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "b" }]);
87
+ expect(id1).not.toBe(id2);
88
+ });
89
+ });
90
+
91
+ // ── poll() ─────────────────────────────────────────────────────────────
92
+
93
+ describe("poll()", () => {
94
+ it("returns running status for an active session", () => {
95
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
96
+ const result = mgr.poll(id);
97
+ expect(result).not.toBeNull();
98
+ expect(result!.running).toBe(true);
99
+ expect(result!.status).toBe("running");
100
+ expect(result!.exitCode).toBeNull();
101
+ });
102
+
103
+ it("returns exited status after process closes", () => {
104
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
105
+ // Simulate process exit
106
+ latestProcRef.current._emit("close", 0);
107
+ const result = mgr.poll(id);
108
+ expect(result!.running).toBe(false);
109
+ expect(result!.status).toBe("exited");
110
+ expect(result!.exitCode).toBe(0);
111
+ });
112
+
113
+ it("returns null for unknown sessionId", () => {
114
+ expect(mgr.poll("0000000000000000")).toBeNull();
115
+ });
116
+ });
117
+
118
+ // ── log() ──────────────────────────────────────────────────────────────
119
+
120
+ describe("log()", () => {
121
+ it("returns buffered stdout output", () => {
122
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
123
+ latestProcRef.current._stdout.emit("data", Buffer.from("Hello "));
124
+ latestProcRef.current._stdout.emit("data", Buffer.from("World"));
125
+ const result = mgr.log(id);
126
+ expect(result).not.toBeNull();
127
+ expect(result!.stdout).toBe("Hello World");
128
+ expect(result!.offset).toBe(11);
129
+ });
130
+
131
+ it("returns buffered stderr output", () => {
132
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
133
+ latestProcRef.current._stderr.emit("data", Buffer.from("warning"));
134
+ const result = mgr.log(id);
135
+ expect(result!.stderr).toBe("warning");
136
+ });
137
+
138
+ it("supports offset to get incremental output", () => {
139
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
140
+ latestProcRef.current._stdout.emit("data", Buffer.from("ABCDE"));
141
+ const result = mgr.log(id, 3);
142
+ expect(result!.stdout).toBe("DE");
143
+ expect(result!.offset).toBe(5);
144
+ });
145
+
146
+ it("returns null for unknown sessionId", () => {
147
+ expect(mgr.log("0000000000000000")).toBeNull();
148
+ });
149
+ });
150
+
151
+ // ── write() ────────────────────────────────────────────────────────────
152
+
153
+ describe("write()", () => {
154
+ it("writes data to stdin of a running session", () => {
155
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
156
+ const proc = latestProcRef.current;
157
+ const ok = mgr.write(id, "input data");
158
+ expect(ok).toBe(true);
159
+ expect(proc.stdin.write).toHaveBeenCalledWith("input data", "utf8");
160
+ });
161
+
162
+ it("returns false for unknown sessionId", () => {
163
+ expect(mgr.write("0000000000000000", "data")).toBe(false);
164
+ });
165
+
166
+ it("returns false for an exited session", () => {
167
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
168
+ latestProcRef.current._emit("close", 0);
169
+ expect(mgr.write(id, "data")).toBe(false);
170
+ });
171
+ });
172
+
173
+ // ── kill() ─────────────────────────────────────────────────────────────
174
+
175
+ describe("kill()", () => {
176
+ it("sends SIGTERM to a running session", () => {
177
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
178
+ const proc = latestProcRef.current;
179
+ const ok = mgr.kill(id);
180
+ expect(ok).toBe(true);
181
+ expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
182
+ // Status should be "killed"
183
+ const poll = mgr.poll(id);
184
+ expect(poll!.status).toBe("killed");
185
+ });
186
+
187
+ it("returns false for unknown sessionId", () => {
188
+ expect(mgr.kill("0000000000000000")).toBe(false);
189
+ });
190
+
191
+ it("returns false for an already-exited session", () => {
192
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
193
+ latestProcRef.current._emit("close", 0);
194
+ expect(mgr.kill(id)).toBe(false);
195
+ });
196
+ });
197
+
198
+ // ── list() ─────────────────────────────────────────────────────────────
199
+
200
+ describe("list()", () => {
201
+ it("returns all sessions", () => {
202
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "a" }]);
203
+ mgr.spawn("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "b" }]);
204
+ const list = mgr.list();
205
+ expect(list).toHaveLength(2);
206
+ expect(list[0].sessionId).toMatch(/^[a-f0-9]{16}$/);
207
+ expect(list[0].model).toBe("cli-gemini/gemini-2.5-pro");
208
+ expect(list[0].status).toBe("running");
209
+ expect(list[1].model).toBe("cli-claude/claude-sonnet-4-6");
210
+ });
211
+
212
+ it("returns empty array when no sessions exist", () => {
213
+ expect(mgr.list()).toHaveLength(0);
214
+ });
215
+ });
216
+
217
+ // ── cleanup() ──────────────────────────────────────────────────────────
218
+
219
+ describe("cleanup()", () => {
220
+ it("removes sessions older than TTL", () => {
221
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
222
+
223
+ // Manually set startTime to far in the past
224
+ const sessions = (mgr as any).sessions as Map<string, any>;
225
+ const entry = sessions.get(id)!;
226
+ entry.startTime = Date.now() - 31 * 60 * 1000; // 31 minutes ago
227
+
228
+ mgr.cleanup();
229
+
230
+ expect(mgr.poll(id)).toBeNull();
231
+ expect(mgr.list()).toHaveLength(0);
232
+ });
233
+
234
+ it("kills running sessions before removing them", () => {
235
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
236
+ const proc = latestProcRef.current;
237
+
238
+ const sessions = (mgr as any).sessions as Map<string, any>;
239
+ sessions.get(id)!.startTime = Date.now() - 31 * 60 * 1000;
240
+
241
+ mgr.cleanup();
242
+
243
+ expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
244
+ });
245
+
246
+ it("does not remove recent sessions", () => {
247
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
248
+ mgr.cleanup();
249
+ expect(mgr.list()).toHaveLength(1);
250
+ });
251
+ });
252
+
253
+ // ── resolveCliCommand (via spawn behavior) ─────────────────────────────
254
+
255
+ describe("resolveCliCommand routing", () => {
256
+ it("routes cli-gemini/ to 'gemini' command", () => {
257
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "test" }]);
258
+ expect(mockSpawn).toHaveBeenCalledWith(
259
+ "gemini",
260
+ expect.arrayContaining(["-m", "gemini-2.5-pro"]),
261
+ expect.any(Object)
262
+ );
263
+ });
264
+
265
+ it("routes cli-claude/ to 'claude' command", () => {
266
+ mgr.spawn("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "test" }]);
267
+ expect(mockSpawn).toHaveBeenCalledWith(
268
+ "claude",
269
+ expect.arrayContaining(["--model", "claude-sonnet-4-6"]),
270
+ expect.any(Object)
271
+ );
272
+ });
273
+
274
+ it("routes openai-codex/ to 'codex' command", () => {
275
+ mgr.spawn("openai-codex/gpt-5.3-codex", [{ role: "user", content: "test" }]);
276
+ expect(mockSpawn).toHaveBeenCalledWith(
277
+ "codex",
278
+ expect.arrayContaining(["--model", "gpt-5.3-codex"]),
279
+ expect.any(Object)
280
+ );
281
+ });
282
+
283
+ it("routes opencode/ to 'opencode' command", () => {
284
+ mgr.spawn("opencode/default", [{ role: "user", content: "test" }]);
285
+ expect(mockSpawn).toHaveBeenCalledWith(
286
+ "opencode",
287
+ expect.arrayContaining(["run"]),
288
+ expect.any(Object)
289
+ );
290
+ });
291
+
292
+ it("routes pi/ to 'pi' command", () => {
293
+ mgr.spawn("pi/default", [{ role: "user", content: "test" }]);
294
+ expect(mockSpawn).toHaveBeenCalledWith(
295
+ "pi",
296
+ expect.arrayContaining(["-p"]),
297
+ expect.any(Object)
298
+ );
299
+ });
300
+
301
+ it("passes workdir option as cwd", () => {
302
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "test" }], { workdir: "/tmp/test" });
303
+ expect(mockSpawn).toHaveBeenCalledWith(
304
+ "gemini",
305
+ expect.any(Array),
306
+ expect.objectContaining({ cwd: "/tmp/test" })
307
+ );
308
+ });
309
+ });
310
+
311
+ // ── process error handling ─────────────────────────────────────────────
312
+
313
+ describe("process error handling", () => {
314
+ it("marks session as exited on process error", () => {
315
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
316
+ latestProcRef.current._emit("error", new Error("spawn ENOENT"));
317
+ const result = mgr.poll(id);
318
+ expect(result!.running).toBe(false);
319
+ expect(result!.status).toBe("exited");
320
+ expect(result!.exitCode).toBe(1);
321
+ });
322
+ });
323
+
324
+ // ── stop() ─────────────────────────────────────────────────────────────
325
+
326
+ describe("stop()", () => {
327
+ it("kills all running sessions", () => {
328
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "a" }]);
329
+ const proc1 = latestProcRef.current;
330
+ mgr.spawn("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "b" }]);
331
+ const proc2 = latestProcRef.current;
332
+
333
+ mgr.stop();
334
+
335
+ expect(proc1.kill).toHaveBeenCalledWith("SIGTERM");
336
+ expect(proc2.kill).toHaveBeenCalledWith("SIGTERM");
337
+ });
338
+ });
339
+ });