335 lines
11 KiB
JavaScript
335 lines
11 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";
|
|
|
|
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 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, "blocked");
|
|
assert.equal(failed.availability.blocker, "凭证缺口");
|
|
assert.equal(failed.availability.reason, "provider_unavailable");
|
|
assert.match(failed.availability.summary, /真实后端已接入/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");
|
|
|
|
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
|
|
? 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) {
|
|
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),
|
|
...(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 classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStatus = null } = {}) {
|
|
const assistantReply = typeof payload?.reply?.content === "string" ? payload.reply.content.trim() : "";
|
|
if (payload?.status === "completed" && assistantReply) {
|
|
if (realDevLive) {
|
|
return {
|
|
status: "pass",
|
|
level: "#143 DEV-LIVE reply pass",
|
|
blocker: null,
|
|
devLiveReplyPass: true,
|
|
reason: "real DEV /v1/agent/chat completed with a non-empty assistant reply"
|
|
};
|
|
}
|
|
return {
|
|
status: "blocked",
|
|
level: "SOURCE",
|
|
blocker: "not-dev-live",
|
|
devLiveReplyPass: false,
|
|
reason: "mock, fixture, local stub, or source-only completion cannot be counted as #143 DEV-LIVE reply pass"
|
|
};
|
|
}
|
|
|
|
const missingEnv = Array.isArray(payload?.error?.missingEnv) ? payload.error.missingEnv : [];
|
|
if (payload?.status === "failed" && payload.error?.code === "provider_unavailable" && missingEnv.includes("OPENAI_API_KEY")) {
|
|
return {
|
|
status: "blocked",
|
|
level: "BLOCKED/credential",
|
|
blocker: "credential",
|
|
devLiveReplyPass: false,
|
|
reason: "provider_unavailable with missing OPENAI_API_KEY means the provider credential is absent"
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: "blocked",
|
|
level: "BLOCKED",
|
|
blocker: "runtime",
|
|
devLiveReplyPass: false,
|
|
reason: `Code Agent chat did not produce a completed non-empty assistant reply${httpStatus ? ` (HTTP ${httpStatus})` : ""}`
|
|
};
|
|
}
|
|
|
|
function summarizePayload(payload) {
|
|
const assistantReply = typeof payload?.reply?.content === "string" ? payload.reply.content.trim() : "";
|
|
const error = payload?.error
|
|
? {
|
|
code: payload.error.code,
|
|
missingEnv: payload.error.missingEnv,
|
|
missingCommands: payload.error.missingCommands,
|
|
providerStatus: payload.error.providerStatus
|
|
}
|
|
: null;
|
|
return {
|
|
status: payload?.status ?? null,
|
|
provider: payload?.provider ?? null,
|
|
model: payload?.model ?? null,
|
|
backend: payload?.backend ?? null,
|
|
assistantReplyNonEmpty: assistantReply.length > 0,
|
|
assistantReplyLength: assistantReply.length,
|
|
error
|
|
};
|
|
}
|
|
|
|
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 is reported as BLOCKED/credential."
|
|
]
|
|
};
|
|
}
|
|
|
|
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)}`;
|
|
}
|