@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.
- package/README.md +33 -3
- package/SKILL.md +1 -1
- package/index.ts +38 -23
- package/openclaw.plugin.json +21 -2
- package/package.json +1 -1
- package/src/cli-runner.ts +119 -33
- package/src/config.ts +217 -0
- package/src/provider-sessions.ts +264 -0
- package/src/proxy-server.ts +76 -15
- package/src/session-manager.ts +24 -7
- package/test/cli-runner-extended.test.ts +72 -0
- package/test/config.test.ts +102 -0
- package/test/provider-sessions.test.ts +294 -0
- package/test/session-manager.test.ts +14 -0
package/src/session-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|