mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
test(openclaw): cover branch routing paths
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
2f05705043
commit
37f32a8922
5 changed files with 397 additions and 299 deletions
|
|
@ -355,6 +355,11 @@ def start_openclaw_gateway(env: dict[str, str], cwd: Path) -> subprocess.Popen[s
|
|||
)
|
||||
|
||||
|
||||
def stop_openclaw_gateway(env: dict[str, str], cwd: Path) -> None:
|
||||
log("Stopping OpenClaw gateway after e2e verification")
|
||||
run(["openclaw", "gateway", "stop"], env=env, cwd=cwd, timeout=60)
|
||||
|
||||
|
||||
def verify_installs() -> None:
|
||||
log("Verifying installed packages and binaries")
|
||||
for tool in ("headroom", "codex", "aider", "openclaw"):
|
||||
|
|
@ -617,6 +622,7 @@ def verify_openclaw_wrap(
|
|||
finally:
|
||||
if gateway_proc is not None:
|
||||
stop_process(gateway_proc)
|
||||
stop_openclaw_gateway(base_env, project_dir)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
|||
|
|
@ -1132,6 +1132,24 @@ def openclaw(
|
|||
elif not local_source_mode and skip_build:
|
||||
click.echo(" Skipping build: npm install mode does not build local source.")
|
||||
|
||||
effective_python_path = python_path
|
||||
if effective_python_path is None and not no_auto_start and sys.executable:
|
||||
effective_python_path = sys.executable
|
||||
|
||||
existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
|
||||
entry = _build_openclaw_plugin_entry(
|
||||
existing_entry=existing_entry,
|
||||
proxy_port=proxy_port,
|
||||
startup_timeout_ms=startup_timeout_ms,
|
||||
python_path=effective_python_path,
|
||||
no_auto_start=no_auto_start,
|
||||
gateway_provider_ids=gateway_provider_ids,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
click.echo(" Writing plugin configuration...")
|
||||
_write_openclaw_plugin_entry(openclaw_bin, entry)
|
||||
|
||||
install_cmd = [
|
||||
openclaw_bin,
|
||||
"plugins",
|
||||
|
|
@ -1184,19 +1202,6 @@ def openclaw(
|
|||
elif verbose and install_result.stdout.strip():
|
||||
click.echo(install_result.stdout.strip())
|
||||
|
||||
existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
|
||||
entry = _build_openclaw_plugin_entry(
|
||||
existing_entry=existing_entry,
|
||||
proxy_port=proxy_port,
|
||||
startup_timeout_ms=startup_timeout_ms,
|
||||
python_path=python_path,
|
||||
no_auto_start=no_auto_start,
|
||||
gateway_provider_ids=gateway_provider_ids,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
click.echo(" Writing plugin configuration...")
|
||||
_write_openclaw_plugin_entry(openclaw_bin, entry)
|
||||
_set_openclaw_context_engine_slot(openclaw_bin, "headroom")
|
||||
_run_checked(
|
||||
[openclaw_bin, "config", "validate"],
|
||||
|
|
|
|||
|
|
@ -1,300 +1,101 @@
|
|||
/**
|
||||
* Integration tests for HeadroomContextEngine.
|
||||
*
|
||||
* Tests the full flow: proxy management, AgentMessage conversion,
|
||||
* compression via proxy, and round-trip back to AgentMessage.
|
||||
*
|
||||
* Requires: Python 3 + headroom-ai[proxy] installed
|
||||
* Run: HEADROOM_INTEGRATION=1 npx vitest run test/engine.test.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from "vitest";
|
||||
import { HeadroomContextEngine } from "../src/engine.js";
|
||||
import { agentToOpenAI, openAIToAgent } from "../src/convert.js";
|
||||
import { ProxyManager } from "../src/proxy-manager.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const RUN = process.env.HEADROOM_INTEGRATION === "1";
|
||||
const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787";
|
||||
const mocked = vi.hoisted(() => ({
|
||||
start: vi.fn(async () => "http://127.0.0.1:8787"),
|
||||
stop: vi.fn(async () => undefined),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("headroom-ai", () => ({
|
||||
compress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../src/proxy-manager.js", () => ({
|
||||
ProxyManager: class {
|
||||
start = mocked.start;
|
||||
stop = mocked.stop;
|
||||
},
|
||||
defaultLogger: mocked.logger,
|
||||
}));
|
||||
|
||||
import { HeadroomContextEngine } from "../src/engine.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mocked.start.mockReset();
|
||||
mocked.start.mockResolvedValue("http://127.0.0.1:8787");
|
||||
mocked.stop.mockClear();
|
||||
mocked.logger.debug.mockClear();
|
||||
mocked.logger.error.mockClear();
|
||||
mocked.logger.info.mockClear();
|
||||
mocked.logger.warn.mockClear();
|
||||
});
|
||||
|
||||
// Proxy probing and ProxyManager.start tests live in proxy-manager.test.ts
|
||||
|
||||
describe("AgentMessage conversion", () => {
|
||||
it("converts user message", () => {
|
||||
const agent = [{ role: "user", content: "hello", timestamp: Date.now() }];
|
||||
const openai = agentToOpenAI(agent);
|
||||
expect(openai).toHaveLength(1);
|
||||
expect(openai[0]).toMatchObject({ role: "user", content: "hello" });
|
||||
});
|
||||
|
||||
it("converts assistant with tool_use blocks", () => {
|
||||
const agent = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me search" },
|
||||
{ type: "tool_use", id: "tu_1", name: "search", input: { q: "test" } },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const openai = agentToOpenAI(agent);
|
||||
expect(openai[0].role).toBe("assistant");
|
||||
expect(openai[0].content).toBe("Let me search");
|
||||
expect(openai[0].tool_calls).toHaveLength(1);
|
||||
expect(openai[0].tool_calls![0].function.name).toBe("search");
|
||||
});
|
||||
|
||||
it("converts assistant with toolCall blocks", () => {
|
||||
const agent = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me search" },
|
||||
{ type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const openai = agentToOpenAI(agent);
|
||||
expect(openai[0].role).toBe("assistant");
|
||||
expect(openai[0].content).toBe("Let me search");
|
||||
expect(openai[0].tool_calls).toHaveLength(1);
|
||||
expect(openai[0].tool_calls![0].id).toBe("call_1|fc_1");
|
||||
expect(openai[0].tool_calls![0].function.name).toBe("search");
|
||||
});
|
||||
|
||||
it("converts toolResult message", () => {
|
||||
const agent = [
|
||||
{
|
||||
role: "toolResult",
|
||||
content: '{"results": [1, 2, 3]}',
|
||||
tool_use_id: "tu_1",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const openai = agentToOpenAI(agent);
|
||||
expect(openai[0].role).toBe("tool");
|
||||
expect(openai[0].content).toBe('{"results": [1, 2, 3]}');
|
||||
expect(openai[0].tool_call_id).toBe("tu_1");
|
||||
});
|
||||
|
||||
it("round-trips user message", () => {
|
||||
const original = [{ role: "user", content: "hello", timestamp: Date.now() }];
|
||||
const openai = agentToOpenAI(original);
|
||||
const back = openAIToAgent(openai);
|
||||
expect(back[0].role).toBe("user");
|
||||
expect(back[0].content).toBe("hello");
|
||||
});
|
||||
|
||||
it("round-trips assistant text-only (content always array)", () => {
|
||||
const original = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello there!" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const openai = agentToOpenAI(original);
|
||||
const back = openAIToAgent(openai);
|
||||
expect(back[0].role).toBe("assistant");
|
||||
// OpenClaw requires content to ALWAYS be an array for assistant messages
|
||||
const content = back[0].content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content[0]).toEqual({ type: "text", text: "Hello there!" });
|
||||
});
|
||||
|
||||
it("round-trips assistant with tool calls", () => {
|
||||
const original = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Searching..." },
|
||||
{ type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const openai = agentToOpenAI(original);
|
||||
const back = openAIToAgent(openai);
|
||||
expect(back[0].role).toBe("assistant");
|
||||
const content = back[0].content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content).toContainEqual(expect.objectContaining({ type: "text", text: "Searching..." }));
|
||||
expect(content).toContainEqual(
|
||||
expect.objectContaining({ type: "toolCall", id: "call_1|fc_1", name: "search" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips toolResult", () => {
|
||||
const original = [
|
||||
{
|
||||
role: "toolResult",
|
||||
content: '{"data": true}',
|
||||
tool_use_id: "tu_1",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const openai = agentToOpenAI(original);
|
||||
const back = openAIToAgent(openai);
|
||||
expect(back[0].role).toBe("toolResult");
|
||||
expect(back[0].content).toEqual([{ type: "text", text: '{"data": true}' }]);
|
||||
expect(back[0].tool_use_id).toBe("tu_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeadroomContextEngine startup behavior", () => {
|
||||
it("bootstrap schedules proxy startup without blocking on it", async () => {
|
||||
const start = vi.fn(
|
||||
() => new Promise<string>((resolve) => setTimeout(() => resolve("http://127.0.0.1:8787"), 50)),
|
||||
);
|
||||
vi.spyOn(ProxyManager.prototype, "start").mockImplementation(start);
|
||||
|
||||
describe("HeadroomContextEngine proxy startup helpers", () => {
|
||||
it("bootstraps by scheduling proxy startup when enabled", async () => {
|
||||
const engine = new HeadroomContextEngine();
|
||||
const result = await engine.bootstrap({
|
||||
sessionId: "test-session",
|
||||
sessionFile: "/tmp/test-session.jsonl",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ bootstrapped: true, reason: "proxy startup scheduled" });
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(engine.getProxyUrl()).toBeNull();
|
||||
await expect(
|
||||
engine.bootstrap({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "session.jsonl",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
bootstrapped: true,
|
||||
reason: "proxy startup scheduled",
|
||||
});
|
||||
expect(mocked.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("assemble returns original messages while proxy startup is still pending", async () => {
|
||||
const start = vi.fn(
|
||||
() => new Promise<string>((resolve) => setTimeout(() => resolve("http://127.0.0.1:8787"), 50)),
|
||||
);
|
||||
vi.spyOn(ProxyManager.prototype, "start").mockImplementation(start);
|
||||
|
||||
it("removes unsubscribed proxy listeners before notifying readiness", async () => {
|
||||
const engine = new HeadroomContextEngine();
|
||||
const messages = [{ role: "user", content: "hello", timestamp: Date.now() }];
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
const result = await engine.assemble({
|
||||
sessionId: "test-session",
|
||||
const unsubscribeFirst = engine.onProxyReady(first);
|
||||
engine.onProxyReady(second);
|
||||
unsubscribeFirst();
|
||||
|
||||
engine.ensureProxyStarted();
|
||||
await engine.ensureProxyUrl();
|
||||
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledWith("http://127.0.0.1:8787");
|
||||
});
|
||||
|
||||
it("returns the existing proxy URL without starting again", async () => {
|
||||
const engine = new HeadroomContextEngine();
|
||||
|
||||
(engine as { proxyUrl: string | null }).proxyUrl = "http://127.0.0.1:8787";
|
||||
|
||||
await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
|
||||
expect(mocked.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when proxy startup is disabled", async () => {
|
||||
const engine = new HeadroomContextEngine({ enabled: false });
|
||||
|
||||
await expect(engine.ensureProxyUrl()).rejects.toThrow("Headroom proxy startup is disabled");
|
||||
expect(mocked.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules startup and returns original messages when assembling before proxy readiness", async () => {
|
||||
const engine = new HeadroomContextEngine();
|
||||
const messages = [{ role: "user", content: "hello" }];
|
||||
|
||||
await expect(
|
||||
engine.assemble({
|
||||
sessionId: "session-1",
|
||||
messages,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
messages,
|
||||
model: "claude-sonnet-4-5",
|
||||
estimatedTokens: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messages, estimatedTokens: 0 });
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(mocked.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
if (RUN) {
|
||||
describe("ProxyManager", () => {
|
||||
it("connects to configured proxy URL", { timeout: 30000 }, async () => {
|
||||
const manager = new ProxyManager({ proxyUrl: PROXY_URL });
|
||||
try {
|
||||
const url = await manager.start();
|
||||
expect(url).toMatch(/^http:\/\/(127\.0\.0\.1|localhost):\d+$/);
|
||||
|
||||
// Verify health
|
||||
const resp = await fetch(`${url}/health`);
|
||||
expect(resp.ok).toBe(true);
|
||||
} finally {
|
||||
await manager.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeadroomContextEngine", () => {
|
||||
let engine: HeadroomContextEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new HeadroomContextEngine({ proxyUrl: PROXY_URL });
|
||||
await engine.bootstrap({
|
||||
sessionId: "test-session",
|
||||
sessionFile: "/tmp/test-session.jsonl",
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("assemble() compresses tool outputs", { timeout: 15000 }, async () => {
|
||||
// Simulate an OpenClaw agent conversation with large tool result
|
||||
const serverData = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `server-${i + 1}`,
|
||||
status: i % 15 === 0 ? "critical" : i % 5 === 0 ? "warning" : "healthy",
|
||||
cpu: Math.round(Math.random() * 100),
|
||||
memory: Math.round(Math.random() * 100),
|
||||
region: ["us-east-1", "eu-west-1", "ap-southeast-1"][i % 3],
|
||||
description: `Production server ${i + 1} running service-${["auth", "payment", "user", "api"][i % 4]}`,
|
||||
lastAlert: i % 15 === 0 ? `Disk usage at ${90 + (i % 10)}%` : null,
|
||||
}));
|
||||
|
||||
const messages = [
|
||||
{ role: "user", content: "Check the fleet status", timestamp: Date.now() },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "tu_fleet", name: "getFleetStatus", input: {} },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
content: JSON.stringify(serverData),
|
||||
tool_use_id: "tu_fleet",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ role: "user", content: "Which servers are critical?", timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const result = await engine.assemble({
|
||||
sessionId: "test-session",
|
||||
messages,
|
||||
model: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
console.log(
|
||||
` assemble(): estimatedTokens=${result.estimatedTokens}, ` +
|
||||
`systemPrompt=${result.systemPromptAddition ? "yes" : "no"}`,
|
||||
);
|
||||
|
||||
// Messages should be returned (compressed or not)
|
||||
expect(result.messages.length).toBeGreaterThan(0);
|
||||
// First and last messages should still be user messages
|
||||
expect(result.messages[0].role).toBe("user");
|
||||
expect(result.messages[result.messages.length - 1].role).toBe("user");
|
||||
});
|
||||
|
||||
it("assemble() preserves small conversations", { timeout: 15000 }, async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "Hello", timestamp: Date.now() },
|
||||
{ role: "assistant", content: "Hi there!", timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const result = await engine.assemble({
|
||||
sessionId: "test-session",
|
||||
messages,
|
||||
});
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].content).toBe("Hello");
|
||||
expect(result.messages[1].content).toBe("Hi there!");
|
||||
});
|
||||
|
||||
it("compact() returns success (compression handled in assemble)", async () => {
|
||||
const result = await engine.compact({
|
||||
sessionId: "test-session",
|
||||
sessionFile: "/tmp/test.jsonl",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(true);
|
||||
});
|
||||
|
||||
it("getStats() returns compression statistics", () => {
|
||||
const stats = engine.getStats();
|
||||
expect(stats).toHaveProperty("totalCompressions");
|
||||
expect(stats).toHaveProperty("totalTokensSaved");
|
||||
expect(stats.totalCompressions).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,18 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
|
|||
assert ["openclaw", "gateway", "restart"] in cmds
|
||||
assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
|
||||
|
||||
config_set_index = next(
|
||||
i
|
||||
for i, cmd in enumerate(cmds)
|
||||
if cmd[:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
|
||||
)
|
||||
install_index = next(
|
||||
i
|
||||
for i, cmd in enumerate(cmds)
|
||||
if cmd[:4] == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"]
|
||||
)
|
||||
assert config_set_index < install_index
|
||||
|
||||
# Verify plugin install in npm mode does not set cwd
|
||||
install_call = next(
|
||||
c
|
||||
|
|
@ -91,6 +103,7 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
|
|||
assert payload["config"]["autoStart"] is True
|
||||
assert payload["config"]["startupTimeoutMs"] == 20000
|
||||
assert payload["config"]["gatewayProviderIds"] == ["openai-codex"]
|
||||
assert payload["config"]["pythonPath"] == wrap_cli.sys.executable
|
||||
|
||||
|
||||
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
|
|||
assert payload["config"]["gatewayProviderIds"] == ["openai-codex", "anthropic"]
|
||||
|
||||
|
||||
def test_normalize_openclaw_gateway_provider_ids_dedupes_blanks_and_defaults() -> None:
|
||||
assert wrap_cli._normalize_openclaw_gateway_provider_ids(
|
||||
(" openai-codex ", "", "anthropic", "openai-codex", " ")
|
||||
) == ["openai-codex", "anthropic"]
|
||||
assert wrap_cli._normalize_openclaw_gateway_provider_ids(None) == ["openai-codex"]
|
||||
|
||||
|
||||
def test_read_openclaw_config_value_handles_missing_and_raw_strings() -> None:
|
||||
missing = MagicMock(returncode=1, stdout="", stderr="missing")
|
||||
raw_string = MagicMock(returncode=0, stdout="plain-text-value\n", stderr="")
|
||||
|
||||
with patch("headroom.cli.wrap.subprocess.run", side_effect=[missing, raw_string]):
|
||||
assert wrap_cli._read_openclaw_config_value("openclaw", "plugins.entries.headroom") is None
|
||||
assert (
|
||||
wrap_cli._read_openclaw_config_value(
|
||||
"openclaw", "plugins.entries.headroom.config.pythonPath"
|
||||
)
|
||||
== "plain-text-value"
|
||||
)
|
||||
|
||||
|
||||
def test_build_openclaw_plugin_entry_sets_and_clears_python_path() -> None:
|
||||
with_python = wrap_cli._build_openclaw_plugin_entry(
|
||||
existing_entry={"config": {"customFlag": True}},
|
||||
proxy_port=8787,
|
||||
startup_timeout_ms=20000,
|
||||
python_path="C:\\Python312\\python.exe",
|
||||
no_auto_start=False,
|
||||
gateway_provider_ids=("openai-codex",),
|
||||
enabled=True,
|
||||
)
|
||||
assert with_python["config"]["pythonPath"] == "C:\\Python312\\python.exe"
|
||||
|
||||
without_python = wrap_cli._build_openclaw_plugin_entry(
|
||||
existing_entry={"config": {"pythonPath": "C:\\Old\\python.exe", "customFlag": True}},
|
||||
proxy_port=8787,
|
||||
startup_timeout_ms=20000,
|
||||
python_path=None,
|
||||
no_auto_start=False,
|
||||
gateway_provider_ids=("openai-codex",),
|
||||
enabled=True,
|
||||
)
|
||||
assert "pythonPath" not in without_python["config"]
|
||||
assert without_python["config"]["customFlag"] is True
|
||||
|
||||
|
||||
def test_wrap_openclaw_no_auto_start_does_not_default_python_path(
|
||||
runner: CliRunner, plugin_dir: Path
|
||||
) -> None:
|
||||
calls: list[dict] = []
|
||||
|
||||
def which(name: str) -> str | None:
|
||||
return {"openclaw": "openclaw", "npm": "npm"}.get(name)
|
||||
|
||||
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
|
||||
with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"wrap",
|
||||
"openclaw",
|
||||
"--plugin-path",
|
||||
str(plugin_dir),
|
||||
"--skip-build",
|
||||
"--no-auto-start",
|
||||
"--no-restart",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
set_entry = next(
|
||||
c
|
||||
for c in calls
|
||||
if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
|
||||
)
|
||||
payload = json.loads(set_entry["cmd"][4])
|
||||
assert payload["config"]["autoStart"] is False
|
||||
assert "pythonPath" not in payload["config"]
|
||||
|
||||
|
||||
def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback(
|
||||
runner: CliRunner,
|
||||
) -> None:
|
||||
|
|
@ -571,3 +664,37 @@ def test_unwrap_openclaw_no_restart_skips_gateway_restart(runner: CliRunner) ->
|
|||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert ["openclaw", "gateway", "restart"] not in [c["cmd"] for c in calls]
|
||||
|
||||
|
||||
def test_unwrap_openclaw_fails_when_openclaw_missing(runner: CliRunner) -> None:
|
||||
with patch("headroom.cli.wrap.shutil.which", return_value=None):
|
||||
result = runner.invoke(main, ["unwrap", "openclaw"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "'openclaw' not found in PATH" in result.output
|
||||
|
||||
|
||||
def test_unwrap_openclaw_verbose_prints_gateway_and_inspect_output(runner: CliRunner) -> None:
|
||||
def which(name: str) -> str | None:
|
||||
return {"openclaw": "openclaw"}.get(name)
|
||||
|
||||
def run(cmd, **kwargs): # noqa: ANN001
|
||||
if cmd[:4] == ["openclaw", "config", "get", "plugins.entries.headroom"]:
|
||||
return MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"enabled": True, "config": {"proxyPort": 8787}}),
|
||||
stderr="",
|
||||
)
|
||||
if cmd[:3] == ["openclaw", "gateway", "restart"]:
|
||||
return MagicMock(returncode=0, stdout="gateway-restarted", stderr="")
|
||||
if cmd[:3] == ["openclaw", "plugins", "inspect"]:
|
||||
return MagicMock(returncode=0, stdout="inspect-disabled", stderr="")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
|
||||
with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
|
||||
result = runner.invoke(main, ["unwrap", "openclaw", "--verbose"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "gateway-restarted" in result.output
|
||||
assert "inspect-disabled" in result.output
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import base64
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from headroom.proxy.handlers.openai import _resolve_codex_routing_headers
|
||||
import anyio
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from headroom.proxy.handlers.openai import (
|
||||
OpenAIHandlerMixin,
|
||||
_resolve_codex_routing_headers,
|
||||
)
|
||||
|
||||
|
||||
def _jwt(payload: dict) -> str:
|
||||
|
|
@ -56,3 +66,152 @@ def test_resolve_codex_routing_leaves_regular_openai_bearer_tokens_unchanged():
|
|||
|
||||
assert is_chatgpt is False
|
||||
assert "ChatGPT-Account-ID" not in headers
|
||||
|
||||
|
||||
def test_resolve_codex_routing_returns_none_without_bearer_auth():
|
||||
headers, is_chatgpt = _resolve_codex_routing_headers({})
|
||||
|
||||
assert is_chatgpt is False
|
||||
assert headers == {}
|
||||
|
||||
|
||||
def test_resolve_codex_routing_ignores_non_jwt_bearer_tokens():
|
||||
headers, is_chatgpt = _resolve_codex_routing_headers(
|
||||
{
|
||||
"authorization": "Bearer not-a-jwt",
|
||||
}
|
||||
)
|
||||
|
||||
assert is_chatgpt is False
|
||||
assert headers["authorization"] == "Bearer not-a-jwt"
|
||||
|
||||
|
||||
def test_resolve_codex_routing_ignores_invalid_jwt_payloads():
|
||||
invalid_payload = base64.urlsafe_b64encode(b"not-json").decode("ascii").rstrip("=")
|
||||
token = f"test-header.{invalid_payload}.signature"
|
||||
|
||||
headers, is_chatgpt = _resolve_codex_routing_headers(
|
||||
{
|
||||
"authorization": f"Bearer {token}",
|
||||
}
|
||||
)
|
||||
|
||||
assert is_chatgpt is False
|
||||
assert headers["authorization"] == f"Bearer {token}"
|
||||
|
||||
|
||||
class _DummyMetrics:
|
||||
async def record_request(self, **kwargs): # noqa: ANN003
|
||||
return None
|
||||
|
||||
async def record_failed(self):
|
||||
return None
|
||||
|
||||
|
||||
class _DummyTokenizer:
|
||||
def count_messages(self, messages):
|
||||
return len(messages)
|
||||
|
||||
|
||||
class _ResponseStub:
|
||||
def json(self):
|
||||
return {"usage": {"input_tokens": 2, "output_tokens": 1}}
|
||||
|
||||
|
||||
class _DummyOpenAIHandler(OpenAIHandlerMixin):
|
||||
OPENAI_API_URL = "https://api.openai.com"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rate_limiter = None
|
||||
self.metrics = _DummyMetrics()
|
||||
self.config = SimpleNamespace(optimize=False)
|
||||
self.usage_reporter = None
|
||||
self.openai_provider = SimpleNamespace()
|
||||
self.anthropic_backend = None
|
||||
self.cost_tracker = None
|
||||
self.captured_request: tuple[str, str, dict, dict] | None = None
|
||||
|
||||
async def _next_request_id(self) -> str:
|
||||
return "req-1"
|
||||
|
||||
def _extract_tags(self, headers: dict[str, str]) -> list[str]:
|
||||
return []
|
||||
|
||||
async def _retry_request(self, method: str, url: str, headers: dict, body: dict):
|
||||
self.captured_request = (method, url, headers, body)
|
||||
return _ResponseStub()
|
||||
|
||||
|
||||
def _build_request(body: dict, headers: dict[str, str]) -> Request:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": payload, "more_body": False}
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"scheme": "https",
|
||||
"path": "/v1/responses",
|
||||
"raw_path": b"/v1/responses",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(key.lower().encode("utf-8"), value.encode("utf-8")) for key, value in headers.items()
|
||||
],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("testserver", 443),
|
||||
}
|
||||
return Request(scope, receive)
|
||||
|
||||
|
||||
def test_handle_openai_responses_routes_chatgpt_auth_to_backend_api(monkeypatch):
|
||||
token = _jwt(
|
||||
{
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acct-from-jwt",
|
||||
}
|
||||
}
|
||||
)
|
||||
request = _build_request(
|
||||
{"model": "gpt-5.4", "input": "hello"},
|
||||
{"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
handler = _DummyOpenAIHandler()
|
||||
|
||||
monkeypatch.setattr("headroom.tokenizers.get_tokenizer", lambda model: _DummyTokenizer())
|
||||
|
||||
anyio.run(handler.handle_openai_responses, request)
|
||||
|
||||
assert handler.captured_request is not None
|
||||
method, url, headers, body = handler.captured_request
|
||||
assert method == "POST"
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses"
|
||||
assert headers["ChatGPT-Account-ID"] == "acct-from-jwt"
|
||||
assert body["input"] == "hello"
|
||||
|
||||
|
||||
class _DummyWebSocket:
|
||||
def __init__(self, headers: dict[str, str]):
|
||||
self.headers = headers
|
||||
self.accepted_subprotocol = None
|
||||
|
||||
async def accept(self, subprotocol=None):
|
||||
self.accepted_subprotocol = subprotocol
|
||||
|
||||
|
||||
def test_handle_openai_responses_ws_resolves_codex_routing_headers():
|
||||
class SentinelError(RuntimeError):
|
||||
pass
|
||||
|
||||
handler = _DummyOpenAIHandler()
|
||||
websocket = _DummyWebSocket({"authorization": "Bearer token"})
|
||||
|
||||
with patch.dict(sys.modules, {"websockets": MagicMock()}):
|
||||
with patch(
|
||||
"headroom.proxy.handlers.openai._resolve_codex_routing_headers",
|
||||
side_effect=SentinelError("resolved"),
|
||||
):
|
||||
with pytest.raises(SentinelError, match="resolved"):
|
||||
anyio.run(handler.handle_openai_responses_ws, websocket)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue