Files
openclaw/scripts/e2e/mock-openai-server.mjs
Ayaan Zaidi 4bd56b8ad8 test(e2e): tag the mock Responses preamble with its wire phase
The mock streamed pre-tool assistant text on the Responses path without `phase: "commentary"`, the field the API sets and the transport reads, so the draft-proof scenario ran green while never exercising the preamble lane on that transport. Proven live on Telegram: the preamble now renders as the status headline, and as a commentary line when progress.commentary is on.
2026-07-30 16:34:38 +09:00

660 lines
19 KiB
JavaScript

// Mock OpenAI-compatible server for broader E2E scenarios.
import { createHash } from "node:crypto";
import http from "node:http";
import { setTimeout as delay } from "node:timers/promises";
import { escapeRegExp } from "../lib/regexp.mjs";
import { readPositiveIntEnv, readTcpPortEnv } from "./lib/env-limits.mjs";
import {
boundedRequestLogBody,
isRequestBodyTooLargeError,
readBody,
writeRequestLogEntryOrFail,
writeJson,
writeSse,
} from "./lib/mock-openai-http.mjs";
const port =
process.env.MOCK_PORT != null
? readTcpPortEnv("MOCK_PORT")
: readTcpPortEnv("OPENCLAW_MOCK_OPENAI_PORT");
const successMarker = process.env.SUCCESS_MARKER ?? "OPENCLAW_E2E_OK";
const requestLog = process.env.MOCK_REQUEST_LOG;
const responseChunkDelayMs = process.env.MOCK_RESPONSE_CHUNK_DELAY_MS
? readPositiveIntEnv("MOCK_RESPONSE_CHUNK_DELAY_MS", undefined)
: 0;
function splitResponseText(text) {
if (text.length < 2) {
return [text];
}
const midpoint = Math.floor(text.length / 2);
const whitespace = text.lastIndexOf(" ", midpoint);
const splitAt = whitespace > 0 ? whitespace : Math.max(1, midpoint);
return [text.slice(0, splitAt), text.slice(splitAt)];
}
function responseEvents(text, deltas = [text]) {
const itemId = "msg_e2e_1";
return [
{
type: "response.output_item.added",
item: {
type: "message",
id: itemId,
role: "assistant",
content: [],
status: "in_progress",
},
},
...deltas.map((delta) => ({
type: "response.output_text.delta",
item_id: itemId,
output_index: 0,
content_index: 0,
delta,
})),
{
type: "response.output_text.done",
item_id: itemId,
output_index: 0,
content_index: 0,
text,
},
{
type: "response.output_item.done",
item: {
type: "message",
id: itemId,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text, annotations: [] }],
},
},
{
type: "response.completed",
response: {
id: "resp_e2e",
status: "completed",
output: [
{
type: "message",
id: itemId,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text, annotations: [] }],
},
],
usage: {
input_tokens: 11,
output_tokens: 7,
total_tokens: 18,
input_tokens_details: { cached_tokens: 0 },
},
},
},
];
}
async function writeDefaultResponseEvents(res, text) {
if (responseChunkDelayMs === 0) {
writeSse(res, responseEvents(text));
return;
}
const events = responseEvents(text, splitResponseText(text));
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-store",
connection: "keep-alive",
});
let deltaCount = 0;
for (const event of events) {
if (event.type === "response.output_text.delta" && deltaCount > 0) {
await delay(responseChunkDelayMs);
}
res.write(`data: ${JSON.stringify(event)}\n\n`);
if (event.type === "response.output_text.delta") {
deltaCount += 1;
}
}
res.write("data: [DONE]\n\n");
res.end();
}
function buildMockFunctionCall(name, args) {
const serialized = JSON.stringify(args);
const suffix = createHash("sha256")
.update(name)
.update("\0")
.update(serialized)
.digest("hex")
.slice(0, 10);
const callId = `call_mock_${name}_${suffix}`;
const itemId = `fc_mock_${name}_${suffix}`;
const item = {
type: "function_call",
id: itemId,
call_id: callId,
name,
arguments: serialized,
};
return {
item,
itemId,
responseId: `resp_mock_${name}_${suffix}`,
serialized,
};
}
// Progress-draft proof: assistant text emitted BEFORE a tool call is tagged as
// commentary, which channels render as the draft's status headline. Streaming
// text and then a call in one response is the only way to exercise
// headline-plus-tool-line composition without a live model.
// The Responses API carries that tag as `phase` on the message item, and the
// transport reads it straight off the item, so an untagged item produces no
// preamble at all and the scenario silently proves nothing.
function preambleThenToolCallEvents(preamble, name, args) {
const messageItemId = "msg_e2e_preamble";
const call = buildMockFunctionCall(name, args);
return [
{
type: "response.output_item.added",
item: {
type: "message",
id: messageItemId,
role: "assistant",
content: [],
status: "in_progress",
},
},
...splitResponseText(preamble).map((delta) => ({
type: "response.output_text.delta",
item_id: messageItemId,
output_index: 0,
content_index: 0,
delta,
})),
{
type: "response.output_text.done",
item_id: messageItemId,
output_index: 0,
content_index: 0,
text: preamble,
},
{
type: "response.output_item.done",
item: {
type: "message",
id: messageItemId,
role: "assistant",
status: "completed",
phase: "commentary",
content: [{ type: "output_text", text: preamble, annotations: [] }],
},
},
{
type: "response.output_item.added",
item: {
type: "function_call",
id: call.itemId,
call_id: call.item.call_id,
name,
arguments: "",
},
},
{ type: "response.function_call_arguments.delta", delta: call.serialized },
{ type: "response.output_item.done", item: call.item },
{
type: "response.completed",
response: {
id: call.responseId,
status: "completed",
output: [
{
type: "message",
id: messageItemId,
role: "assistant",
status: "completed",
phase: "commentary",
content: [{ type: "output_text", text: preamble, annotations: [] }],
},
call.item,
],
usage: {
input_tokens: 64,
output_tokens: 24,
total_tokens: 88,
input_tokens_details: { cached_tokens: 0 },
},
},
},
];
}
/** Two-turn draft scenario: preamble + shell call, then a final answer. */
function progressDraftEvents(body, bodyText) {
const allText = collectText(body).join("\n");
if (!allText.includes("OPENCLAW_E2E_DRAFTPROOF")) {
return null;
}
if (!collectFunctionCallOutputText(body)) {
if (!hasDeclaredTool(bodyText, "exec")) {
return null;
}
return preambleThenToolCallEvents("Checking the workspace before answering.", "exec", {
command: ["bash", "-lc", "sleep 3 && echo openclaw-draft-proof"],
});
}
return responseEvents("OPENCLAW_E2E_DRAFTPROOF");
}
function toolCallEvents(name, args) {
const call = buildMockFunctionCall(name, args);
return [
{
type: "response.output_item.added",
item: {
type: "function_call",
id: call.itemId,
call_id: call.item.call_id,
name,
arguments: "",
},
},
{ type: "response.function_call_arguments.delta", delta: call.serialized },
{ type: "response.output_item.done", item: call.item },
{
type: "response.completed",
response: {
id: call.responseId,
status: "completed",
output: [call.item],
usage: {
input_tokens: 64,
output_tokens: 16,
total_tokens: 80,
input_tokens_details: { cached_tokens: 0 },
},
},
},
];
}
function writeResponsesEvents(res, stream, events) {
if (stream === false) {
const completed = events.find((event) => event.type === "response.completed");
writeJson(res, 200, {
id: completed?.response?.id ?? "resp_e2e",
object: "response",
status: "completed",
output: completed?.response?.output ?? [],
usage: completed?.response?.usage ?? {
input_tokens: 64,
output_tokens: 16,
total_tokens: 80,
},
});
return;
}
writeSse(res, events);
}
function writeChatCompletion(res, stream, text = successMarker) {
if (stream) {
writeSse(res, [
{
id: "chatcmpl_e2e",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { role: "assistant", content: text } }],
},
{
id: "chatcmpl_e2e",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
]);
return;
}
writeJson(res, 200, {
id: "chatcmpl_e2e",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 },
});
}
/** Streams assistant content, then a tool call, in one chat-completions turn. */
function writeChatCompletionPreambleToolCall(res, stream, preamble, name, args) {
const serialized = JSON.stringify(args);
const callId = `call_mock_${name}_${createHash("sha256").update(name).update(serialized).digest("hex").slice(0, 10)}`;
if (!stream) {
writeJson(res, 200, {
id: "chatcmpl_e2e",
object: "chat.completion",
choices: [
{
index: 0,
message: {
role: "assistant",
content: preamble,
tool_calls: [
{ id: callId, type: "function", function: { name, arguments: serialized } },
],
},
finish_reason: "tool_calls",
},
],
usage: { prompt_tokens: 24, completion_tokens: 18, total_tokens: 42 },
});
return;
}
writeSse(res, [
{
id: "chatcmpl_e2e",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { role: "assistant", content: "" } }],
},
...splitResponseText(preamble).map((delta) => ({
id: "chatcmpl_e2e",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { content: delta } }],
})),
{
id: "chatcmpl_e2e",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
tool_calls: [
{ index: 0, id: callId, type: "function", function: { name, arguments: serialized } },
],
},
},
],
},
{
id: "chatcmpl_e2e",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
},
]);
}
function writeImageGeneration(res) {
writeJson(res, 200, {
created: Math.floor(Date.now() / 1000),
data: [
{
b64_json:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yf7kAAAAASUVORK5CYII=",
mime_type: "image/png",
revised_prompt: "openclaw mock image",
},
],
});
}
function resolveResponseText(bodyText) {
const matches = Array.from(bodyText.matchAll(/\bOPENCLAW_E2E_[A-Z0-9]+(?:_[A-Z0-9]+)*\b/gu));
return matches.at(-1)?.[0] ?? successMarker;
}
function collectText(value) {
if (typeof value === "string") {
return [value];
}
if (Array.isArray(value)) {
return value.flatMap((entry) => collectText(entry));
}
if (!value || typeof value !== "object") {
return [];
}
const texts = [];
for (const key of ["text", "content", "output"]) {
if (typeof value[key] === "string") {
texts.push(value[key]);
}
}
for (const nested of Object.values(value)) {
if (nested && typeof nested === "object") {
texts.push(...collectText(nested));
}
}
return texts;
}
function stringifyFunctionCallOutput(output) {
if (typeof output === "string") {
return output;
}
try {
return JSON.stringify(output);
} catch {
return "";
}
}
function collectFunctionCallOutputText(body) {
const input = Array.isArray(body?.input) ? body.input : [];
return input
.filter((item) => item?.type === "function_call_output")
.map((item) => stringifyFunctionCallOutput(item.output))
.filter(Boolean)
.join("\n");
}
function hasDeclaredTool(bodyText, name) {
return new RegExp(`"name"\\s*:\\s*"${escapeRegExp(name)}"`, "u").test(bodyText);
}
function mcpCodeModeApiFileEvents(body, bodyText) {
const allText = collectText(body).join("\n");
if (!/mcp code mode api file qa check/i.test(allText)) {
return null;
}
const toolOutput = collectFunctionCallOutputText(body);
if (!toolOutput) {
if (!hasDeclaredTool(bodyText, "exec")) {
return null;
}
return toolCallEvents("exec", {
language: "javascript",
code: [
'const files = await API.list("mcp");',
'const root = await API.read("mcp/index.d.ts");',
'const api = await API.read("mcp/fixture.d.ts");',
'const result = await MCP.fixture.lookupNote({ id: "alpha" });',
"return {",
' marker: "MCP_CODE_MODE_FILE_TOOL_RESULT",',
" files: files.files.map((file) => file.path),",
" rootHasFixture: root.content.includes('fixture'),",
" headerHasLookup: api.content.includes('function lookupNote'),",
" resultText: result.content?.[0]?.text,",
" allHasMcp: ALL_TOOLS.some((tool) => tool.source === 'mcp'),",
"};",
].join("\n"),
});
}
if (
!/MCP_CODE_MODE_FILE_TOOL_RESULT/.test(toolOutput) ||
!/fixture-note-alpha/.test(toolOutput)
) {
return responseEvents(
"MCP_CODE_MODE_FILE_FAIL unclear=code-mode-exec-did-not-return-fixture-note",
);
}
return responseEvents(
"MCP_CODE_MODE_FILE_OK note=fixture-note-alpha unclear=none improvement=virtual-api-files-were-clear-and-needed-one-exec",
);
}
function mcpAppConformanceEvents(body, bodyText) {
const allText = collectText(body).join("\n");
if (!/mcp app conformance qa check/i.test(allText)) {
return null;
}
const toolOutput = collectFunctionCallOutputText(body);
if (!toolOutput) {
if (!hasDeclaredTool(bodyText, "fixture__show")) {
return null;
}
return toolCallEvents("fixture__show", {});
}
return /initial-result/.test(toolOutput)
? responseEvents("MCP_APP_CONFORMANCE_READY")
: responseEvents("MCP_APP_CONFORMANCE_FAIL");
}
const server = http.createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && url.pathname === "/health") {
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, {
object: "list",
data: [{ id: "gpt-5.6-luna", object: "model", owned_by: "openclaw-e2e" }],
});
return;
}
let bodyText;
try {
bodyText = await readBody(req);
} catch (error) {
if (isRequestBodyTooLargeError(error)) {
writeJson(res, 413, { error: { message: error.message } });
return;
}
throw error;
}
let body;
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
if (
writeRequestLogEntryOrFail(res, {
requestLog,
entry: {
method: req.method,
path: url.pathname,
body: boundedRequestLogBody(bodyText, bodyText),
},
})
) {
return;
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
const appEvents = mcpAppConformanceEvents(body, bodyText);
if (appEvents) {
writeResponsesEvents(res, body.stream, appEvents);
return;
}
const codeModeEvents = mcpCodeModeApiFileEvents(body, bodyText);
if (codeModeEvents) {
writeResponsesEvents(res, body.stream, codeModeEvents);
return;
}
const draftEvents = progressDraftEvents(body, bodyText);
if (draftEvents) {
writeResponsesEvents(res, body.stream, draftEvents);
return;
}
const responseText = resolveResponseText(bodyText);
if (body.stream === false) {
writeJson(res, 200, {
id: "resp_e2e",
object: "response",
status: "completed",
output: [
{
type: "message",
id: "msg_e2e_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: responseText, annotations: [] }],
},
],
usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18 },
});
return;
}
await writeDefaultResponseEvents(res, responseText);
return;
}
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
// Progress-draft proof needs assistant content followed by a tool call in
// one streamed turn: the completions transport tags that leading text as
// commentary, which channels render as the draft status headline.
if (bodyText.includes("OPENCLAW_E2E_DRAFTPROOF")) {
const messages = Array.isArray(body.messages) ? body.messages : [];
const toolTurnDone = messages.some((message) => message?.role === "tool");
if (!toolTurnDone) {
writeChatCompletionPreambleToolCall(
res,
body.stream !== false,
"Checking the workspace before answering.",
"exec",
{ command: ["bash", "-lc", "sleep 3 && echo openclaw-draft-proof"] },
);
return;
}
// Hold the final answer so the turn outlives the progress-draft start
// gate. Without this the whole turn finishes in well under a second and
// no draft is created, which is correct behavior but proves nothing.
await delay(readPositiveIntEnv("MOCK_DRAFTPROOF_FINAL_DELAY_MS", 6000));
writeChatCompletion(res, body.stream !== false, "OPENCLAW_E2E_DRAFTPROOF");
return;
}
const responseText = resolveResponseText(bodyText);
writeChatCompletion(res, body.stream !== false, responseText);
return;
}
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
const input = Array.isArray(body.input) ? body.input : [body.input ?? ""];
writeJson(res, 200, {
object: "list",
data: input.map((_, index) => ({
object: "embedding",
index,
embedding: [1, index / 100, 0, 0],
})),
model: body.model ?? "text-embedding-3-small",
usage: { prompt_tokens: input.length, total_tokens: input.length },
});
return;
}
if (
req.method === "POST" &&
(url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
) {
writeImageGeneration(res);
return;
}
writeJson(res, 404, {
error: { message: `unhandled mock route: ${req.method} ${url.pathname}` },
});
})().catch((/** @type {unknown} */ error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`mock-openai request handler failed: ${message}`);
if (!res.headersSent) {
writeJson(res, 500, { error: { message: `mock OpenAI handler failed: ${message}` } });
return;
}
res.destroy(error instanceof Error ? error : new Error(message));
});
});
server.listen(port, "127.0.0.1", () => {
console.log(`mock-openai listening on ${port}`);
});