578 lines
24 KiB
JavaScript
578 lines
24 KiB
JavaScript
#!/usr/bin/env bun
|
|
import assert from "node:assert/strict";
|
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import http from "node:http";
|
|
import https from "node:https";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
handleCodeAgentChat,
|
|
validateCodeAgentChatSchema
|
|
} from "../internal/cloud/code-agent-chat.ts";
|
|
import {
|
|
codexAppServerArgs,
|
|
codexAppServerProviderBaseUrl,
|
|
createCodexStdioSessionManager
|
|
} from "../internal/cloud/codex-stdio-session.ts";
|
|
import {
|
|
classifyCodexRunnerCapability,
|
|
classifyCodeAgentChatReadiness,
|
|
isHttpNon2xxStatus,
|
|
summarizeCodeAgentPayload
|
|
} from "./src/code-agent-response-contract.mjs";
|
|
|
|
const defaultLiveUrl = "http://74.48.78.17:17667/";
|
|
const defaultLiveMessage = "请用一句话回复 HWLAB Code Agent readiness。";
|
|
const defaultLiveTimeoutMs = 45000;
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
|
|
if (args.mode === "help") {
|
|
process.stdout.write(`${JSON.stringify(usage(), null, 2)}\n`);
|
|
} else if (args.mode === "live") {
|
|
const report = await runLiveReadinessSmoke(args);
|
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
process.exitCode = report.status === "pass" ? 0 : 2;
|
|
} else {
|
|
await runLocalContractSmoke();
|
|
}
|
|
|
|
function logOk(name) {
|
|
process.stdout.write(`[code-agent-chat-smoke] ok ${name}\n`);
|
|
}
|
|
|
|
function runnerTraceLabels(payload) {
|
|
return Array.isArray(payload?.runnerTrace?.events)
|
|
? payload.runnerTrace.events.map((event) => event?.label ?? event).filter(Boolean)
|
|
: [];
|
|
}
|
|
|
|
async function runLocalContractSmoke() {
|
|
const echoReadiness = classifyCodeAgentChatReadiness({
|
|
conversationId: "cnv_code-agent-chat-smoke-echo",
|
|
sessionId: "cnv_code-agent-chat-smoke-echo",
|
|
messageId: "msg_code-agent-chat-smoke-echo",
|
|
status: "completed",
|
|
createdAt: "2026-05-22T00:00:00.000Z",
|
|
updatedAt: "2026-05-22T00:00:00.000Z",
|
|
traceId: "trc_code-agent-chat-smoke-echo",
|
|
provider: "echo-mock",
|
|
model: "mock-model",
|
|
backend: "hwlab-cloud-api/echo-mock",
|
|
reply: {
|
|
messageId: "msg_code-agent-chat-smoke-echo",
|
|
role: "assistant",
|
|
content: "mock reply",
|
|
createdAt: "2026-05-22T00:00:00.000Z"
|
|
}
|
|
}, { realDevLive: true, httpStatus: 200 });
|
|
assert.equal(echoReadiness.status, "blocked");
|
|
assert.equal(echoReadiness.level, "BLOCKED/untrusted-completion");
|
|
assert.equal(echoReadiness.blocker, "untrusted-completion");
|
|
assert.equal(echoReadiness.devLiveReplyPass, false);
|
|
assert.match(echoReadiness.reason, /echo\/mock\/stub/u);
|
|
logOk("echo/mock completion is not real DEV-LIVE pass");
|
|
|
|
for (const [index, message] of [
|
|
"列出可以使用的skill",
|
|
"pwd",
|
|
"ls .",
|
|
"rg --files .",
|
|
"请用cat读取 package.json"
|
|
].entries()) {
|
|
const blocked = await handleCodeAgentChat(
|
|
{
|
|
conversationId: `cnv_code-agent-chat-blocked-${index}`,
|
|
traceId: `trc_code-agent-chat-blocked-${index}`,
|
|
message
|
|
},
|
|
{
|
|
now: () => "2026-05-22T00:01:00.000Z",
|
|
env: {
|
|
PATH: "",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex"
|
|
}
|
|
}
|
|
);
|
|
validateCodeAgentChatSchema(blocked);
|
|
assert.equal(blocked.status, "failed");
|
|
assert.equal(blocked.provider, "codex-stdio");
|
|
assert.equal(blocked.backend, "hwlab-cloud-api/codex-app-server-stdio");
|
|
assert.equal(blocked.error.code, "codex_cli_binary_missing");
|
|
assert.equal(Object.hasOwn(blocked, "reply"), false);
|
|
assert.deepEqual(blocked.toolCalls, []);
|
|
assert.ok(blocked.runnerLimitations.includes("no-controlled-readonly-fallback"));
|
|
assert.ok(blocked.runnerLimitations.includes("no-text-fallback"));
|
|
assert.equal(blocked.availability.ready, false);
|
|
assert.equal(blocked.availability.partialReady, false);
|
|
assert.equal(blocked.runnerTrace.traceId, `trc_code-agent-chat-blocked-${index}`);
|
|
assert.ok(runnerTraceLabels(blocked).includes("request:accepted"));
|
|
assert.ok(runnerTraceLabels(blocked).includes("codex-stdio:blocked"));
|
|
assert.equal(blocked.runnerTrace.lastEvent.errorCode, "codex_cli_binary_missing");
|
|
assert.equal(blocked.runnerTrace.lastEvent.waitingFor, "codex-stdio-readiness");
|
|
assert.equal(JSON.stringify(blocked).includes("sk-"), false);
|
|
}
|
|
logOk("skills/pwd/ls/rg/cat prompts fail with Codex stdio blocker instead of local fallback");
|
|
|
|
const stdioWorkspaceRoot = await mkdtemp(path.join(os.tmpdir(), `hwlab-code-agent-stdio-smoke-${process.pid}-`));
|
|
const stdioWorkspace = path.join(stdioWorkspaceRoot, "workspace");
|
|
const stdioCodexHome = path.join(stdioWorkspaceRoot, "codex-home");
|
|
const stdioSkillsDir = path.join(stdioWorkspaceRoot, "skills");
|
|
await mkdir(stdioWorkspace, { recursive: true });
|
|
await mkdir(path.join(stdioSkillsDir, "aaa-stdio-smoke-skill"), { recursive: true });
|
|
await mkdir(stdioCodexHome, { recursive: true });
|
|
await writeFile(path.join(stdioWorkspace, "README.md"), "stdio smoke workspace\n");
|
|
await writeFile(path.join(stdioCodexHome, "config.toml"), "model = \"gpt-test\"\n");
|
|
await writeFile(path.join(stdioCodexHome, "auth.json"), "{\"auth\":\"redacted-test-placeholder\"}\n");
|
|
await writeFile(path.join(stdioSkillsDir, "aaa-stdio-smoke-skill", "SKILL.md"), [
|
|
"---",
|
|
"name: aaa-stdio-smoke-skill",
|
|
"description: Stdio smoke skill summary.",
|
|
"---",
|
|
"",
|
|
"# Stdio Smoke"
|
|
].join("\n"));
|
|
let fakeCodex = null;
|
|
try {
|
|
fakeCodex = await createFakeCodexCommand();
|
|
const rpcCalls = [];
|
|
const stdioManager = createCodexStdioSessionManager({
|
|
idFactory: () => "ses_code_agent_chat_smoke_stdio",
|
|
createRpcClient: async () => createFakeAppServerClient({ rpcCalls })
|
|
});
|
|
const stdioEnv = {
|
|
PATH: process.env.PATH,
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
CODEX_HOME: stdioCodexHome,
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: stdioWorkspace,
|
|
HWLAB_CODE_AGENT_CODEX_SANDBOX: "workspace-write",
|
|
HWLAB_CODE_AGENT_SKILLS_DIRS: stdioSkillsDir,
|
|
HWLAB_CODE_AGENT_SKILLS_STRICT: "1",
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
|
};
|
|
const stdioPwd = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_code-agent-chat-stdio",
|
|
traceId: "trc_code-agent-chat-stdio-pwd",
|
|
message: "列出可以使用的skill"
|
|
},
|
|
{
|
|
now: () => "2026-05-22T00:02:10.000Z",
|
|
codexStdioManager: stdioManager,
|
|
env: stdioEnv,
|
|
skillsDirs: [stdioSkillsDir],
|
|
skillsDirsExact: true
|
|
}
|
|
);
|
|
validateCodeAgentChatSchema(stdioPwd);
|
|
assert.equal(stdioPwd.status, "completed");
|
|
assert.equal(stdioPwd.provider, "codex-stdio");
|
|
assert.equal(stdioPwd.backend, "hwlab-cloud-api/codex-app-server-stdio");
|
|
assert.equal(stdioPwd.runner.kind, "codex-app-server-stdio-runner");
|
|
assert.equal(stdioPwd.sessionMode, "codex-app-server-stdio-long-lived");
|
|
assert.equal(stdioPwd.implementationType, "repo-owned-codex-app-server-stdio-session");
|
|
assert.equal(stdioPwd.capabilityLevel, "long-lived-codex-stdio-session");
|
|
assert.equal(stdioPwd.workspace, stdioWorkspace);
|
|
assert.equal(stdioPwd.sandbox, "workspace-write");
|
|
assert.equal(stdioPwd.runner.codexStdio, true);
|
|
assert.equal(stdioPwd.runner.writeCapable, true);
|
|
assert.equal(stdioPwd.runner.durableSession, true);
|
|
assert.equal(stdioPwd.session.longLivedSession, true);
|
|
assert.equal(stdioPwd.session.codexStdio, true);
|
|
assert.equal(stdioPwd.sessionReuse.reused, false);
|
|
assert.equal(stdioPwd.sessionReuse.turn, 1);
|
|
assert.equal(stdioPwd.codexStdioFeasibility.ready, true);
|
|
assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "wired");
|
|
assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "present");
|
|
assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.workspaceMount.status, "ready");
|
|
assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.codexHome.status, "ready");
|
|
assert.equal(stdioPwd.longLivedSessionGate.status, "pass");
|
|
assert.equal(stdioPwd.longLivedSessionGate.pass, true);
|
|
assert.equal(stdioPwd.providerTrace.toolName, "codex-app-server.thread/start+turn/start");
|
|
assert.equal(stdioPwd.providerTrace.threadId, "thread_code_agent_chat_smoke_stdio");
|
|
assert.equal(Object.hasOwn(stdioPwd.providerTrace, "sidecarOnly"), false);
|
|
const firstTurnCalls = rpcCalls.filter((call) => call.method !== "initialize");
|
|
assert.equal(firstTurnCalls[0].method, "thread/start");
|
|
assert.equal(firstTurnCalls[1].method, "turn/start");
|
|
assert.match(firstTurnCalls[1].args.prompt, /列出可以使用的skill/u);
|
|
assert.ok(stdioPwd.toolCalls.some((call) => call.name === "codex-app-server.thread/start+turn/start" && call.status === "completed"));
|
|
assert.ok(stdioPwd.toolCalls.some((call) => call.name === "skills.discover" && call.status === "completed"));
|
|
assert.equal(stdioPwd.skills.status, "ready");
|
|
assert.ok(stdioPwd.skills.items.some((skill) => skill.name === "aaa-stdio-smoke-skill"));
|
|
assert.equal(stdioPwd.runnerTrace.runnerKind, "codex-app-server-stdio-runner");
|
|
assert.equal(stdioPwd.runnerTrace.sessionMode, "codex-app-server-stdio-long-lived");
|
|
assert.ok(runnerTraceLabels(stdioPwd).includes("session:created"));
|
|
assert.ok(runnerTraceLabels(stdioPwd).includes("prompt:sent"));
|
|
assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex-app-server.thread/start+turn/start:started"));
|
|
assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex-app-server.thread/start+turn/start:output_chunk"));
|
|
assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex-app-server.thread/start+turn/start:completed"));
|
|
assert.ok(stdioPwd.runnerTrace.assistantStreams.some((stream) => stream.label === "assistant:stream" && stream.chunkCount >= 1));
|
|
assert.ok(runnerTraceLabels(stdioPwd).includes("assistant:completed"));
|
|
assert.equal(classifyCodexRunnerCapability(stdioPwd, { httpStatus: 200 }).capabilityPass, true);
|
|
|
|
const stdioSecondTurn = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_code-agent-chat-stdio",
|
|
traceId: "trc_code-agent-chat-stdio-reuse",
|
|
message: "继续复用同一个 session 并 ls ."
|
|
},
|
|
{
|
|
now: () => "2026-05-22T00:02:11.000Z",
|
|
codexStdioManager: stdioManager,
|
|
env: stdioEnv,
|
|
skillsDirs: [stdioSkillsDir],
|
|
skillsDirsExact: true
|
|
}
|
|
);
|
|
validateCodeAgentChatSchema(stdioSecondTurn);
|
|
assert.equal(stdioSecondTurn.status, "completed");
|
|
assert.equal(stdioSecondTurn.provider, "codex-stdio");
|
|
assert.equal(stdioSecondTurn.sessionId, stdioPwd.sessionId);
|
|
assert.equal(stdioSecondTurn.session.sessionId, stdioPwd.session.sessionId);
|
|
assert.equal(stdioSecondTurn.session.threadId, "thread_code_agent_chat_smoke_stdio");
|
|
assert.equal(stdioSecondTurn.sessionReuse.reused, true);
|
|
assert.equal(stdioSecondTurn.sessionReuse.turn, 2);
|
|
assert.equal(stdioSecondTurn.providerTrace.toolName, "codex-app-server.thread/resume+turn/start");
|
|
assert.equal(Object.hasOwn(stdioSecondTurn.providerTrace, "sidecarOnly"), false);
|
|
assert.ok(stdioSecondTurn.toolCalls.some((call) => call.name === "codex-app-server.thread/resume+turn/start" && call.status === "completed"));
|
|
assert.ok(runnerTraceLabels(stdioSecondTurn).includes("session:reused"));
|
|
assert.ok(runnerTraceLabels(stdioSecondTurn).includes("prompt:sent"));
|
|
assert.ok(runnerTraceLabels(stdioSecondTurn).includes("tool:codex-app-server.thread/resume+turn/start:completed"));
|
|
assert.equal(classifyCodexRunnerCapability(stdioSecondTurn, { httpStatus: 200 }).capabilityPass, true);
|
|
assert.equal(JSON.stringify(stdioSecondTurn).includes("test-openai-key-material"), false);
|
|
logOk("codex stdio runner passes long-lived workspace-write session capability");
|
|
} finally {
|
|
await rm(stdioWorkspaceRoot, { recursive: true, force: true });
|
|
if (fakeCodex) {
|
|
await rm(fakeCodex.root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
const completedCodexPayload = {
|
|
conversationId: "cnv_code-agent-chat-stdio-http",
|
|
sessionId: "ses_code-agent-chat-stdio-http",
|
|
messageId: "msg_code-agent-chat-stdio-http",
|
|
status: "completed",
|
|
createdAt: "2026-05-22T00:05:00.000Z",
|
|
updatedAt: "2026-05-22T00:05:00.000Z",
|
|
traceId: "trc_code-agent-chat-stdio-http",
|
|
provider: "codex-stdio",
|
|
model: "gpt-5.5",
|
|
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
|
runner: { kind: "codex-app-server-stdio-runner", codexStdio: true, writeCapable: true, durableSession: true },
|
|
sessionMode: "codex-app-server-stdio-long-lived",
|
|
implementationType: "repo-owned-codex-app-server-stdio-session",
|
|
capabilityLevel: "long-lived-codex-stdio-session",
|
|
reply: {
|
|
messageId: "msg_code-agent-chat-stdio-http",
|
|
role: "assistant",
|
|
content: "real stdio reply",
|
|
createdAt: "2026-05-22T00:05:00.000Z"
|
|
}
|
|
};
|
|
for (const status of [400, 401, 403, 429, 500, 502, 503, 504]) {
|
|
const readiness = classifyCodeAgentChatReadiness(completedCodexPayload, { realDevLive: true, httpStatus: status });
|
|
assert.equal(readiness.status, "blocked", `HTTP ${status} must block`);
|
|
assert.equal(readiness.blocker, "provider-upstream", `HTTP ${status} blocker`);
|
|
assert.equal(readiness.providerStatus, status, `HTTP ${status} providerStatus`);
|
|
assert.equal(readiness.devLiveReplyPass, false, `HTTP ${status} devLiveReplyPass`);
|
|
assert.match(readiness.reason, /HTTP non-2xx|upstream response/u, `HTTP ${status} reason`);
|
|
}
|
|
logOk("non-2xx completion-looking payloads are blocked");
|
|
|
|
const failedProvider401Payload = {
|
|
...completedCodexPayload,
|
|
status: "failed",
|
|
reply: undefined,
|
|
error: {
|
|
code: "codex_stdio_failed",
|
|
providerStatus: 401,
|
|
layer: "runner",
|
|
message: "provider returned HTTP 401"
|
|
},
|
|
availability: {
|
|
status: "codex-stdio-feasible",
|
|
ready: true
|
|
}
|
|
};
|
|
const provider401Readiness = classifyCodeAgentChatReadiness(failedProvider401Payload, { realDevLive: true, httpStatus: 200 });
|
|
assert.equal(provider401Readiness.status, "blocked");
|
|
assert.equal(provider401Readiness.level, "BLOCKED/provider");
|
|
assert.equal(provider401Readiness.blocker, "provider-upstream");
|
|
assert.equal(provider401Readiness.providerStatus, 401);
|
|
assert.equal(provider401Readiness.devLiveReplyPass, false);
|
|
logOk("providerStatus=401 blocks even when readiness fields look feasible");
|
|
|
|
const providerEnv = {
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses",
|
|
OPENAI_API_KEY: "test-openai-key-material"
|
|
};
|
|
assert.equal(codexAppServerProviderBaseUrl(providerEnv), "http://127.0.0.1:49280");
|
|
const providerArgs = codexAppServerArgs(providerEnv).join(" ");
|
|
assert.match(providerArgs, /model_providers\.OpenAI\.base_url="http:\/\/127\.0\.0\.1:49280"/u);
|
|
assert.match(providerArgs, /model_providers\.OpenAI\.name="OpenAI"/u);
|
|
assert.match(providerArgs, /model_providers\.OpenAI\.wire_api="responses"/u);
|
|
assert.match(providerArgs, /model_providers\.OpenAI\.requires_openai_auth=true/u);
|
|
assert.equal(providerArgs.includes("test-openai-key-material"), false);
|
|
logOk("Codex app-server provider override keeps auth material out of argv");
|
|
|
|
process.stdout.write("[code-agent-chat-smoke] passed\n");
|
|
}
|
|
|
|
async function runLiveReadinessSmoke(args) {
|
|
const endpoint = agentChatEndpoint(args.url);
|
|
const traceId = protocolId("trc_code-agent-chat-live");
|
|
const conversationId = protocolId("cnv_code-agent-chat-live");
|
|
let httpStatus = null;
|
|
let payload = null;
|
|
let transportError = null;
|
|
|
|
try {
|
|
const response = await postJson(endpoint, {
|
|
headers: {
|
|
"x-trace-id": traceId
|
|
},
|
|
timeoutMs: args.timeoutMs,
|
|
body: {
|
|
conversationId,
|
|
traceId,
|
|
projectId: "prj_hwlab-cloud-workbench",
|
|
message: args.message
|
|
}
|
|
});
|
|
httpStatus = response.status;
|
|
payload = response.json;
|
|
} catch (error) {
|
|
transportError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
const readiness = payload || isHttpNon2xxStatus(httpStatus)
|
|
? classifyCodeAgentChatReadiness(payload, { realDevLive: true, httpStatus })
|
|
: {
|
|
status: "blocked",
|
|
level: "BLOCKED",
|
|
blocker: "transport",
|
|
devLiveReplyPass: false,
|
|
reason: "real DEV /v1/agent/chat did not return a JSON Code Agent payload"
|
|
};
|
|
|
|
if (payload && readiness.status === "pass") {
|
|
try {
|
|
validateCodeAgentChatSchema(payload);
|
|
} catch (error) {
|
|
readiness.status = "blocked";
|
|
readiness.level = "BLOCKED";
|
|
readiness.blocker = "schema";
|
|
readiness.devLiveReplyPass = false;
|
|
readiness.reason = error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: readiness.status,
|
|
mode: "live-readiness",
|
|
url: endpoint.href,
|
|
httpStatus,
|
|
readiness,
|
|
response: summarizePayload(payload, { httpStatus, traceId }),
|
|
...(transportError ? { transportError } : {}),
|
|
safety: {
|
|
prodTouched: false,
|
|
servicesRestarted: false,
|
|
readsSecretValues: false,
|
|
printsAssistantReply: false,
|
|
statement: "This smoke posts only to the configured DEV Code Agent chat endpoint and prints reply presence, not reply content or secret values."
|
|
}
|
|
};
|
|
}
|
|
|
|
function summarizePayload(payload, options = {}) {
|
|
const responseSummary = summarizeCodeAgentPayload(payload, options);
|
|
const assistantReply = responseSummary?.hasReply === true && typeof payload?.reply?.content === "string"
|
|
? payload.reply.content.trim()
|
|
: "";
|
|
return {
|
|
status: responseSummary?.status ?? null,
|
|
provider: responseSummary?.provider ?? null,
|
|
model: responseSummary?.model ?? null,
|
|
backend: responseSummary?.backend ?? null,
|
|
runner: responseSummary?.runner ?? null,
|
|
workspace: responseSummary?.workspace ?? null,
|
|
sandbox: responseSummary?.sandbox ?? null,
|
|
capabilityLevel: responseSummary?.capabilityLevel ?? null,
|
|
sessionMode: responseSummary?.sessionMode ?? null,
|
|
sessionReuse: responseSummary?.sessionReuse ?? null,
|
|
implementationType: responseSummary?.implementationType ?? null,
|
|
runnerLimitations: responseSummary?.runnerLimitations ?? [],
|
|
toolCalls: responseSummary?.toolCalls ?? [],
|
|
skills: responseSummary?.skills ?? null,
|
|
runnerTrace: responseSummary?.runnerTrace ?? null,
|
|
codexStdioFeasibility: responseSummary?.codexStdioFeasibility ?? null,
|
|
session: responseSummary?.session ?? null,
|
|
longLivedSessionGate: responseSummary?.longLivedSessionGate ?? null,
|
|
assistantReplyNonEmpty: responseSummary?.hasReply === true,
|
|
assistantReplyLength: assistantReply.length,
|
|
error: responseSummary?.error ?? null
|
|
};
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const parsed = {
|
|
mode: "local",
|
|
url: defaultLiveUrl,
|
|
message: defaultLiveMessage,
|
|
timeoutMs: defaultLiveTimeoutMs
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--live") {
|
|
parsed.mode = "live";
|
|
} else if (arg === "--url") {
|
|
index += 1;
|
|
if (!argv[index]) throw new Error("--url requires a value");
|
|
parsed.url = argv[index];
|
|
} else if (arg === "--message") {
|
|
index += 1;
|
|
if (!argv[index]) throw new Error("--message requires a value");
|
|
parsed.message = argv[index];
|
|
} else if (arg === "--timeout-ms") {
|
|
index += 1;
|
|
if (!argv[index]) throw new Error("--timeout-ms requires a value");
|
|
const timeoutMs = Number.parseInt(argv[index], 10);
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
throw new Error("--timeout-ms must be a positive integer");
|
|
}
|
|
parsed.timeoutMs = timeoutMs;
|
|
} else if (arg === "--help") {
|
|
return { mode: "help" };
|
|
} else {
|
|
throw new Error(`unknown argument ${arg}`);
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function usage() {
|
|
return {
|
|
status: "usage",
|
|
command: "node scripts/code-agent-chat-smoke.mjs [--live --url http://74.48.78.17:17667/ --timeout-ms 45000]",
|
|
notes: [
|
|
"Default mode is local schema/readiness contract only and cannot pass #143 DEV-LIVE.",
|
|
"--live posts a minimal chat prompt to the current G14 DEV public endpoint and passes only on completed plus non-empty assistant reply.",
|
|
"Live chat first token can exceed 10s on healthy Codex stdio cold turns; default timeout is 45000ms and can be overridden with --timeout-ms.",
|
|
"provider_unavailable with missing OPENAI_API_KEY or missing/forbidden HWLAB_CODE_AGENT_OPENAI_BASE_URL is reported as BLOCKED/provider-config."
|
|
]
|
|
};
|
|
}
|
|
|
|
async function createFakeCodexCommand() {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-"));
|
|
const packageRoot = path.join(root, "node_modules", "@openai", "codex");
|
|
const command = path.join(packageRoot, "bin", "codex.js");
|
|
const native = path.join(root, "node_modules", "@openai", "codex-linux-x64", "vendor", "x86_64-unknown-linux-musl", "codex", "codex");
|
|
await mkdir(path.dirname(command), { recursive: true });
|
|
await mkdir(path.dirname(native), { recursive: true });
|
|
await writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.128.0" }), "utf8");
|
|
await writeFile(command, "#!/usr/bin/env node\nconsole.log('codex 0.128.0');\n", "utf8");
|
|
await writeFile(native, "#!/bin/sh\nexit 0\n", "utf8");
|
|
await chmod(command, 0o755);
|
|
await chmod(native, 0o755);
|
|
return { root, command };
|
|
}
|
|
|
|
function createFakeAppServerClient({ rpcCalls }) {
|
|
let notificationHandler = null;
|
|
let turn = 0;
|
|
return {
|
|
async initialize() {
|
|
rpcCalls.push({ method: "initialize" });
|
|
return { initialized: true };
|
|
},
|
|
setNotificationHandler(handler) {
|
|
notificationHandler = typeof handler === "function" ? handler : null;
|
|
},
|
|
async startThread(args) {
|
|
rpcCalls.push({ method: "thread/start", args });
|
|
notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_code_agent_chat_smoke_stdio" } } });
|
|
return { threadId: "thread_code_agent_chat_smoke_stdio" };
|
|
},
|
|
async resumeThread(args) {
|
|
rpcCalls.push({ method: "thread/resume", args });
|
|
notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } });
|
|
return { threadId: args.threadId };
|
|
},
|
|
async startTurn(args) {
|
|
rpcCalls.push({ method: "turn/start", args });
|
|
turn += 1;
|
|
const turnId = `turn_code_agent_chat_smoke_${turn}`;
|
|
const text = `codex-app-server fixture reply for ${args.threadId ?? "new-thread"}`;
|
|
notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } });
|
|
notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: `item_${turn}`, delta: text } });
|
|
notificationHandler?.({ method: "item/completed", params: { item: { id: `item_${turn}`, type: "agentMessage", text } } });
|
|
notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } });
|
|
return { turnId };
|
|
},
|
|
close() {}
|
|
};
|
|
}
|
|
|
|
function agentChatEndpoint(value) {
|
|
const url = new URL(value);
|
|
if (url.pathname.endsWith("/v1/agent/chat")) return url;
|
|
return new URL("/v1/agent/chat", url);
|
|
}
|
|
|
|
function postJson(url, { headers = {}, body, timeoutMs = 10000 }) {
|
|
const client = url.protocol === "https:" ? https : http;
|
|
const payload = JSON.stringify(body);
|
|
return new Promise((resolve, reject) => {
|
|
const request = client.request(
|
|
url,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"content-length": Buffer.byteLength(payload),
|
|
...headers
|
|
},
|
|
insecureHTTPParser: true,
|
|
timeout: timeoutMs
|
|
},
|
|
(response) => {
|
|
const chunks = [];
|
|
response.on("data", (chunk) => chunks.push(chunk));
|
|
response.on("end", () => {
|
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
resolve({
|
|
status: response.statusCode ?? null,
|
|
body: text,
|
|
json: parseJsonOrNull(text)
|
|
});
|
|
});
|
|
}
|
|
);
|
|
request.on("timeout", () => request.destroy(new Error(`request timed out after ${timeoutMs}ms`)));
|
|
request.on("error", reject);
|
|
request.end(payload);
|
|
});
|
|
}
|
|
|
|
function parseJsonOrNull(value) {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function protocolId(prefix) {
|
|
return `${prefix}_${Date.now().toString(36)}`;
|
|
}
|