import { spawn } from "node:child_process"; import { createServer, type Server } from "node:http"; import path from "node:path"; import { describe, expect, it } from "vitest"; const clientPath = path.resolve("scripts/e2e/lib/openai-chat-tools/client.mjs"); interface ClientResult { error?: Error; signal: NodeJS.Signals | null; status: number | null; stderr: string; stdout: string; } async function listen(server: Server): Promise { await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); const address = server.address(); if (!address || typeof address === "string") { throw new Error("test server did not expose a TCP port"); } return address.port; } function runClient( port: number, env: Record = {}, timeout = 5_000, ): Promise { return new Promise((resolve) => { const child = spawn(process.execPath, [clientPath], { env: { ...process.env, MODEL_REF: "openai/gpt-5.4-mini", OPENCLAW_GATEWAY_TOKEN: "test-token", OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS: "1", PORT: String(port), ...env, }, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; let timedOut = false; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeout); child.on("error", (error) => { clearTimeout(timer); resolve({ error, signal: null, status: null, stderr, stdout }); }); child.on("exit", (status, signal) => { clearTimeout(timer); resolve({ error: timedOut ? new Error(`client timed out after ${timeout}ms`) : undefined, signal, status, stderr, stdout, }); }); }); } function toolCallResponse() { return { choices: [ { finish_reason: "tool_calls", message: { tool_calls: [ { type: "function", function: { name: "get_weather", arguments: JSON.stringify({ city: "Paris, France" }), }, }, ], }, }, ], }; } describe("scripts/e2e/lib/openai-chat-tools/client.mjs", () => { it("accepts a matching chat completions tool call response", async () => { const server = createServer((request, response) => { expect(request.method).toBe("POST"); expect(request.url).toBe("/v1/chat/completions"); expect(request.headers.authorization).toBe("Bearer test-token"); expect(request.headers["x-openclaw-model"]).toBe("openai/gpt-5.4-mini"); response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify(toolCallResponse())); }); const port = await listen(server); try { const result = await runClient(port); expect(result.status).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ args: { city: "Paris, France" }, finishReason: "tool_calls", ok: true, toolName: "get_weather", }); } finally { server.close(); } }); it("keeps the request timeout active while reading the response body", async () => { const server = createServer((_request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.write('{"choices":'); }); const port = await listen(server); const startedAt = Date.now(); try { const result = await runClient(port, {}, 4_000); const elapsedMs = Date.now() - startedAt; expect(result.error).toBeUndefined(); expect(result.status).not.toBe(0); expect(result.stderr).toMatch(/aborted|AbortError/iu); expect(elapsedMs).toBeLessThan(3_500); } finally { server.close(); } }); it("caps chat completion response bodies before JSON parsing", async () => { const server = createServer((_request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end("x".repeat(256)); }); const port = await listen(server); try { const result = await runClient(port, { OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES: "64" }); expect(result.status).not.toBe(0); expect(result.stderr).toContain("chat completions response body exceeded 64 bytes"); } finally { server.close(); } }); });