JerrettDavis Copilot commited on
Commit
73ff535
·
1 Parent(s): 37f81ac

test(openclaw): cover branch routing paths

Browse files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

e2e/wrap/run.py CHANGED
@@ -355,6 +355,11 @@ def start_openclaw_gateway(env: dict[str, str], cwd: Path) -> subprocess.Popen[s
355
  )
356
 
357
 
 
 
 
 
 
358
  def verify_installs() -> None:
359
  log("Verifying installed packages and binaries")
360
  for tool in ("headroom", "codex", "aider", "openclaw"):
@@ -617,6 +622,7 @@ def verify_openclaw_wrap(
617
  finally:
618
  if gateway_proc is not None:
619
  stop_process(gateway_proc)
 
620
 
621
 
622
  def main() -> None:
 
355
  )
356
 
357
 
358
+ def stop_openclaw_gateway(env: dict[str, str], cwd: Path) -> None:
359
+ log("Stopping OpenClaw gateway after e2e verification")
360
+ run(["openclaw", "gateway", "stop"], env=env, cwd=cwd, timeout=60)
361
+
362
+
363
  def verify_installs() -> None:
364
  log("Verifying installed packages and binaries")
365
  for tool in ("headroom", "codex", "aider", "openclaw"):
 
622
  finally:
623
  if gateway_proc is not None:
624
  stop_process(gateway_proc)
625
+ stop_openclaw_gateway(base_env, project_dir)
626
 
627
 
628
  def main() -> None:
