@elvatis_com/openclaw-cli-bridge-elvatis 2.4.0 → 2.6.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.
@@ -16,6 +16,11 @@ import { join } from "node:path";
16
16
  import { execSync } from "node:child_process";
17
17
  import { formatPrompt, type ChatMessage } from "./cli-runner.js";
18
18
  import { createIsolatedWorkdir, cleanupWorkdir, sweepOrphanedWorkdirs } from "./workdir.js";
19
+ import {
20
+ SESSION_TTL_MS,
21
+ CLEANUP_INTERVAL_MS,
22
+ SESSION_KILL_GRACE_MS,
23
+ } from "./config.js";
19
24
 
20
25
  // ──────────────────────────────────────────────────────────────────────────────
21
26
  // Types
@@ -92,9 +97,7 @@ function buildMinimalEnv(): Record<string, string> {
92
97
  // Session Manager
93
98
  // ──────────────────────────────────────────────────────────────────────────────
94
99
 
95
- /** Auto-cleanup interval: 30 minutes. */
96
- const SESSION_TTL_MS = 30 * 60 * 1000;
97
- const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
100
+ // SESSION_TTL_MS, CLEANUP_INTERVAL_MS, SESSION_KILL_GRACE_MS imported from config.ts
98
101
 
99
102
  export class SessionManager {
100
103
  private sessions = new Map<string, SessionEntry>();
@@ -213,12 +216,19 @@ export class SessionManager {
213
216
  }
214
217
  }
215
218
 
216
- /** Send SIGTERM to the session process. */
219
+ /**
220
+ * Gracefully terminate a session: SIGTERM first, then SIGKILL after grace period.
221
+ * This prevents the ambiguous "exit 143 (no output)" pattern.
222
+ */
217
223
  kill(sessionId: string): boolean {
218
224
  const entry = this.sessions.get(sessionId);
219
225
  if (!entry || entry.status !== "running") return false;
220
226
  entry.status = "killed";
221
227
  entry.proc.kill("SIGTERM");
228
+ // If the process doesn't exit within the grace period, force-kill it
229
+ setTimeout(() => {
230
+ try { if (!entry.proc.killed) entry.proc.kill("SIGKILL"); } catch { /* already dead */ }
231
+ }, SESSION_KILL_GRACE_MS);
222
232
  return true;
223
233
  }
224
234
 
@@ -238,7 +248,7 @@ export class SessionManager {
238
248
  return result;
239
249
  }
240
250
 
241
- /** Remove sessions older than SESSION_TTL_MS. Kill running ones first. Clean up isolated workdirs. */
251
+ /** Remove sessions older than SESSION_TTL_MS. Kill running ones with graceful SIGTERM→SIGKILL. */
242
252
  cleanup(): void {
243
253
  const now = Date.now();
244
254
  for (const [sessionId, entry] of this.sessions) {
@@ -246,6 +256,10 @@ export class SessionManager {
246
256
  if (entry.status === "running") {
247
257
  entry.proc.kill("SIGTERM");
248
258
  entry.status = "killed";
259
+ // Escalate to SIGKILL after grace period
260
+ setTimeout(() => {
261
+ try { if (!entry.proc.killed) entry.proc.kill("SIGKILL"); } catch { /* already dead */ }
262
+ }, SESSION_KILL_GRACE_MS);
249
263
  }
250
264
  // Clean up isolated workdir if it wasn't cleaned on exit
251
265
  if (entry.isolatedWorkdir) {
@@ -258,17 +272,20 @@ export class SessionManager {
258
272
  sweepOrphanedWorkdirs();
259
273
  }
260
274
 
261
- /** Stop the cleanup timer (for graceful shutdown). */
275
+ /** Stop the cleanup timer (for graceful shutdown). SIGTERM all sessions, SIGKILL after grace. */
262
276
  stop(): void {
263
277
  if (this.cleanupTimer) {
264
278
  clearInterval(this.cleanupTimer);
265
279
  this.cleanupTimer = null;
266
280
  }
267
- // Kill all running sessions and clean up their workdirs
281
+ // Kill all running sessions with graceful SIGTERM SIGKILL escalation
268
282
  for (const [, entry] of this.sessions) {
269
283
  if (entry.status === "running") {
270
284
  entry.proc.kill("SIGTERM");
271
285
  entry.status = "killed";
286
+ setTimeout(() => {
287
+ try { if (!entry.proc.killed) entry.proc.kill("SIGKILL"); } catch { /* already dead */ }
288
+ }, SESSION_KILL_GRACE_MS);
272
289
  }
273
290
  if (entry.isolatedWorkdir) {
274
291
  cleanupWorkdir(entry.isolatedWorkdir);
@@ -265,3 +265,75 @@ describe("Codex auto-git-init via routeToCliRunner", () => {
265
265
  expect(mockExecSync).toHaveBeenCalledWith("git init", expect.objectContaining({ cwd: "/no-git-dir" }));
266
266
  });
267
267
  });
268
+
269
+ // ──────────────────────────────────────────────────────────────────────────────
270
+ // Timeout handling: graceful SIGTERM → SIGKILL and exit 143 annotation
271
+ // ──────────────────────────────────────────────────────────────────────────────
272
+
273
+ import { runCli, annotateExitError } from "../src/cli-runner.js";
274
+
275
+ describe("runCli() timeout handling", () => {
276
+ it("does NOT pass timeout to spawn options (manual timer instead)", async () => {
277
+ mockSpawn.mockImplementation(() => makeFakeProc("ok", 0));
278
+ await runCli("echo", [], "hello", 60_000);
279
+ const spawnOpts = mockSpawn.mock.calls[0][2];
280
+ expect(spawnOpts.timeout).toBeUndefined();
281
+ });
282
+
283
+ it("sends SIGTERM after timeout fires", async () => {
284
+ vi.useFakeTimers();
285
+ const proc = new EventEmitter() as any;
286
+ proc.stdout = new EventEmitter();
287
+ proc.stderr = new EventEmitter();
288
+ proc.stdin = { write: vi.fn((_d: string, _e: string, cb?: () => void) => { cb?.(); }), end: vi.fn() };
289
+ proc.kill = vi.fn(() => { proc.emit("close", 143); });
290
+ proc.killed = false;
291
+ mockSpawn.mockImplementation(() => proc);
292
+
293
+ const logMessages: string[] = [];
294
+ const promise = runCli("claude", [], "prompt", 100, { log: (m) => logMessages.push(m) });
295
+
296
+ // Advance past the timeout
297
+ vi.advanceTimersByTime(101);
298
+
299
+ const result = await promise;
300
+ expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
301
+ expect(result.timedOut).toBe(true);
302
+ expect(result.exitCode).toBe(143);
303
+ expect(logMessages.some(m => m.includes("timeout") && m.includes("SIGTERM"))).toBe(true);
304
+ vi.useRealTimers();
305
+ });
306
+
307
+ it("sets timedOut=false for normal exits", async () => {
308
+ mockSpawn.mockImplementation(() => makeFakeProc("output", 0));
309
+ const result = await runCli("echo", [], "hello", 60_000);
310
+ expect(result.timedOut).toBe(false);
311
+ expect(result.exitCode).toBe(0);
312
+ });
313
+ });
314
+
315
+ describe("annotateExitError()", () => {
316
+ it("annotates exit 143 as timeout", () => {
317
+ const msg = annotateExitError(143, "(no output)", false, "cli-claude/claude-sonnet-4-6");
318
+ expect(msg).toContain("timeout");
319
+ expect(msg).toContain("supervisor");
320
+ expect(msg).toContain("cli-claude/claude-sonnet-4-6");
321
+ });
322
+
323
+ it("annotates when timedOut is true regardless of exit code", () => {
324
+ const msg = annotateExitError(1, "some error", true, "cli-claude/claude-sonnet-4-6");
325
+ expect(msg).toContain("timeout");
326
+ expect(msg).toContain("supervisor");
327
+ });
328
+
329
+ it("returns plain error when not a timeout", () => {
330
+ const msg = annotateExitError(1, "auth error", false, "cli-claude/claude-sonnet-4-6");
331
+ expect(msg).toBe("auth error");
332
+ expect(msg).not.toContain("timeout");
333
+ });
334
+
335
+ it("returns (no output) placeholder when stderr is empty and not a timeout", () => {
336
+ const msg = annotateExitError(1, "", false, "cli-claude/claude-sonnet-4-6");
337
+ expect(msg).toBe("(no output)");
338
+ });
339
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Tests for the centralized config module.
3
+ * No mocks — tests the real exported values.
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ DEFAULT_PROXY_PORT,
9
+ DEFAULT_PROXY_API_KEY,
10
+ DEFAULT_PROXY_TIMEOUT_MS,
11
+ DEFAULT_CLI_TIMEOUT_MS,
12
+ TIMEOUT_GRACE_MS,
13
+ MAX_MESSAGES,
14
+ MAX_MSG_CHARS,
15
+ SESSION_TTL_MS,
16
+ CLEANUP_INTERVAL_MS,
17
+ SESSION_KILL_GRACE_MS,
18
+ DEFAULT_MODEL_TIMEOUTS,
19
+ DEFAULT_MODEL_FALLBACKS,
20
+ OPENCLAW_DIR,
21
+ CLI_TEST_DEFAULT_MODEL,
22
+ MAX_EFFECTIVE_TIMEOUT_MS,
23
+ TIMEOUT_PER_EXTRA_MSG_MS,
24
+ TIMEOUT_PER_TOOL_MS,
25
+ PROVIDER_SESSION_TTL_MS,
26
+ MEDIA_TMP_DIR,
27
+ PROFILE_DIRS,
28
+ STATE_FILE,
29
+ PENDING_FILE,
30
+ PROVIDER_SESSIONS_FILE,
31
+ DEFAULT_BITNET_SERVER_URL,
32
+ BITNET_MAX_MESSAGES,
33
+ BITNET_SYSTEM_PROMPT,
34
+ } from "../src/config.js";
35
+
36
+ describe("config.ts exports", () => {
37
+ it("exports sensible timeout defaults", () => {
38
+ expect(DEFAULT_PROXY_TIMEOUT_MS).toBe(300_000);
39
+ expect(DEFAULT_CLI_TIMEOUT_MS).toBe(120_000);
40
+ expect(TIMEOUT_GRACE_MS).toBe(5_000);
41
+ expect(MAX_EFFECTIVE_TIMEOUT_MS).toBe(600_000);
42
+ expect(SESSION_TTL_MS).toBe(30 * 60 * 1000);
43
+ expect(CLEANUP_INTERVAL_MS).toBe(5 * 60 * 1000);
44
+ expect(SESSION_KILL_GRACE_MS).toBe(5_000);
45
+ expect(PROVIDER_SESSION_TTL_MS).toBe(2 * 60 * 60 * 1000);
46
+ });
47
+
48
+ it("exports dynamic timeout scaling factors", () => {
49
+ expect(TIMEOUT_PER_EXTRA_MSG_MS).toBe(2_000);
50
+ expect(TIMEOUT_PER_TOOL_MS).toBe(5_000);
51
+ });
52
+
53
+ it("exports message limits", () => {
54
+ expect(MAX_MESSAGES).toBe(20);
55
+ expect(MAX_MSG_CHARS).toBe(4_000);
56
+ });
57
+
58
+ it("exports proxy defaults", () => {
59
+ expect(DEFAULT_PROXY_PORT).toBe(31337);
60
+ expect(DEFAULT_PROXY_API_KEY).toBe("cli-bridge");
61
+ });
62
+
63
+ it("exports per-model timeouts for all major models", () => {
64
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-opus-4-6"]).toBe(300_000);
65
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-sonnet-4-6"]).toBe(180_000);
66
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-haiku-4-5"]).toBe(90_000);
67
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-pro"]).toBe(180_000);
68
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-flash"]).toBe(90_000);
69
+ expect(DEFAULT_MODEL_TIMEOUTS["openai-codex/gpt-5.4"]).toBe(300_000);
70
+ });
71
+
72
+ it("exports model fallback chains", () => {
73
+ expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-sonnet-4-6"]).toBe("cli-claude/claude-haiku-4-5");
74
+ expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).toBe("cli-claude/claude-sonnet-4-6");
75
+ expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).toBe("cli-gemini/gemini-2.5-flash");
76
+ });
77
+
78
+ it("exports path constants rooted in ~/.openclaw", () => {
79
+ expect(OPENCLAW_DIR).toContain(".openclaw");
80
+ expect(STATE_FILE).toContain("cli-bridge-state.json");
81
+ expect(PENDING_FILE).toContain("cli-bridge-pending.json");
82
+ expect(PROVIDER_SESSIONS_FILE).toContain("sessions.json");
83
+ expect(MEDIA_TMP_DIR).toContain("cli-bridge-media");
84
+ });
85
+
86
+ it("exports browser profile directories", () => {
87
+ expect(PROFILE_DIRS.grok).toContain("grok-profile");
88
+ expect(PROFILE_DIRS.gemini).toContain("gemini-profile");
89
+ expect(PROFILE_DIRS.claude).toContain("claude-profile");
90
+ expect(PROFILE_DIRS.chatgpt).toContain("chatgpt-profile");
91
+ });
92
+
93
+ it("exports BitNet defaults", () => {
94
+ expect(DEFAULT_BITNET_SERVER_URL).toBe("http://127.0.0.1:8082");
95
+ expect(BITNET_MAX_MESSAGES).toBe(6);
96
+ expect(BITNET_SYSTEM_PROMPT).toContain("Akido");
97
+ });
98
+
99
+ it("exports CLI test default model", () => {
100
+ expect(CLI_TEST_DEFAULT_MODEL).toBe("cli-claude/claude-sonnet-4-6");
101
+ });
102
+ });
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Unit tests for ProviderSessionRegistry.
3
+ *
4
+ * Mocks the filesystem to prevent real file I/O.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
8
+
9
+ // Mock config module — provide constants used by provider-sessions.ts
10
+ vi.mock("../src/config.js", async () => {
11
+ return {
12
+ PROVIDER_SESSIONS_FILE: "/tmp/test-sessions.json",
13
+ PROVIDER_SESSION_TTL_MS: 2 * 60 * 60 * 1000, // 2 hours
14
+ PROVIDER_SESSION_SWEEP_MS: 10 * 60 * 1000,
15
+ };
16
+ });
17
+
18
+ // Mock fs to prevent real file operations
19
+ const { mockReadFileSync, mockWriteFileSync, mockMkdirSync } = vi.hoisted(() => ({
20
+ mockReadFileSync: vi.fn(() => { throw new Error("ENOENT"); }),
21
+ mockWriteFileSync: vi.fn(),
22
+ mockMkdirSync: vi.fn(),
23
+ }));
24
+
25
+ vi.mock("node:fs", async (importOriginal) => {
26
+ const orig = await importOriginal<typeof import("node:fs")>();
27
+ return {
28
+ ...orig,
29
+ readFileSync: mockReadFileSync,
30
+ writeFileSync: mockWriteFileSync,
31
+ mkdirSync: mockMkdirSync,
32
+ };
33
+ });
34
+
35
+ import { ProviderSessionRegistry } from "../src/provider-sessions.js";
36
+
37
+ // ──────────────────────────────────────────────────────────────────────────────
38
+
39
+ describe("ProviderSessionRegistry", () => {
40
+ let registry: ProviderSessionRegistry;
41
+
42
+ beforeEach(() => {
43
+ mockReadFileSync.mockImplementation(() => { throw new Error("ENOENT"); });
44
+ mockWriteFileSync.mockClear();
45
+ mockMkdirSync.mockClear();
46
+ registry = new ProviderSessionRegistry();
47
+ });
48
+
49
+ afterEach(() => {
50
+ registry.stop();
51
+ });
52
+
53
+ // ── createSession ──────────────────────────────────────────────────────
54
+
55
+ describe("createSession()", () => {
56
+ it("creates a session with correct fields", () => {
57
+ const session = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
58
+ expect(session.id).toMatch(/^claude:session-[a-f0-9]+$/);
59
+ expect(session.provider).toBe("claude");
60
+ expect(session.modelAlias).toBe("cli-claude/claude-sonnet-4-6");
61
+ expect(session.state).toBe("active");
62
+ expect(session.runCount).toBe(0);
63
+ expect(session.timeoutCount).toBe(0);
64
+ expect(session.createdAt).toBeGreaterThan(0);
65
+ expect(session.updatedAt).toBe(session.createdAt);
66
+ });
67
+
68
+ it("creates unique session IDs", () => {
69
+ const s1 = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
70
+ const s2 = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
71
+ expect(s1.id).not.toBe(s2.id);
72
+ });
73
+
74
+ it("accepts optional metadata", () => {
75
+ const session = registry.createSession("claude", "cli-claude/claude-sonnet-4-6", {
76
+ meta: { profilePath: "/tmp/test" },
77
+ });
78
+ expect(session.meta.profilePath).toBe("/tmp/test");
79
+ });
80
+
81
+ it("flushes to disk after creation", () => {
82
+ registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
83
+ expect(mockWriteFileSync).toHaveBeenCalled();
84
+ });
85
+ });
86
+
87
+ // ── getSession ─────────────────────────────────────────────────────────
88
+
89
+ describe("getSession()", () => {
90
+ it("returns the session by ID", () => {
91
+ const created = registry.createSession("gemini", "cli-gemini/gemini-2.5-pro");
92
+ const found = registry.getSession(created.id);
93
+ expect(found).toBe(created);
94
+ });
95
+
96
+ it("returns undefined for unknown ID", () => {
97
+ expect(registry.getSession("nonexistent")).toBeUndefined();
98
+ });
99
+ });
100
+
101
+ // ── findSession ────────────────────────────────────────────────────────
102
+
103
+ describe("findSession()", () => {
104
+ it("finds most recently updated matching session", () => {
105
+ const s1 = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
106
+ const s2 = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
107
+ // Force s2 to be newer so the comparison is deterministic
108
+ s2.updatedAt = s1.updatedAt + 1000;
109
+ const found = registry.findSession("claude", "cli-claude/claude-sonnet-4-6");
110
+ expect(found?.id).toBe(s2.id);
111
+ });
112
+
113
+ it("returns undefined when no match", () => {
114
+ registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
115
+ expect(registry.findSession("gemini", "cli-gemini/gemini-2.5-pro")).toBeUndefined();
116
+ });
117
+
118
+ it("skips expired sessions", () => {
119
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
120
+ s.state = "expired";
121
+ expect(registry.findSession("claude", "cli-claude/claude-sonnet-4-6")).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ // ── ensureSession ──────────────────────────────────────────────────────
126
+
127
+ describe("ensureSession()", () => {
128
+ it("reuses existing active session", () => {
129
+ const s1 = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
130
+ const s2 = registry.ensureSession("claude", "cli-claude/claude-sonnet-4-6");
131
+ expect(s2.id).toBe(s1.id);
132
+ });
133
+
134
+ it("creates new session when none exists", () => {
135
+ const s = registry.ensureSession("claude", "cli-claude/claude-sonnet-4-6");
136
+ expect(s.id).toMatch(/^claude:session-/);
137
+ });
138
+
139
+ it("touches the existing session", () => {
140
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
141
+ const originalUpdated = s.updatedAt;
142
+ // Small delay to ensure timestamp changes
143
+ s.updatedAt = originalUpdated - 1000;
144
+ registry.ensureSession("claude", "cli-claude/claude-sonnet-4-6");
145
+ expect(s.updatedAt).toBeGreaterThan(originalUpdated - 1000);
146
+ });
147
+ });
148
+
149
+ // ── touchSession ───────────────────────────────────────────────────────
150
+
151
+ describe("touchSession()", () => {
152
+ it("updates the timestamp", () => {
153
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
154
+ const before = s.updatedAt;
155
+ s.updatedAt = before - 5000;
156
+ registry.touchSession(s.id);
157
+ expect(s.updatedAt).toBeGreaterThanOrEqual(before);
158
+ });
159
+
160
+ it("returns false for unknown session", () => {
161
+ expect(registry.touchSession("nonexistent")).toBe(false);
162
+ });
163
+
164
+ it("reactivates idle sessions", () => {
165
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
166
+ s.state = "idle";
167
+ registry.touchSession(s.id);
168
+ expect(s.state).toBe("active");
169
+ });
170
+ });
171
+
172
+ // ── recordRun ──────────────────────────────────────────────────────────
173
+
174
+ describe("recordRun()", () => {
175
+ it("increments run count", () => {
176
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
177
+ registry.recordRun(s.id, false);
178
+ expect(s.runCount).toBe(1);
179
+ expect(s.timeoutCount).toBe(0);
180
+ expect(s.state).toBe("idle");
181
+ });
182
+
183
+ it("increments timeout count when timedOut is true", () => {
184
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
185
+ registry.recordRun(s.id, true);
186
+ expect(s.runCount).toBe(1);
187
+ expect(s.timeoutCount).toBe(1);
188
+ });
189
+
190
+ it("sets state to idle after run", () => {
191
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
192
+ expect(s.state).toBe("active");
193
+ registry.recordRun(s.id, false);
194
+ expect(s.state).toBe("idle");
195
+ });
196
+ });
197
+
198
+ // ── deleteSession ──────────────────────────────────────────────────────
199
+
200
+ describe("deleteSession()", () => {
201
+ it("removes the session", () => {
202
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
203
+ expect(registry.deleteSession(s.id)).toBe(true);
204
+ expect(registry.getSession(s.id)).toBeUndefined();
205
+ });
206
+
207
+ it("returns false for unknown ID", () => {
208
+ expect(registry.deleteSession("nonexistent")).toBe(false);
209
+ });
210
+ });
211
+
212
+ // ── stats ──────────────────────────────────────────────────────────────
213
+
214
+ describe("stats()", () => {
215
+ it("returns correct counts", () => {
216
+ const s1 = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
217
+ const s2 = registry.createSession("gemini", "cli-gemini/gemini-2.5-pro");
218
+ registry.recordRun(s2.id, false); // s2 → idle
219
+ const stats = registry.stats();
220
+ expect(stats.total).toBe(2);
221
+ expect(stats.active).toBe(1);
222
+ expect(stats.idle).toBe(1);
223
+ expect(stats.expired).toBe(0);
224
+ });
225
+ });
226
+
227
+ // ── sweep ──────────────────────────────────────────────────────────────
228
+
229
+ describe("sweep()", () => {
230
+ it("removes stale sessions", () => {
231
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
232
+ // Make the session very old
233
+ s.updatedAt = Date.now() - 3 * 60 * 60 * 1000; // 3 hours ago
234
+ registry.sweep();
235
+ expect(registry.getSession(s.id)).toBeUndefined();
236
+ });
237
+
238
+ it("keeps recent sessions", () => {
239
+ const s = registry.createSession("claude", "cli-claude/claude-sonnet-4-6");
240
+ registry.sweep();
241
+ expect(registry.getSession(s.id)).toBeDefined();
242
+ });
243
+ });
244
+
245
+ // ── persistence ────────────────────────────────────────────────────────
246
+
247
+ describe("persistence", () => {
248
+ it("loads sessions from disk on construction", () => {
249
+ const stored = {
250
+ version: 1,
251
+ sessions: [{
252
+ id: "claude:session-abc123",
253
+ provider: "claude",
254
+ modelAlias: "cli-claude/claude-sonnet-4-6",
255
+ createdAt: Date.now() - 1000,
256
+ updatedAt: Date.now() - 500,
257
+ state: "idle" as const,
258
+ runCount: 3,
259
+ timeoutCount: 1,
260
+ meta: {},
261
+ }],
262
+ };
263
+ mockReadFileSync.mockReturnValue(JSON.stringify(stored));
264
+ const freshRegistry = new ProviderSessionRegistry();
265
+ const loaded = freshRegistry.getSession("claude:session-abc123");
266
+ expect(loaded).toBeDefined();
267
+ expect(loaded!.runCount).toBe(3);
268
+ freshRegistry.stop();
269
+ });
270
+
271
+ it("skips expired sessions on load", () => {
272
+ const stored = {
273
+ version: 1,
274
+ sessions: [{
275
+ id: "claude:session-old",
276
+ provider: "claude",
277
+ modelAlias: "cli-claude/claude-sonnet-4-6",
278
+ createdAt: Date.now() - 5 * 60 * 60 * 1000,
279
+ updatedAt: Date.now() - 5 * 60 * 60 * 1000, // 5 hours ago
280
+ state: "idle" as const,
281
+ runCount: 1,
282
+ timeoutCount: 0,
283
+ meta: {},
284
+ }],
285
+ };
286
+ mockReadFileSync.mockReturnValue(JSON.stringify(stored));
287
+ const freshRegistry = new ProviderSessionRegistry();
288
+ expect(freshRegistry.getSession("claude:session-old")).toBeUndefined();
289
+ freshRegistry.stop();
290
+ });
291
+ });
292
+ });
293
+
294
+ // Config module tests are in a separate file (config.test.ts) without mocks.
@@ -62,6 +62,20 @@ vi.mock("../src/workdir.js", () => ({
62
62
  sweepOrphanedWorkdirs: mockSweepOrphanedWorkdirs,
63
63
  }));
64
64
 
65
+ // Mock config module — provide all constants needed by session-manager.ts and cli-runner.ts
66
+ vi.mock("../src/config.js", async () => {
67
+ return {
68
+ SESSION_TTL_MS: 30 * 60 * 1000,
69
+ CLEANUP_INTERVAL_MS: 5 * 60 * 1000,
70
+ SESSION_KILL_GRACE_MS: 5_000,
71
+ DEFAULT_CLI_TIMEOUT_MS: 120_000,
72
+ TIMEOUT_GRACE_MS: 5_000,
73
+ MAX_MESSAGES: 20,
74
+ MAX_MSG_CHARS: 4_000,
75
+ MEDIA_TMP_DIR: "/tmp/cli-bridge-media",
76
+ };
77
+ });
78
+
65
79
  // Now import SessionManager (uses the mocked spawn)
66
80
  import { SessionManager } from "../src/session-manager.js";
67
81