@elvatis_com/openclaw-cli-bridge-elvatis 1.9.1 → 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.
- package/.ai/handoff/STATUS.md +53 -50
- package/CONTRIBUTING.md +18 -0
- package/README.md +13 -2
- package/openclaw.plugin.json +2 -2
- package/package.json +1 -1
- package/src/cli-runner.ts +178 -19
- package/src/grok-client.ts +1 -1
- package/src/proxy-server.ts +141 -22
- package/src/session-manager.ts +307 -0
- package/test/chatgpt-proxy.test.ts +2 -2
- package/test/claude-proxy.test.ts +2 -2
- package/test/cli-runner-extended.test.ts +267 -0
- package/test/grok-proxy.test.ts +2 -2
- package/test/proxy-e2e.test.ts +274 -2
- package/test/session-manager.test.ts +339 -0
|
@@ -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
|
+
});
|