headroom/cli/wrap.py CHANGED
@@ -1132,6 +1132,24 @@ def openclaw(
1132
  elif not local_source_mode and skip_build:
1133
  click.echo(" Skipping build: npm install mode does not build local source.")
1134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1135
  install_cmd = [
1136
  openclaw_bin,
1137
  "plugins",
@@ -1184,19 +1202,6 @@ def openclaw(
1184
  elif verbose and install_result.stdout.strip():
1185
  click.echo(install_result.stdout.strip())
1186
 
1187
- existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
1188
- entry = _build_openclaw_plugin_entry(
1189
- existing_entry=existing_entry,
1190
- proxy_port=proxy_port,
1191
- startup_timeout_ms=startup_timeout_ms,
1192
- python_path=python_path,
1193
- no_auto_start=no_auto_start,
1194
- gateway_provider_ids=gateway_provider_ids,
1195
- enabled=True,
1196
- )
1197
-
1198
- click.echo(" Writing plugin configuration...")
1199
- _write_openclaw_plugin_entry(openclaw_bin, entry)
1200
  _set_openclaw_context_engine_slot(openclaw_bin, "headroom")
1201
  _run_checked(
1202
  [openclaw_bin, "config", "validate"],
 
1132
  elif not local_source_mode and skip_build:
1133
  click.echo(" Skipping build: npm install mode does not build local source.")
1134
 
1135
+ effective_python_path = python_path
1136
+ if effective_python_path is None and not no_auto_start and sys.executable:
1137
+ effective_python_path = sys.executable
1138
+
1139
+ existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
1140
+ entry = _build_openclaw_plugin_entry(
1141
+ existing_entry=existing_entry,
1142
+ proxy_port=proxy_port,
1143
+ startup_timeout_ms=startup_timeout_ms,
1144
+ python_path=effective_python_path,
1145
+ no_auto_start=no_auto_start,
1146
+ gateway_provider_ids=gateway_provider_ids,
1147
+ enabled=True,
1148
+ )
1149
+
1150
+ click.echo(" Writing plugin configuration...")
1151
+ _write_openclaw_plugin_entry(openclaw_bin, entry)
1152
+
1153
  install_cmd = [
1154
  openclaw_bin,
1155
  "plugins",
 
1202
  elif verbose and install_result.stdout.strip():
1203
  click.echo(install_result.stdout.strip())
1204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1205
  _set_openclaw_context_engine_slot(openclaw_bin, "headroom")
1206
  _run_checked(
1207
  [openclaw_bin, "config", "validate"],
plugins/openclaw/test/engine.test.ts CHANGED
@@ -1,300 +1,101 @@
1
- /**
2
- * Integration tests for HeadroomContextEngine.
3
- *
4
- * Tests the full flow: proxy management, AgentMessage conversion,
5
- * compression via proxy, and round-trip back to AgentMessage.
6
- *
7
- * Requires: Python 3 + headroom-ai[proxy] installed
8
- * Run: HEADROOM_INTEGRATION=1 npx vitest run test/engine.test.ts
9
- */
10
- import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from "vitest";
11
- import { HeadroomContextEngine } from "../src/engine.js";
12
- import { agentToOpenAI, openAIToAgent } from "../src/convert.js";
13
- import { ProxyManager } from "../src/proxy-manager.js";
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- const RUN = process.env.HEADROOM_INTEGRATION === "1";
16
- const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787";
17
 
18
  afterEach(() => {
19
- vi.restoreAllMocks();
 
 
 
 
 
 
20
  });
21
 
22
- // Proxy probing and ProxyManager.start tests live in proxy-manager.test.ts
 
 
23
 
24
- describe("AgentMessage conversion", () => {
25
- it("converts user message", () => {
26
- const agent = [{ role: "user", content: "hello", timestamp: Date.now() }];
27
- const openai = agentToOpenAI(agent);
28
- expect(openai).toHaveLength(1);
29
- expect(openai[0]).toMatchObject({ role: "user", content: "hello" });
 
 
 
 
30
  });
31
 
32
- it("converts assistant with tool_use blocks", () => {
33
- const agent = [
34
- {
35
- role: "assistant",
36
- content: [
37
- { type: "text", text: "Let me search" },
38
- { type: "tool_use", id: "tu_1", name: "search", input: { q: "test" } },
39
- ],
40
- timestamp: Date.now(),
41
- },
42
- ];
43
- const openai = agentToOpenAI(agent);
44
- expect(openai[0].role).toBe("assistant");
45
- expect(openai[0].content).toBe("Let me search");
46
- expect(openai[0].tool_calls).toHaveLength(1);
47
- expect(openai[0].tool_calls![0].function.name).toBe("search");
48
- });
49
 
50
- it("converts assistant with toolCall blocks", () => {
51
- const agent = [
52
- {
53
- role: "assistant",
54
- content: [
55
- { type: "text", text: "Let me search" },
56
- { type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
57
- ],
58
- timestamp: Date.now(),
59
- },
60
- ];
61
- const openai = agentToOpenAI(agent);
62
- expect(openai[0].role).toBe("assistant");
63
- expect(openai[0].content).toBe("Let me search");
64
- expect(openai[0].tool_calls).toHaveLength(1);
65
- expect(openai[0].tool_calls![0].id).toBe("call_1|fc_1");
66
- expect(openai[0].tool_calls![0].function.name).toBe("search");
67
- });
68
 
69
- it("converts toolResult message", () => {
70
- const agent = [
71
- {
72
- role: "toolResult",
73
- content: '{"results": [1, 2, 3]}',
74
- tool_use_id: "tu_1",
75
- timestamp: Date.now(),
76
- },
77
- ];
78
- const openai = agentToOpenAI(agent);
79
- expect(openai[0].role).toBe("tool");
80
- expect(openai[0].content).toBe('{"results": [1, 2, 3]}');
81
- expect(openai[0].tool_call_id).toBe("tu_1");
82
- });
83
 
84
- it("round-trips user message", () => {
85
- const original = [{ role: "user", content: "hello", timestamp: Date.now() }];
86
- const openai = agentToOpenAI(original);
87
- const back = openAIToAgent(openai);
88
- expect(back[0].role).toBe("user");
89
- expect(back[0].content).toBe("hello");
90
  });
91
 
92
- it("round-trips assistant text-only (content always array)", () => {
93
- const original = [
94
- {
95
- role: "assistant",
96
- content: [{ type: "text", text: "Hello there!" }],
97
- timestamp: Date.now(),
98
- },
99
- ];
100
- const openai = agentToOpenAI(original);
101
- const back = openAIToAgent(openai);
102
- expect(back[0].role).toBe("assistant");
103
- // OpenClaw requires content to ALWAYS be an array for assistant messages
104
- const content = back[0].content;
105
- expect(Array.isArray(content)).toBe(true);
106
- expect(content[0]).toEqual({ type: "text", text: "Hello there!" });
107
- });
108
 
109
- it("round-trips assistant with tool calls", () => {
110
- const original = [
111
- {
112
- role: "assistant",
113
- content: [
114
- { type: "text", text: "Searching..." },
115
- { type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
116
- ],
117
- timestamp: Date.now(),
118
- },
119
- ];
120
- const openai = agentToOpenAI(original);
121
- const back = openAIToAgent(openai);
122
- expect(back[0].role).toBe("assistant");
123
- const content = back[0].content;
124
- expect(Array.isArray(content)).toBe(true);
125
- expect(content).toContainEqual(expect.objectContaining({ type: "text", text: "Searching..." }));
126
- expect(content).toContainEqual(
127
- expect.objectContaining({ type: "toolCall", id: "call_1|fc_1", name: "search" }),
128
- );
129
- });
130
 
131
- it("round-trips toolResult", () => {
132
- const original = [
133
- {
134
- role: "toolResult",
135
- content: '{"data": true}',
136
- tool_use_id: "tu_1",
137
- timestamp: Date.now(),
138
- },
139
- ];
140
- const openai = agentToOpenAI(original);
141
- const back = openAIToAgent(openai);
142
- expect(back[0].role).toBe("toolResult");
143
- expect(back[0].content).toEqual([{ type: "text", text: '{"data": true}' }]);
144
- expect(back[0].tool_use_id).toBe("tu_1");
145
  });
146
- });
147
 
148
- describe("HeadroomContextEngine startup behavior", () => {
149
- it("bootstrap schedules proxy startup without blocking on it", async () => {
150
- const start = vi.fn(
151
- () => new Promise<string>((resolve) => setTimeout(() => resolve("http://127.0.0.1:8787"), 50)),
152
- );
153
- vi.spyOn(ProxyManager.prototype, "start").mockImplementation(start);
154
 
155
- const engine = new HeadroomContextEngine();
156
- const result = await engine.bootstrap({
157
- sessionId: "test-session",
158
- sessionFile: "/tmp/test-session.jsonl",
159
- });
160
-
161
- expect(result).toEqual({ bootstrapped: true, reason: "proxy startup scheduled" });
162
- expect(start).toHaveBeenCalledTimes(1);
163
- expect(engine.getProxyUrl()).toBeNull();
164
  });
165
 
166
- it("assemble returns original messages while proxy startup is still pending", async () => {
167
- const start = vi.fn(
168
- () => new Promise<string>((resolve) => setTimeout(() => resolve("http://127.0.0.1:8787"), 50)),
169
- );
170
- vi.spyOn(ProxyManager.prototype, "start").mockImplementation(start);
171
-
172
  const engine = new HeadroomContextEngine();
173
- const messages = [{ role: "user", content: "hello", timestamp: Date.now() }];
174
-
175
- const result = await engine.assemble({
176
- sessionId: "test-session",
 
 
 
 
177
  messages,
178
- model: "claude-sonnet-4-5",
179
  });
180
-
181
- expect(result).toEqual({ messages, estimatedTokens: 0 });
182
- expect(start).toHaveBeenCalledTimes(1);
183
  });
184
  });
185
-
186
- if (RUN) {
187
- describe("ProxyManager", () => {
188
- it("connects to configured proxy URL", { timeout: 30000 }, async () => {
189
- const manager = new ProxyManager({ proxyUrl: PROXY_URL });
190
- try {
191
- const url = await manager.start();
192
- expect(url).toMatch(/^http:\/\/(127\.0\.0\.1|localhost):\d+$/);
193
-
194
- // Verify health
195
- const resp = await fetch(`${url}/health`);
196
- expect(resp.ok).toBe(true);
197
- } finally {
198
- await manager.stop();
199
- }
200
- });
201
- });
202
-
203
- describe("HeadroomContextEngine", () => {
204
- let engine: HeadroomContextEngine;
205
-
206
- beforeAll(async () => {
207
- engine = new HeadroomContextEngine({ proxyUrl: PROXY_URL });
208
- await engine.bootstrap({
209
- sessionId: "test-session",
210
- sessionFile: "/tmp/test-session.jsonl",
211
- });
212
- }, 30000);
213
-
214
- afterAll(async () => {
215
- await engine.dispose();
216
- });
217
-
218
- it("assemble() compresses tool outputs", { timeout: 15000 }, async () => {
219
- // Simulate an OpenClaw agent conversation with large tool result
220
- const serverData = Array.from({ length: 100 }, (_, i) => ({
221
- id: i + 1,
222
- name: `server-${i + 1}`,
223
- status: i % 15 === 0 ? "critical" : i % 5 === 0 ? "warning" : "healthy",
224
- cpu: Math.round(Math.random() * 100),
225
- memory: Math.round(Math.random() * 100),
226
- region: ["us-east-1", "eu-west-1", "ap-southeast-1"][i % 3],
227
- description: `Production server ${i + 1} running service-${["auth", "payment", "user", "api"][i % 4]}`,
228
- lastAlert: i % 15 === 0 ? `Disk usage at ${90 + (i % 10)}%` : null,
229
- }));
230
-
231
- const messages = [
232
- { role: "user", content: "Check the fleet status", timestamp: Date.now() },
233
- {
234
- role: "assistant",
235
- content: [
236
- { type: "tool_use", id: "tu_fleet", name: "getFleetStatus", input: {} },
237
- ],
238
- timestamp: Date.now(),
239
- },
240
- {
241
- role: "toolResult",
242
- content: JSON.stringify(serverData),
243
- tool_use_id: "tu_fleet",
244
- timestamp: Date.now(),
245
- },
246
- { role: "user", content: "Which servers are critical?", timestamp: Date.now() },
247
- ];
248
-
249
- const result = await engine.assemble({
250
- sessionId: "test-session",
251
- messages,
252
- model: "claude-sonnet-4-5",
253
- });
254
-
255
- console.log(
256
- ` assemble(): estimatedTokens=${result.estimatedTokens}, ` +
257
- `systemPrompt=${result.systemPromptAddition ? "yes" : "no"}`,
258
- );
259
-
260
- // Messages should be returned (compressed or not)
261
- expect(result.messages.length).toBeGreaterThan(0);
262
- // First and last messages should still be user messages
263
- expect(result.messages[0].role).toBe("user");
264
- expect(result.messages[result.messages.length - 1].role).toBe("user");
265
- });
266
-
267
- it("assemble() preserves small conversations", { timeout: 15000 }, async () => {
268
- const messages = [
269
- { role: "user", content: "Hello", timestamp: Date.now() },
270
- { role: "assistant", content: "Hi there!", timestamp: Date.now() },
271
- ];
272
-
273
- const result = await engine.assemble({
274
- sessionId: "test-session",
275
- messages,
276
- });
277
-
278
- expect(result.messages).toHaveLength(2);
279
- expect(result.messages[0].content).toBe("Hello");
280
- expect(result.messages[1].content).toBe("Hi there!");
281
- });
282
-
283
- it("compact() returns success (compression handled in assemble)", async () => {
284
- const result = await engine.compact({
285
- sessionId: "test-session",
286
- sessionFile: "/tmp/test.jsonl",
287
- });
288
-
289
- expect(result.ok).toBe(true);
290
- expect(result.compacted).toBe(true);
291
- });
292
-
293
- it("getStats() returns compression statistics", () => {
294
- const stats = engine.getStats();
295
- expect(stats).toHaveProperty("totalCompressions");
296
- expect(stats).toHaveProperty("totalTokensSaved");
297
- expect(stats.totalCompressions).toBeGreaterThanOrEqual(0);
298
- });
299
- });
300
- }
 
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocked = vi.hoisted(() => ({
4
+ start: vi.fn(async () => "http://127.0.0.1:8787"),
5
+ stop: vi.fn(async () => undefined),
6
+ logger: {
7
+ debug: vi.fn(),
8
+ error: vi.fn(),
9
+ info: vi.fn(),
10
+ warn: vi.fn(),
11
+ },
12
+ }));
13
+
14
+ vi.mock("headroom-ai", () => ({
15
+ compress: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("../src/proxy-manager.js", () => ({
19
+ ProxyManager: class {
20
+ start = mocked.start;
21
+ stop = mocked.stop;
22
+ },
23
+ defaultLogger: mocked.logger,
24
+ }));
25
 
26
+ import { HeadroomContextEngine } from "../src/engine.js";
 
27
 
28
  afterEach(() => {
29
+ mocked.start.mockReset();
30
+ mocked.start.mockResolvedValue("http://127.0.0.1:8787");
31
+ mocked.stop.mockClear();
32
+ mocked.logger.debug.mockClear();
33
+ mocked.logger.error.mockClear();
34
+ mocked.logger.info.mockClear();
35
+ mocked.logger.warn.mockClear();
36
  });
37
 
38
+ describe("HeadroomContextEngine proxy startup helpers", () => {
39
+ it("bootstraps by scheduling proxy startup when enabled", async () => {
40
+ const engine = new HeadroomContextEngine();
41
 
42
+ await expect(
43
+ engine.bootstrap({
44
+ sessionId: "session-1",
45
+ sessionFile: "session.jsonl",
46
+ }),
47
+ ).resolves.toEqual({
48
+ bootstrapped: true,
49
+ reason: "proxy startup scheduled",
50
+ });
51
+ expect(mocked.start).toHaveBeenCalledTimes(1);
52
  });
53
 
54
+ it("removes unsubscribed proxy listeners before notifying readiness", async () => {
55
+ const engine = new HeadroomContextEngine();
56
+ const first = vi.fn();
57
+ const second = vi.fn();
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
+ const unsubscribeFirst = engine.onProxyReady(first);
60
+ engine.onProxyReady(second);
61
+ unsubscribeFirst();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
+ engine.ensureProxyStarted();
64
+ await engine.ensureProxyUrl();
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
+ expect(first).not.toHaveBeenCalled();
67
+ expect(second).toHaveBeenCalledWith("http://127.0.0.1:8787");
 
 
 
 
68
  });
69
 
70
+ it("returns the existing proxy URL without starting again", async () => {
71
+ const engine = new HeadroomContextEngine();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
+ (engine as { proxyUrl: string | null }).proxyUrl = "http://127.0.0.1:8787";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
+ await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
76
+ expect(mocked.start).not.toHaveBeenCalled();
 
 
 
 
 
 
 
 
 
 
 
 
77
  });
 
78
 
79
+ it("throws when proxy startup is disabled", async () => {
80
+ const engine = new HeadroomContextEngine({ enabled: false });
 
 
 
 
81
 
82
+ await expect(engine.ensureProxyUrl()).rejects.toThrow("Headroom proxy startup is disabled");
83
+ expect(mocked.start).not.toHaveBeenCalled();
 
 
 
 
 
 
 
84
  });
85
 
86
+ it("schedules startup and returns original messages when assembling before proxy readiness", async () => {
 
 
 
 
 
87
  const engine = new HeadroomContextEngine();
88
+ const messages = [{ role: "user", content: "hello" }];
89
+
90
+ await expect(
91
+ engine.assemble({
92
+ sessionId: "session-1",
93
+ messages,
94
+ }),
95
+ ).resolves.toEqual({
96
  messages,
97
+ estimatedTokens: 0,
98
  });
99
+ expect(mocked.start).toHaveBeenCalledTimes(1);
 
 
100
  });
101
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_cli/test_wrap_openclaw.py CHANGED
@@ -67,6 +67,18 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
67
  assert ["openclaw", "gateway", "restart"] in cmds
68
  assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
69
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  # Verify plugin install in npm mode does not set cwd
71
  install_call = next(
72
  c
@@ -91,6 +103,7 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
91
  assert payload["config"]["autoStart"] is True
92
  assert payload["config"]["startupTimeoutMs"] == 20000
93
  assert payload["config"]["gatewayProviderIds"] == ["openai-codex"]
 
94
 
95
 
96
  def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None:
@@ -330,6 +343,86 @@ def test_wrap_openclaw_accepts_repeatable_gateway_provider_ids(runner: CliRunner
330
  assert payload["config"]["gatewayProviderIds"] == ["openai-codex", "anthropic"]
331
 
332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback(
334
  runner: CliRunner,
335
  ) -> None:
@@ -571,3 +664,37 @@ def test_unwrap_openclaw_no_restart_skips_gateway_restart(runner: CliRunner) ->
571
 
572
  assert result.exit_code == 0, result.output
573
  assert ["openclaw", "gateway", "restart"] not in [c["cmd"] for c in calls]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  assert ["openclaw", "gateway", "restart"] in cmds
68
  assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
69
 
70
+ config_set_index = next(
71
+ i
72
+ for i, cmd in enumerate(cmds)
73
+ if cmd[:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
74
+ )
75
+ install_index = next(
76
+ i
77
+ for i, cmd in enumerate(cmds)
78
+ if cmd[:4] == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"]
79
+ )
80
+ assert config_set_index < install_index
81
+
82
  # Verify plugin install in npm mode does not set cwd
83
  install_call = next(
84
  c
 
103
  assert payload["config"]["autoStart"] is True
104
  assert payload["config"]["startupTimeoutMs"] == 20000
105
  assert payload["config"]["gatewayProviderIds"] == ["openai-codex"]
106
+ assert payload["config"]["pythonPath"] == wrap_cli.sys.executable
107
 
108
 
109
  def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None:
 
343
  assert payload["config"]["gatewayProviderIds"] == ["openai-codex", "anthropic"]
344
 
345
 
346
+ def test_normalize_openclaw_gateway_provider_ids_dedupes_blanks_and_defaults() -> None:
347
+ assert wrap_cli._normalize_openclaw_gateway_provider_ids(
348
+ (" openai-codex ", "", "anthropic", "openai-codex", " ")
349
+ ) == ["openai-codex", "anthropic"]
350
+ assert wrap_cli._normalize_openclaw_gateway_provider_ids(None) == ["openai-codex"]
351
+
352
+
353
+ def test_read_openclaw_config_value_handles_missing_and_raw_strings() -> None:
354
+ missing = MagicMock(returncode=1, stdout="", stderr="missing")
355
+ raw_string = MagicMock(returncode=0, stdout="plain-text-value\n", stderr="")
356
+
357
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=[missing, raw_string]):
358
+ assert wrap_cli._read_openclaw_config_value("openclaw", "plugins.entries.headroom") is None
359
+ assert (
360
+ wrap_cli._read_openclaw_config_value(
361
+ "openclaw", "plugins.entries.headroom.config.pythonPath"
362
+ )
363
+ == "plain-text-value"
364
+ )
365
+
366
+
367
+ def test_build_openclaw_plugin_entry_sets_and_clears_python_path() -> None:
368
+ with_python = wrap_cli._build_openclaw_plugin_entry(
369
+ existing_entry={"config": {"customFlag": True}},
370
+ proxy_port=8787,
371
+ startup_timeout_ms=20000,
372
+ python_path="C:\\Python312\\python.exe",
373
+ no_auto_start=False,
374
+ gateway_provider_ids=("openai-codex",),
375
+ enabled=True,
376
+ )
377
+ assert with_python["config"]["pythonPath"] == "C:\\Python312\\python.exe"
378
+
379
+ without_python = wrap_cli._build_openclaw_plugin_entry(
380
+ existing_entry={"config": {"pythonPath": "C:\\Old\\python.exe", "customFlag": True}},
381
+ proxy_port=8787,
382
+ startup_timeout_ms=20000,
383
+ python_path=None,
384
+ no_auto_start=False,
385
+ gateway_provider_ids=("openai-codex",),
386
+ enabled=True,
387
+ )
388
+ assert "pythonPath" not in without_python["config"]
389
+ assert without_python["config"]["customFlag"] is True
390
+
391
+
392
+ def test_wrap_openclaw_no_auto_start_does_not_default_python_path(
393
+ runner: CliRunner, plugin_dir: Path
394
+ ) -> None:
395
+ calls: list[dict] = []
396
+
397
+ def which(name: str) -> str | None:
398
+ return {"openclaw": "openclaw", "npm": "npm"}.get(name)
399
+
400
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
401
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
402
+ result = runner.invoke(
403
+ main,
404
+ [
405
+ "wrap",
406
+ "openclaw",
407
+ "--plugin-path",
408
+ str(plugin_dir),
409
+ "--skip-build",
410
+ "--no-auto-start",
411
+ "--no-restart",
412
+ ],
413
+ )
414
+
415
+ assert result.exit_code == 0, result.output
416
+ set_entry = next(
417
+ c
418
+ for c in calls
419
+ if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
420
+ )
421
+ payload = json.loads(set_entry["cmd"][4])
422
+ assert payload["config"]["autoStart"] is False
423
+ assert "pythonPath" not in payload["config"]
424
+
425
+
426
  def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback(
427
  runner: CliRunner,
428
  ) -> None:
 
664
 
665
  assert result.exit_code == 0, result.output
666
  assert ["openclaw", "gateway", "restart"] not in [c["cmd"] for c in calls]
667
+
668
+
669
+ def test_unwrap_openclaw_fails_when_openclaw_missing(runner: CliRunner) -> None:
670
+ with patch("headroom.cli.wrap.shutil.which", return_value=None):
671
+ result = runner.invoke(main, ["unwrap", "openclaw"])
672
+
673
+ assert result.exit_code != 0
674
+ assert "'openclaw' not found in PATH" in result.output
675
+
676
+
677
+ def test_unwrap_openclaw_verbose_prints_gateway_and_inspect_output(runner: CliRunner) -> None:
678
+ def which(name: str) -> str | None:
679
+ return {"openclaw": "openclaw"}.get(name)
680
+
681
+ def run(cmd, **kwargs): # noqa: ANN001
682
+ if cmd[:4] == ["openclaw", "config", "get", "plugins.entries.headroom"]:
683
+ return MagicMock(
684
+ returncode=0,
685
+ stdout=json.dumps({"enabled": True, "config": {"proxyPort": 8787}}),
686
+ stderr="",
687
+ )
688
+ if cmd[:3] == ["openclaw", "gateway", "restart"]:
689
+ return MagicMock(returncode=0, stdout="gateway-restarted", stderr="")
690
+ if cmd[:3] == ["openclaw", "plugins", "inspect"]:
691
+ return MagicMock(returncode=0, stdout="inspect-disabled", stderr="")
692
+ return MagicMock(returncode=0, stdout="", stderr="")
693
+
694
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
695
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
696
+ result = runner.invoke(main, ["unwrap", "openclaw", "--verbose"])
697
+
698
+ assert result.exit_code == 0, result.output
699
+ assert "gateway-restarted" in result.output
700
+ assert "inspect-disabled" in result.output
tests/test_openai_codex_routing.py CHANGED
@@ -1,7 +1,17 @@
1
  import base64
2
  import json
 
 
 
3
 
4
- from headroom.proxy.handlers.openai import _resolve_codex_routing_headers
 
 
 
 
 
 
 
5
 
6
 
7
  def _jwt(payload: dict) -> str:
@@ -56,3 +66,152 @@ def test_resolve_codex_routing_leaves_regular_openai_bearer_tokens_unchanged():
56
 
57
  assert is_chatgpt is False
58
  assert "ChatGPT-Account-ID" not in headers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import base64
2
  import json
3
+ import sys
4
+ from types import SimpleNamespace
5
+ from unittest.mock import MagicMock, patch
6
 
7
+ import anyio
8
+ import pytest
9
+ from fastapi import Request
10
+
11
+ from headroom.proxy.handlers.openai import (
12
+ OpenAIHandlerMixin,
13
+ _resolve_codex_routing_headers,
14
+ )
15
 
16
 
17
  def _jwt(payload: dict) -> str:
 
66
 
67
  assert is_chatgpt is False
68
  assert "ChatGPT-Account-ID" not in headers
69
+
70
+
71
+ def test_resolve_codex_routing_returns_none_without_bearer_auth():
72
+ headers, is_chatgpt = _resolve_codex_routing_headers({})
73
+
74
+ assert is_chatgpt is False
75
+ assert headers == {}
76
+
77
+
78
+ def test_resolve_codex_routing_ignores_non_jwt_bearer_tokens():
79
+ headers, is_chatgpt = _resolve_codex_routing_headers(
80
+ {
81
+ "authorization": "Bearer not-a-jwt",
82
+ }
83
+ )
84
+
85
+ assert is_chatgpt is False
86
+ assert headers["authorization"] == "Bearer not-a-jwt"
87
+
88
+
89
+ def test_resolve_codex_routing_ignores_invalid_jwt_payloads():
90
+ invalid_payload = base64.urlsafe_b64encode(b"not-json").decode("ascii").rstrip("=")
91
+ token = f"test-header.{invalid_payload}.signature"
92
+
93
+ headers, is_chatgpt = _resolve_codex_routing_headers(
94
+ {
95
+ "authorization": f"Bearer {token}",
96
+ }
97
+ )
98
+
99
+ assert is_chatgpt is False
100
+ assert headers["authorization"] == f"Bearer {token}"
101
+
102
+
103
+ class _DummyMetrics:
104
+ async def record_request(self, **kwargs): # noqa: ANN003
105
+ return None
106
+
107
+ async def record_failed(self):
108
+ return None
109
+
110
+
111
+ class _DummyTokenizer:
112
+ def count_messages(self, messages):
113
+ return len(messages)
114
+
115
+
116
+ class _ResponseStub:
117
+ def json(self):
118
+ return {"usage": {"input_tokens": 2, "output_tokens": 1}}
119
+
120
+
121
+ class _DummyOpenAIHandler(OpenAIHandlerMixin):
122
+ OPENAI_API_URL = "https://api.openai.com"
123
+
124
+ def __init__(self) -> None:
125
+ self.rate_limiter = None
126
+ self.metrics = _DummyMetrics()
127
+ self.config = SimpleNamespace(optimize=False)
128
+ self.usage_reporter = None
129
+ self.openai_provider = SimpleNamespace()
130
+ self.anthropic_backend = None
131
+ self.cost_tracker = None
132
+ self.captured_request: tuple[str, str, dict, dict] | None = None
133
+
134
+ async def _next_request_id(self) -> str:
135
+ return "req-1"
136
+
137
+ def _extract_tags(self, headers: dict[str, str]) -> list[str]:
138
+ return []
139
+
140
+ async def _retry_request(self, method: str, url: str, headers: dict, body: dict):
141
+ self.captured_request = (method, url, headers, body)
142
+ return _ResponseStub()
143
+
144
+
145
+ def _build_request(body: dict, headers: dict[str, str]) -> Request:
146
+ payload = json.dumps(body).encode("utf-8")
147
+
148
+ async def receive():
149
+ return {"type": "http.request", "body": payload, "more_body": False}
150
+
151
+ scope = {
152
+ "type": "http",
153
+ "asgi": {"version": "3.0"},
154
+ "http_version": "1.1",
155
+ "method": "POST",
156
+ "scheme": "https",
157
+ "path": "/v1/responses",
158
+ "raw_path": b"/v1/responses",
159
+ "query_string": b"",
160
+ "headers": [
161
+ (key.lower().encode("utf-8"), value.encode("utf-8")) for key, value in headers.items()
162
+ ],
163
+ "client": ("127.0.0.1", 12345),
164
+ "server": ("testserver", 443),
165
+ }
166
+ return Request(scope, receive)
167
+
168
+
169
+ def test_handle_openai_responses_routes_chatgpt_auth_to_backend_api(monkeypatch):
170
+ token = _jwt(
171
+ {
172
+ "https://api.openai.com/auth": {
173
+ "chatgpt_account_id": "acct-from-jwt",
174
+ }
175
+ }
176
+ )
177
+ request = _build_request(
178
+ {"model": "gpt-5.4", "input": "hello"},
179
+ {"Authorization": f"Bearer {token}"},
180
+ )
181
+ handler = _DummyOpenAIHandler()
182
+
183
+ monkeypatch.setattr("headroom.tokenizers.get_tokenizer", lambda model: _DummyTokenizer())
184
+
185
+ anyio.run(handler.handle_openai_responses, request)
186
+
187
+ assert handler.captured_request is not None
188
+ method, url, headers, body = handler.captured_request
189
+ assert method == "POST"
190
+ assert url == "https://chatgpt.com/backend-api/codex/responses"
191
+ assert headers["ChatGPT-Account-ID"] == "acct-from-jwt"
192
+ assert body["input"] == "hello"
193
+
194
+
195
+ class _DummyWebSocket:
196
+ def __init__(self, headers: dict[str, str]):
197
+ self.headers = headers
198
+ self.accepted_subprotocol = None
199
+
200
+ async def accept(self, subprotocol=None):
201
+ self.accepted_subprotocol = subprotocol
202
+
203
+
204
+ def test_handle_openai_responses_ws_resolves_codex_routing_headers():
205
+ class SentinelError(RuntimeError):
206
+ pass
207
+
208
+ handler = _DummyOpenAIHandler()
209
+ websocket = _DummyWebSocket({"authorization": "Bearer token"})
210
+
211
+ with patch.dict(sys.modules, {"websockets": MagicMock()}):
212
+ with patch(
213
+ "headroom.proxy.handlers.openai._resolve_codex_routing_headers",
214
+ side_effect=SentinelError("resolved"),
215
+ ):
216
+ with pytest.raises(SentinelError, match="resolved"):
217
+ anyio.run(handler.handle_openai_responses_ws, websocket)