Files
pikasTech-HWLAB/scripts/code-agent-chat-smoke.mjs
T
Lyon d7e84f359b feat: gate Code Agent session registry
Implements #317 session-gated read-only runner phase with structured long-lived Codex stdio blockers.
2026-05-23 18:19:58 +08:00

554 lines
21 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import http from "node:http";
import https from "node:https";
import {
handleCodeAgentChat,
validateCodeAgentChatSchema
} from "../internal/cloud/code-agent-chat.mjs";
import {
classifyCodexRunnerCapability,
classifyCodeAgentChatReadiness,
isHttpNon2xxStatus,
summarizeCodeAgentPayload
} from "./src/code-agent-response-contract.mjs";
const defaultLiveUrl = "http://74.48.78.17:16666/";
const defaultLiveMessage = "请用一句话回复 HWLAB Code Agent readiness。";
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`);
}
async function runLocalContractSmoke() {
const completed = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-smoke",
traceId: "trc_code-agent-chat-smoke",
projectId: "prj_code-agent-chat-smoke",
message: "请用一句话说明当前 HWLAB 工作台可以做什么。"
},
{
now: () => "2026-05-22T00:00:00.000Z",
callProvider: async ({ providerPlan }) => ({
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
content: "HWLAB 工作台可以用 Code Agent 对话来整理任务、查看资源并保持硬件变更受控。",
usage: null,
providerTrace: {
source: "schema-smoke-provider"
}
})
}
);
validateCodeAgentChatSchema(completed);
assert.equal(completed.conversationId, "cnv_code-agent-chat-smoke");
assert.equal(completed.sessionId, "cnv_code-agent-chat-smoke");
assert.match(completed.messageId, /^msg_/);
assert.equal(completed.status, "completed");
assert.equal(completed.createdAt, "2026-05-22T00:00:00.000Z");
assert.equal(completed.updatedAt, "2026-05-22T00:00:00.000Z");
assert.equal(completed.traceId, "trc_code-agent-chat-smoke");
assert.equal(completed.error, undefined);
assert.match(completed.reply.content, /Code Agent/);
logOk("completed schema");
const sourceReadiness = classifyCodeAgentChatReadiness(completed, { realDevLive: false });
assert.equal(sourceReadiness.status, "blocked");
assert.equal(sourceReadiness.level, "SOURCE");
assert.equal(sourceReadiness.devLiveReplyPass, false);
logOk("local stub completion is not #143 DEV-LIVE pass");
const echoReadiness = classifyCodeAgentChatReadiness({
...completed,
provider: "echo-mock",
model: "mock-model",
backend: "hwlab-cloud-api/echo-mock"
}, { 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");
const failed = await handleCodeAgentChat(
{
traceId: "trc_code-agent-chat-smoke-failed",
message: "你好"
},
{
now: () => "2026-05-22T00:01:00.000Z",
env: {
PATH: "",
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
HWLAB_CODE_AGENT_MODEL: "gpt-test"
}
}
);
validateCodeAgentChatSchema(failed);
assert.match(failed.conversationId, /^cnv_/);
assert.match(failed.sessionId, /^cnv_/);
assert.match(failed.messageId, /^msg_/);
assert.equal(failed.status, "failed");
assert.equal(failed.createdAt, "2026-05-22T00:01:00.000Z");
assert.equal(failed.updatedAt, "2026-05-22T00:01:00.000Z");
assert.equal(failed.traceId, "trc_code-agent-chat-smoke-failed");
assert.equal(failed.provider, "codex-cli");
assert.equal(failed.model, "gpt-test");
assert.equal(failed.backend, "hwlab-cloud-api/codex-cli");
assert.equal(failed.error.code, "provider_unavailable");
assert.match(failed.error.message, /Codex CLI command is not available/);
assert.deepEqual(failed.error.missingCommands, ["codex"]);
assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY"));
assert.equal(failed.availability.status, "available");
assert.equal(failed.availability.blocker, null);
assert.equal(failed.availability.reason, null);
assert.equal(failed.availability.runner.ready, true);
assert.match(failed.availability.summary, /受控只读 runner/u);
assert.match(failed.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
assert.equal(JSON.stringify(failed).includes("sk-"), false);
assert.equal(Object.hasOwn(failed, "reply"), false);
logOk("failed provider-gap schema");
const credentialReadiness = classifyCodeAgentChatReadiness(failed, { realDevLive: true });
assert.equal(credentialReadiness.status, "blocked");
assert.equal(credentialReadiness.level, "BLOCKED/credential");
assert.equal(credentialReadiness.blocker, "credential");
assert.equal(credentialReadiness.devLiveReplyPass, false);
logOk("provider credential blocker readiness");
const upstreamBlocked = classifyCodeAgentChatReadiness({
...failed,
error: {
code: "provider_unavailable",
message: "OpenAI Responses returned HTTP 502: request rejected",
providerStatus: 502,
missingEnv: []
},
availability: {
status: "available"
}
}, { realDevLive: true, httpStatus: 200 });
assert.equal(upstreamBlocked.status, "blocked");
assert.equal(upstreamBlocked.level, "BLOCKED/provider");
assert.equal(upstreamBlocked.blocker, "provider-upstream");
assert.equal(upstreamBlocked.providerStatus, 502);
assert.equal(upstreamBlocked.devLiveReplyPass, false);
logOk("provider 502 blocker readiness");
const completedLivePayload = {
...completed,
provider: "openai-responses",
model: "gpt-5.5",
backend: "hwlab-cloud-api/openai-responses"
};
const runnerPwd = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-runner-pwd",
traceId: "trc_code-agent-chat-runner-pwd",
message: "用pwd列出你当前的工作目录"
},
{
now: () => "2026-05-22T00:02:00.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(runnerPwd);
assert.equal(runnerPwd.status, "completed");
assert.equal(runnerPwd.provider, "codex-readonly-runner");
assert.equal(runnerPwd.workspace, process.cwd());
assert.equal(runnerPwd.sandbox, "read-only");
assert.equal(runnerPwd.capabilityLevel, "read-only-session-tools");
assert.equal(runnerPwd.session.status, "idle");
assert.equal(runnerPwd.session.workspace, process.cwd());
assert.equal(runnerPwd.session.sandbox, "read-only");
assert.equal(runnerPwd.session.idleTimeoutMs, 1800000);
assert.equal(runnerPwd.session.lastTraceId, "trc_code-agent-chat-runner-pwd");
assert.equal(runnerPwd.sessionMode, "controlled-readonly-session-registry");
assert.equal(runnerPwd.implementationType, "controlled-readonly-session-registry");
assert.equal(runnerPwd.runner.session, "controlled-readonly-session-registry");
assert.equal(runnerPwd.runner.codexStdio, false);
assert.equal(runnerPwd.runner.writeCapable, false);
assert.equal(runnerPwd.runner.durableSession, false);
assert.equal(runnerPwd.sessionReuse.reused, false);
assert.equal(runnerPwd.sessionReuse.turn, 1);
assert.equal(runnerPwd.sessionReuse.status, "idle");
assert.ok(runnerPwd.runnerLimitations.includes("not-codex-stdio"));
assert.equal(runnerPwd.codexStdioFeasibility.status, "blocked");
assert.equal(runnerPwd.longLivedSessionGate.status, "blocked");
assert.equal(runnerPwd.longLivedSessionGate.pass, false);
assert.ok(runnerPwd.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired"));
assert.equal(runnerPwd.toolCalls[0].name, "pwd");
assert.equal(runnerPwd.toolCalls[0].status, "completed");
const runnerCapability = classifyCodexRunnerCapability(runnerPwd, { httpStatus: 200 });
assert.equal(runnerCapability.status, "pass");
assert.equal(runnerCapability.capabilityPass, true);
assert.equal(runnerCapability.longLivedSession, false);
logOk("read-only runner pwd capability");
const runnerSecondTurn = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-runner-pwd",
traceId: "trc_code-agent-chat-runner-session-reuse",
message: "ls ."
},
{
now: () => "2026-05-22T00:02:01.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(runnerSecondTurn);
assert.equal(runnerSecondTurn.status, "completed");
assert.equal(runnerSecondTurn.sessionId, runnerPwd.sessionId);
assert.equal(runnerSecondTurn.session.sessionId, runnerPwd.session.sessionId);
assert.equal(runnerSecondTurn.sessionReuse.reused, true);
assert.equal(runnerSecondTurn.sessionReuse.turn, 2);
assert.equal(runnerSecondTurn.session.lastTraceId, "trc_code-agent-chat-runner-session-reuse");
assert.equal(runnerSecondTurn.toolCalls[0].name, "ls");
assert.equal(runnerSecondTurn.toolCalls[0].status, "completed");
assert.match(runnerSecondTurn.reply.content, /package\.json/u);
logOk("same conversation reuses controlled read-only session registry");
const fallbackCapability = classifyCodexRunnerCapability(completedLivePayload, { httpStatus: 200 });
assert.equal(fallbackCapability.status, "blocked");
assert.equal(fallbackCapability.blocker, "openai-fallback-not-runner");
assert.equal(fallbackCapability.capabilityPass, false);
logOk("OpenAI fallback does not satisfy Codex runner capability");
const missingSkills = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-missing-skills",
traceId: "trc_code-agent-chat-missing-skills",
message: "列出你可用的skills"
},
{
now: () => "2026-05-22T00:03:00.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
HWLAB_CODE_AGENT_SKILLS_DIRS: "/tmp/hwlab-code-agent-smoke-missing-skills"
},
skillsDirs: ["/tmp/hwlab-code-agent-smoke-missing-skills"],
skillsDirsExact: true
}
);
validateCodeAgentChatSchema(missingSkills);
assert.equal(missingSkills.status, "failed");
assert.equal(missingSkills.error.code, "skills_unavailable");
assert.equal(missingSkills.skills.status, "blocked");
assert.equal(missingSkills.skills.blockers[0].sourceIssue, "pikasTech/HWLAB#136");
assert.equal(classifyCodexRunnerCapability(missingSkills, { httpStatus: 200 }).capabilityPass, false);
logOk("missing skills is structured blocker");
const boundedCat = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-cat",
traceId: "trc_code-agent-chat-cat",
message: "请用cat读取 package.json"
},
{
now: () => "2026-05-22T00:04:00.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(boundedCat);
assert.equal(boundedCat.status, "completed");
assert.equal(boundedCat.toolCalls[0].name, "cat");
assert.equal(boundedCat.toolCalls[0].status, "completed");
assert.ok(boundedCat.toolCalls[0].stdout.length <= 4100);
assert.match(boundedCat.reply.content, /"name": "hwlab"/u);
logOk("bounded cat read-only tool capability");
const boundedRgFiles = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-rg-files",
traceId: "trc_code-agent-chat-rg-files",
message: "rg --files ."
},
{
now: () => "2026-05-22T00:04:30.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(boundedRgFiles);
assert.equal(boundedRgFiles.status, "completed");
assert.equal(boundedRgFiles.toolCalls[0].name, "rg --files");
assert.equal(boundedRgFiles.toolCalls[0].status, "completed");
assert.ok(boundedRgFiles.toolCalls[0].stdout.length <= 4100);
assert.match(boundedRgFiles.reply.content, /受控只读 rg --files 结果/u);
logOk("bounded rg --files read-only tool capability");
const toolUnavailable = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-tool-unavailable",
traceId: "trc_code-agent-chat-tool-unavailable",
message: "请用grep搜索 package.json"
},
{
now: () => "2026-05-22T00:04:40.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(toolUnavailable);
assert.equal(toolUnavailable.status, "failed");
assert.equal(toolUnavailable.error.code, "tool_unavailable");
assert.equal(toolUnavailable.toolCalls[0].status, "blocked");
assert.equal(classifyCodexRunnerCapability(toolUnavailable, { httpStatus: 200 }).capabilityPass, false);
logOk("tool unavailable is blocked, not completed runner capability");
const securityBlocked = await handleCodeAgentChat(
{
conversationId: "cnv_code-agent-chat-security-blocked",
traceId: "trc_code-agent-chat-security-blocked",
message: "请cat ../secret"
},
{
now: () => "2026-05-22T00:04:50.000Z",
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
}
}
);
validateCodeAgentChatSchema(securityBlocked);
assert.equal(securityBlocked.status, "failed");
assert.equal(securityBlocked.error.code, "security_blocked");
assert.equal(securityBlocked.toolCalls[0].status, "blocked");
assert.equal(classifyCodexRunnerCapability(securityBlocked, { httpStatus: 200 }).capabilityPass, false);
logOk("security blocked path is structured");
const completedHttp200Readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: 200 });
assert.equal(completedHttp200Readiness.status, "pass");
assert.equal(completedHttp200Readiness.devLiveReplyPass, true);
logOk("HTTP 200 completion-looking payload can pass");
for (const status of [400, 401, 403, 429, 500, 502, 503, 504]) {
const readiness = classifyCodeAgentChatReadiness(completedLivePayload, { 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");
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
},
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 };
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 === "--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:16666/]",
notes: [
"Default mode is local schema/readiness contract only and cannot pass #143 DEV-LIVE.",
"--live posts a minimal chat prompt to real DEV and passes only on completed plus non-empty assistant reply.",
"provider_unavailable with missing OPENAI_API_KEY or missing/forbidden HWLAB_CODE_AGENT_OPENAI_BASE_URL is reported as BLOCKED/provider-config."
]
};
}
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)}`;
}