fix: integrate Code Agent readiness state
fix: integrate Code Agent readiness state
This commit is contained in:
@@ -10,6 +10,7 @@ const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000;
|
||||
const DEFAULT_CODEX_COMMAND = "codex";
|
||||
const DEFAULT_MODEL = "gpt-5.2";
|
||||
const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench";
|
||||
const CODE_AGENT_PROVIDER_SECRET_REF = "hwlab-code-agent-provider/openai-api-key";
|
||||
const CODE_AGENT_SYSTEM_PROMPT = [
|
||||
"你是 HWLAB 云工作台的 Code Agent。",
|
||||
"请用中文直接回答用户的工作台问题。",
|
||||
@@ -52,6 +53,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
now: options.now,
|
||||
callProvider: options.callProvider
|
||||
});
|
||||
const content = typeof providerResult.content === "string" ? providerResult.content.trim() : "";
|
||||
if (!content) {
|
||||
throw providerUnavailable("Code Agent provider returned no assistant text", {
|
||||
provider: providerResult.provider ?? base.provider,
|
||||
model: providerResult.model ?? base.model,
|
||||
backend: providerResult.backend ?? base.backend
|
||||
});
|
||||
}
|
||||
const completedAt = nowIso(options.now);
|
||||
return {
|
||||
...base,
|
||||
@@ -63,7 +72,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
reply: {
|
||||
messageId,
|
||||
role: "assistant",
|
||||
content: providerResult.content,
|
||||
content,
|
||||
createdAt: completedAt
|
||||
},
|
||||
usage: providerResult.usage ?? null,
|
||||
@@ -71,12 +80,16 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
};
|
||||
} catch (error) {
|
||||
const failedAt = nowIso(options.now);
|
||||
return {
|
||||
const payload = {
|
||||
...base,
|
||||
status: "failed",
|
||||
updatedAt: failedAt,
|
||||
error: normalizeChatError(error)
|
||||
};
|
||||
if (error.code === "provider_unavailable") {
|
||||
payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,11 +120,64 @@ export function validateCodeAgentChatSchema(payload) {
|
||||
if (payload.status === "failed" && typeof payload.error?.message !== "string") {
|
||||
throw new Error("failed code agent chat response must include error.message");
|
||||
}
|
||||
if (payload.status === "completed" && typeof payload.reply?.content !== "string") {
|
||||
throw new Error("completed code agent chat response must include reply.content");
|
||||
if (payload.status === "completed" && (typeof payload.reply?.content !== "string" || !payload.reply.content.trim())) {
|
||||
throw new Error("completed code agent chat response must include non-empty reply.content");
|
||||
}
|
||||
}
|
||||
|
||||
export function describeCodeAgentAvailability(env = process.env, options = {}) {
|
||||
const providerPlan = resolveProviderPlan(env, options);
|
||||
const missingEnv = [];
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
missingEnv.push("OPENAI_API_KEY");
|
||||
}
|
||||
|
||||
const blocked = missingEnv.length > 0;
|
||||
return {
|
||||
endpoint: "POST /v1/agent/chat",
|
||||
provider: providerPlan.provider,
|
||||
model: providerPlan.model,
|
||||
backend: providerPlan.backend,
|
||||
mode: providerPlan.mode,
|
||||
schema: [
|
||||
"conversationId",
|
||||
"sessionId",
|
||||
"messageId",
|
||||
"status",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"traceId",
|
||||
"provider",
|
||||
"model",
|
||||
"backend",
|
||||
"error.message",
|
||||
"error.code",
|
||||
"error.missingEnv",
|
||||
"availability.status"
|
||||
],
|
||||
status: blocked ? "blocked" : "available",
|
||||
blocker: blocked ? "凭证缺口" : null,
|
||||
reason: blocked ? "provider_unavailable" : null,
|
||||
summary: blocked
|
||||
? `真实后端已接入,但当前 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 未注入,因此不能返回真实回复。`
|
||||
: "真实后端已接入,发送会走真实 /v1/agent/chat;只有实际 completed 回复才标记 DEV-LIVE。",
|
||||
missingEnv,
|
||||
secretRefs: blocked
|
||||
? [
|
||||
{
|
||||
env: "OPENAI_API_KEY",
|
||||
secretName: "hwlab-code-agent-provider",
|
||||
secretKey: "openai-api-key",
|
||||
present: false,
|
||||
redacted: true
|
||||
}
|
||||
]
|
||||
: [],
|
||||
ready: !blocked
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProviderPlan(env, options = {}) {
|
||||
const provider = String(env.HWLAB_CODE_AGENT_PROVIDER || "auto").trim().toLowerCase();
|
||||
const model = firstNonEmpty(
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
createErrorEnvelope,
|
||||
handleJsonRpcRequest
|
||||
} from "./json-rpc.mjs";
|
||||
import { handleCodeAgentChat } from "./code-agent-chat.mjs";
|
||||
import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs";
|
||||
import { buildDbRuntimeReadiness } from "./db-contract.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
|
||||
@@ -41,6 +41,7 @@ export async function buildHealthPayload(options = {}) {
|
||||
const imageReference = process.env.HWLAB_IMAGE || "unknown";
|
||||
const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown";
|
||||
const db = await buildDbRuntimeReadiness(process.env, options.dbProbe);
|
||||
const codeAgent = describeCodeAgentAvailability(options.env ?? process.env, options);
|
||||
|
||||
return {
|
||||
serviceId,
|
||||
@@ -64,6 +65,7 @@ export async function buildHealthPayload(options = {}) {
|
||||
endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
|
||||
observedAt: new Date().toISOString(),
|
||||
db,
|
||||
codeAgent,
|
||||
runtime: options.runtimeStore?.summary?.() ?? createCloudRuntimeStore().summary()
|
||||
};
|
||||
}
|
||||
@@ -147,23 +149,7 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
adapter: "rest",
|
||||
status: "degraded",
|
||||
rpcBridge: "POST /v1/rpc/{method}",
|
||||
codeAgent: {
|
||||
endpoint: "POST /v1/agent/chat",
|
||||
status: "available",
|
||||
schema: [
|
||||
"conversationId",
|
||||
"sessionId",
|
||||
"messageId",
|
||||
"status",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"traceId",
|
||||
"provider",
|
||||
"model",
|
||||
"backend",
|
||||
"error.message"
|
||||
]
|
||||
},
|
||||
codeAgent: describeCodeAgentAvailability(options.env ?? process.env, options),
|
||||
methods: SUPPORTED_RPC_METHODS,
|
||||
auditFields: AUDIT_FIELD_NAMES,
|
||||
db: await buildDbRuntimeReadiness(process.env, options.dbProbe),
|
||||
|
||||
@@ -10,7 +10,13 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
delete process.env.HWLAB_CLOUD_DB_URL;
|
||||
delete process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
|
||||
const server = createCloudApiServer();
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: "",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
@@ -36,6 +42,14 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthPayload.db.safety.liveDbEvidence, false);
|
||||
assert.equal(healthPayload.runtime.durable, false);
|
||||
assert.equal(healthPayload.runtime.status, "degraded");
|
||||
assert.equal(healthPayload.codeAgent.status, "blocked");
|
||||
assert.equal(healthPayload.codeAgent.blocker, "凭证缺口");
|
||||
assert.equal(healthPayload.codeAgent.reason, "provider_unavailable");
|
||||
assert.deepEqual(healthPayload.codeAgent.missingEnv, ["OPENAI_API_KEY"]);
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].redacted, true);
|
||||
assert.equal(JSON.stringify(healthPayload.codeAgent).includes("sk-"), false);
|
||||
assert.deepEqual(healthPayload.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]);
|
||||
assert.equal(healthPayload.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
|
||||
assert.equal(healthPayload.db.secretRefs[0].redacted, true);
|
||||
@@ -47,6 +61,8 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthLivePayload.status, "degraded");
|
||||
assert.equal(healthLivePayload.commit.id.length > 0, true);
|
||||
assert.equal(healthLivePayload.db.ready, false);
|
||||
assert.equal(healthLivePayload.codeAgent.status, "blocked");
|
||||
assert.equal(healthLivePayload.codeAgent.ready, false);
|
||||
|
||||
const readiness = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
assert.equal(readiness.status, 200);
|
||||
@@ -179,6 +195,45 @@ test("cloud api health does not treat DB env presence-only as live readiness", a
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1 describes Code Agent provider blocker without leaking secret values", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: "",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "openai",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1`);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.codeAgent.endpoint, "POST /v1/agent/chat");
|
||||
assert.equal(payload.codeAgent.provider, "openai-responses");
|
||||
assert.equal(payload.codeAgent.model, "gpt-test");
|
||||
assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/openai-responses");
|
||||
assert.equal(payload.codeAgent.mode, "openai");
|
||||
assert.equal(payload.codeAgent.status, "blocked");
|
||||
assert.equal(payload.codeAgent.blocker, "凭证缺口");
|
||||
assert.equal(payload.codeAgent.reason, "provider_unavailable");
|
||||
assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY"]);
|
||||
assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].redacted, true);
|
||||
assert.equal(payload.codeAgent.ready, false);
|
||||
assert.match(payload.codeAgent.summary, /真实后端已接入/u);
|
||||
assert.match(payload.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
assert.equal(JSON.stringify(payload).includes("sk-"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat returns structured completed Code Agent payload", async () => {
|
||||
const server = createCloudApiServer({
|
||||
callCodeAgentProvider: async ({ providerPlan, message, traceId }) => ({
|
||||
@@ -220,6 +275,7 @@ test("cloud api /v1/agent/chat returns structured completed Code Agent payload",
|
||||
assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false);
|
||||
assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false);
|
||||
assert.match(payload.reply.content, /HWLAB 工作台/);
|
||||
assert.equal(Object.hasOwn(payload, "error"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
@@ -262,6 +318,56 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as
|
||||
assert.match(payload.error.message, /Codex CLI command is not available/);
|
||||
assert.deepEqual(payload.error.missingCommands, ["codex"]);
|
||||
assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(payload.availability.status, "blocked");
|
||||
assert.equal(payload.availability.blocker, "凭证缺口");
|
||||
assert.equal(payload.availability.reason, "provider_unavailable");
|
||||
assert.equal(payload.availability.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(payload.availability.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(payload.availability.secretRefs[0].redacted, true);
|
||||
assert.match(payload.availability.summary, /真实后端已接入/u);
|
||||
assert.match(payload.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
assert.equal(JSON.stringify(payload).includes("sk-"), false);
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat does not complete on empty provider text", async () => {
|
||||
const server = createCloudApiServer({
|
||||
callCodeAgentProvider: async ({ providerPlan }) => ({
|
||||
provider: providerPlan.provider,
|
||||
model: providerPlan.model,
|
||||
backend: providerPlan.backend,
|
||||
content: " ",
|
||||
usage: null,
|
||||
providerTrace: {
|
||||
source: "empty-test-provider"
|
||||
}
|
||||
})
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_empty-provider-text",
|
||||
message: "你好"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.conversationId, "cnv_empty-provider-text");
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.equal(payload.error.code, "provider_unavailable");
|
||||
assert.match(payload.error.message, /no assistant text/);
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,80 +1,334 @@
|
||||
#!/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`);
|
||||
}
|
||||
|
||||
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"
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
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 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"
|
||||
};
|
||||
|
||||
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"
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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(Object.hasOwn(failed, "reply"), false);
|
||||
logOk("failed provider-gap schema");
|
||||
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."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write("[code-agent-chat-smoke] passed\n");
|
||||
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)}`;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
|
||||
import { validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.mjs";
|
||||
import { runCli } from "../tools/hwlab-cli/lib/cli.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -141,7 +142,11 @@ async function smokeCloudApi() {
|
||||
HWLAB_CLOUD_API_HOST: "127.0.0.1",
|
||||
HWLAB_CLOUD_API_PORT: String(port),
|
||||
HWLAB_CLOUD_DB_URL: "",
|
||||
HWLAB_CLOUD_DB_SSL_MODE: ""
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-m1-local",
|
||||
OPENAI_API_KEY: "",
|
||||
PATH: ""
|
||||
});
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
@@ -162,6 +167,14 @@ async function smokeCloudApi() {
|
||||
assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]);
|
||||
assert.equal(health.body.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
|
||||
assert.equal(health.body.db.secretRefs[0].redacted, true);
|
||||
assert.equal(health.body.codeAgent.status, "blocked");
|
||||
assert.equal(health.body.codeAgent.blocker, "凭证缺口");
|
||||
assert.equal(health.body.codeAgent.reason, "provider_unavailable");
|
||||
assert.ok(health.body.codeAgent.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(health.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(health.body.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(health.body.codeAgent.secretRefs[0].redacted, true);
|
||||
assert.equal(JSON.stringify(health.body.codeAgent).includes("sk-"), false);
|
||||
|
||||
const live = await requestJson(`${baseUrl}/live`);
|
||||
assert.equal(live.response.status, 200);
|
||||
@@ -176,6 +189,53 @@ async function smokeCloudApi() {
|
||||
assert.ok(adapter.body.methods.includes("hardware.operation.request"));
|
||||
assert.ok(adapter.body.methods.includes("audit.event.query"));
|
||||
assert.ok(adapter.body.methods.includes("evidence.record.query"));
|
||||
assert.equal(adapter.body.codeAgent.endpoint, "POST /v1/agent/chat");
|
||||
assert.equal(adapter.body.codeAgent.status, "blocked");
|
||||
assert.equal(adapter.body.codeAgent.blocker, "凭证缺口");
|
||||
assert.equal(adapter.body.codeAgent.provider, "codex-cli");
|
||||
assert.equal(adapter.body.codeAgent.model, "gpt-m1-local");
|
||||
assert.equal(adapter.body.codeAgent.backend, "hwlab-cloud-api/codex-cli");
|
||||
assert.equal(adapter.body.codeAgent.ready, false);
|
||||
assert.ok(adapter.body.codeAgent.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(adapter.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(adapter.body.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(adapter.body.codeAgent.secretRefs[0].redacted, true);
|
||||
assert.match(adapter.body.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
assert.equal(JSON.stringify(adapter.body.codeAgent).includes("sk-"), false);
|
||||
|
||||
const agentChat = await requestJson(`${baseUrl}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_m1_local_chat",
|
||||
traceId: "trc_m1_local_chat",
|
||||
projectId: "prj_m1_local_contract",
|
||||
message: "请用一句话说明本地 SOURCE/LOCAL 合同。"
|
||||
})
|
||||
});
|
||||
assert.equal(agentChat.response.status, 200);
|
||||
validateCodeAgentChatSchema(agentChat.body);
|
||||
assert.equal(agentChat.body.conversationId, "cnv_m1_local_chat");
|
||||
assert.equal(agentChat.body.sessionId, "cnv_m1_local_chat");
|
||||
assert.match(agentChat.body.messageId, /^msg_/);
|
||||
assert.equal(agentChat.body.status, "failed");
|
||||
assert.equal(agentChat.body.traceId, "trc_m1_local_chat");
|
||||
assert.equal(agentChat.body.projectId, "prj_m1_local_contract");
|
||||
assert.equal(agentChat.body.provider, "codex-cli");
|
||||
assert.equal(agentChat.body.model, "gpt-m1-local");
|
||||
assert.equal(agentChat.body.backend, "hwlab-cloud-api/codex-cli");
|
||||
assert.equal(agentChat.body.error.code, "provider_unavailable");
|
||||
assert.match(agentChat.body.error.message, /Codex CLI command is not available/);
|
||||
assert.deepEqual(agentChat.body.error.missingCommands, ["codex"]);
|
||||
assert.ok(agentChat.body.error.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(agentChat.body.availability.status, "blocked");
|
||||
assert.equal(agentChat.body.availability.blocker, "凭证缺口");
|
||||
assert.equal(agentChat.body.availability.reason, "provider_unavailable");
|
||||
assert.equal(agentChat.body.availability.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(agentChat.body.availability.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(agentChat.body.availability.secretRefs[0].redacted, true);
|
||||
assert.match(agentChat.body.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
assert.equal(JSON.stringify(agentChat.body).includes("sk-"), false);
|
||||
assert.equal(Object.hasOwn(agentChat.body, "reply"), false);
|
||||
|
||||
const rpcHealth = await requestJson(`${baseUrl}/rpc`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -207,6 +207,11 @@ function runStaticSmoke() {
|
||||
evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider credential blockers and only real completed replies can become DEV-LIVE.", {
|
||||
blocker: "observability_blocker",
|
||||
evidence: ["BLOCKED 凭证缺口", "provider_unavailable", "OPENAI_API_KEY", "hwlab-code-agent-provider/openai-api-key", "completed -> dev-live guard"]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(source), "Workbench source does not expose hardware write APIs or live mutation commands.", {
|
||||
blocker: "safety_blocker",
|
||||
evidence: forbiddenWritePatterns.map(String)
|
||||
@@ -475,6 +480,29 @@ function hasCodeAgentChatContract({ html, app }) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasCodeAgentReadinessVisibility({ html, app }) {
|
||||
const source = `${html}\n${app}`;
|
||||
return (
|
||||
/codeAgentAvailability/u.test(app) &&
|
||||
/codeAgentAvailabilityFrom/u.test(app) &&
|
||||
/latestCompletedAgentMessage/u.test(app) &&
|
||||
/BLOCKED 凭证缺口/u.test(app) &&
|
||||
/provider_unavailable/u.test(app) &&
|
||||
/OPENAI_API_KEY/u.test(app) &&
|
||||
/hwlab-code-agent-provider\/openai-api-key/u.test(app) &&
|
||||
/只有真实 completed 回复才标 DEV-LIVE/u.test(app) &&
|
||||
/不能因为只有 conversationId 标为开发实况/u.test(app) &&
|
||||
/Boolean\(message\.provider\)/u.test(app) &&
|
||||
/Boolean\(message\.model\)/u.test(app) &&
|
||||
/Boolean\(message\.backend\)/u.test(app) &&
|
||||
/status === "completed" \? "dev-live"/u.test(app) &&
|
||||
!/status === "failed" \? "dev-live"/u.test(app) &&
|
||||
!/tone:\s*state\.conversationId\s*\?\s*"dev-live"/u.test(app) &&
|
||||
!/provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu.test(source) &&
|
||||
!/sk-[A-Za-z0-9._-]{8,}/u.test(source)
|
||||
);
|
||||
}
|
||||
|
||||
function noForbiddenWriteSurface(source) {
|
||||
return forbiddenWritePatterns.every((pattern) => !pattern.test(source));
|
||||
}
|
||||
|
||||
+138
-18
@@ -13,6 +13,7 @@ const rpcReadMethods = Object.freeze([
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
active: "活动",
|
||||
available: "可用",
|
||||
blocked: "阻塞 BLOCKED",
|
||||
completed: "完成",
|
||||
connected: "已连接",
|
||||
degraded: "降级",
|
||||
@@ -33,8 +34,7 @@ const STATUS_LABELS = Object.freeze({
|
||||
requires: "依赖",
|
||||
source: "来源 SOURCE",
|
||||
"dry-run": "演练 DRY-RUN",
|
||||
"dev-live": "开发实况 DEV-LIVE",
|
||||
blocked: "阻塞 BLOCKED"
|
||||
"dev-live": "开发实况 DEV-LIVE"
|
||||
});
|
||||
|
||||
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
|
||||
@@ -77,6 +77,7 @@ const el = {
|
||||
|
||||
const state = {
|
||||
conversationId: null,
|
||||
codeAgentAvailability: null,
|
||||
chatMessages: [],
|
||||
chatPending: false
|
||||
};
|
||||
@@ -247,8 +248,12 @@ function initCommandBar() {
|
||||
model: result.model,
|
||||
backend: result.backend,
|
||||
updatedAt: result.updatedAt,
|
||||
error: result.error
|
||||
error: result.error,
|
||||
availability: result.availability
|
||||
};
|
||||
if (result.availability) {
|
||||
state.codeAgentAvailability = result.availability;
|
||||
}
|
||||
if (result.status !== "completed") {
|
||||
el.commandInput.value = value;
|
||||
}
|
||||
@@ -441,14 +446,18 @@ function renderLiveSurface(live) {
|
||||
|
||||
el.liveStatus.textContent = reachable ? "只读连接可用" : "离线查看";
|
||||
el.liveStatus.className = `tone-${toneClass(reachable ? "dev-live" : "source")}`;
|
||||
state.codeAgentAvailability = codeAgentAvailabilityFrom(live);
|
||||
el.liveDetail.textContent = reachable
|
||||
? "同源只读接口可响应。技术复核详情已放在二级页面。"
|
||||
? codeAgentAvailabilitySummary()
|
||||
: "同源 API 暂不可用,当前仍可使用本地草稿和已加载资源。";
|
||||
|
||||
renderAgentChatStatus(deriveAgentChatStatus());
|
||||
renderHardwareStatus(runtimeSummary);
|
||||
renderRecords(live);
|
||||
renderDiagnostics(live);
|
||||
renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, reachable ? "live" : "degraded");
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
}
|
||||
|
||||
function renderResourceTree() {
|
||||
@@ -540,12 +549,7 @@ function renderConversation() {
|
||||
text: "这里是用户工作台:可以整理任务、查看资源、核对接线和可信记录。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。",
|
||||
status: "source"
|
||||
},
|
||||
{
|
||||
role: "agent",
|
||||
title: "Code Agent",
|
||||
text: "请在底部输入中文消息并点击发送,我会通过 cloud-api 调用真实 Code Agent / Codex 能力返回回复。",
|
||||
status: "source"
|
||||
}
|
||||
codeAgentStatusMessage(state.codeAgentAvailability)
|
||||
];
|
||||
replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard));
|
||||
}
|
||||
@@ -648,10 +652,8 @@ function controlRows() {
|
||||
},
|
||||
{
|
||||
title: "Code Agent 对话",
|
||||
detail: state.conversationId
|
||||
? `当前会话 ${state.conversationId};回复来自 cloud-api 受控 Code Agent 接口。`
|
||||
: "输入区会调用 cloud-api 受控 Code Agent 接口,不会直接调用硬件写入方法。",
|
||||
tone: state.conversationId ? "dev-live" : "source"
|
||||
detail: codeAgentControlSummary(state.codeAgentAvailability),
|
||||
tone: currentConversationTone()
|
||||
},
|
||||
{
|
||||
title: "只读探测",
|
||||
@@ -672,8 +674,8 @@ function renderDrafts() {
|
||||
? [
|
||||
{
|
||||
title: "对话状态",
|
||||
detail: "暂无消息。输入区会发送到 Code Agent 后端,失败时会保留输入内容。",
|
||||
tone: "source"
|
||||
detail: codeAgentPromptText(state.codeAgentAvailability),
|
||||
tone: state.codeAgentAvailability?.status === "blocked" ? "blocked" : "source"
|
||||
}
|
||||
]
|
||||
: []),
|
||||
@@ -860,10 +862,11 @@ function renderAgentChatStatus(status, result = null) {
|
||||
idle: "等待输入",
|
||||
running: "处理中",
|
||||
completed: "已回复",
|
||||
failed: "发送失败"
|
||||
failed: "发送失败",
|
||||
blocked: "BLOCKED 凭证缺口"
|
||||
};
|
||||
el.agentChatStatus.textContent = labels[status] ?? statusLabel(status);
|
||||
el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" ? "blocked" : "source")}`;
|
||||
el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" || status === "blocked" ? "blocked" : "source")}`;
|
||||
el.commandInput.disabled = status === "running";
|
||||
el.commandSend.disabled = status === "running";
|
||||
el.commandSend.textContent = status === "running" ? "发送中" : "发送";
|
||||
@@ -879,6 +882,13 @@ function sourceTitle(result) {
|
||||
}
|
||||
|
||||
function failureMessage(result) {
|
||||
if (result?.error?.code === "provider_unavailable" || result?.availability?.status === "blocked") {
|
||||
const availability = result.availability ?? codeAgentAvailabilityFromResult(result);
|
||||
const missing = Array.isArray(result.error?.missingEnv) && result.error.missingEnv.length > 0
|
||||
? ` 当前缺口:${result.error.missingEnv.join("、")}。`
|
||||
: "";
|
||||
return `${availability.summary}${missing}`;
|
||||
}
|
||||
const missing = [
|
||||
...(result.error?.missingCommands ?? []),
|
||||
...(result.error?.missingEnv ?? [])
|
||||
@@ -985,6 +995,116 @@ function messageFromPayload(payload) {
|
||||
return payload.error?.message || payload.error?.code || payload.message || JSON.stringify(payload).slice(0, 160);
|
||||
}
|
||||
|
||||
function codeAgentAvailabilityFrom(live) {
|
||||
return live?.restIndex?.data?.codeAgent ?? live?.healthLive?.data?.codeAgent ?? null;
|
||||
}
|
||||
|
||||
function codeAgentAvailabilityFromResult(result) {
|
||||
return {
|
||||
status: "blocked",
|
||||
blocker: "凭证缺口",
|
||||
reason: result?.error?.code ?? "provider_unavailable",
|
||||
summary: "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。",
|
||||
missingEnv: result?.error?.missingEnv ?? [],
|
||||
secretRefs: [
|
||||
{
|
||||
env: "OPENAI_API_KEY",
|
||||
secretName: "hwlab-code-agent-provider",
|
||||
secretKey: "openai-api-key",
|
||||
redacted: true
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentStatusMessage(availability) {
|
||||
if (availability?.status === "blocked") {
|
||||
return {
|
||||
role: "system",
|
||||
title: "Code Agent 状态:BLOCKED 凭证缺口",
|
||||
text: codeAgentBlockedSummary(availability),
|
||||
status: "blocked"
|
||||
};
|
||||
}
|
||||
if (availability?.status === "available") {
|
||||
return {
|
||||
role: "system",
|
||||
title: "Code Agent 状态:真实通道已接入",
|
||||
text: "真实 /v1/agent/chat 后端已接入;发送后只有实际 completed 回复才会标记为 DEV-LIVE。",
|
||||
status: "source"
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: "system",
|
||||
title: "Code Agent 状态:等待只读探测",
|
||||
text: "正在读取 same-origin /v1 与 /health/live;首屏不会宣称 Code Agent 可用,也不会把失败或静态内容标成 DEV-LIVE。",
|
||||
status: "source"
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentPromptText(availability) {
|
||||
if (availability?.status === "blocked") {
|
||||
return "可以继续输入并保留草稿;发送会走真实 /v1/agent/chat,但当前 DEV provider 凭证缺口会返回结构化失败,不会冒充真实 Codex 回复。";
|
||||
}
|
||||
return "请在底部输入中文消息并点击发送;完成态只来自真实 /v1/agent/chat completed 回复。";
|
||||
}
|
||||
|
||||
function codeAgentControlSummary(availability) {
|
||||
if (latestCompletedAgentMessage()) {
|
||||
return state.conversationId
|
||||
? `当前会话 ${state.conversationId};最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口。`
|
||||
: "最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口。";
|
||||
}
|
||||
if (availability?.status === "blocked") {
|
||||
return codeAgentBlockedSummary(availability);
|
||||
}
|
||||
return "输入区会调用 cloud-api 受控 Code Agent 接口;只有真实 completed 回复才标 DEV-LIVE,不能因为只有 conversationId 标为开发实况。";
|
||||
}
|
||||
|
||||
function codeAgentBlockedSummary(availability) {
|
||||
return availability?.summary ?? "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。";
|
||||
}
|
||||
|
||||
function codeAgentAvailabilitySummary() {
|
||||
if (state.codeAgentAvailability?.status === "blocked") {
|
||||
return "同源 API 可响应;Code Agent 为 BLOCKED/凭证缺口,真实后端已接入但 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入。";
|
||||
}
|
||||
if (state.codeAgentAvailability?.status === "available") {
|
||||
return "同源 API 可响应;Code Agent 真实通道已接入,只有 completed 回复才标 DEV-LIVE。";
|
||||
}
|
||||
return "同源只读接口可响应。技术复核详情已放在二级页面。";
|
||||
}
|
||||
|
||||
function latestCompletedAgentMessage() {
|
||||
return [...state.chatMessages].reverse().find((message) =>
|
||||
message.role === "agent" &&
|
||||
message.status === "completed" &&
|
||||
!message.error &&
|
||||
Boolean(message.provider) &&
|
||||
Boolean(message.model) &&
|
||||
Boolean(message.backend)
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function latestChatResult() {
|
||||
return [...state.chatMessages].reverse().find((message) => message.role === "agent") ?? null;
|
||||
}
|
||||
|
||||
function deriveAgentChatStatus() {
|
||||
if (state.chatPending) return "running";
|
||||
const latest = latestChatResult();
|
||||
if (latest?.status === "completed") return "completed";
|
||||
if (latest?.status === "failed") return latest.error?.code === "provider_unavailable" ? "blocked" : "failed";
|
||||
if (state.codeAgentAvailability?.status === "blocked") return "blocked";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function currentConversationTone() {
|
||||
if (latestCompletedAgentMessage()) return "dev-live";
|
||||
if (state.codeAgentAvailability?.status === "blocked" || latestChatResult()?.status === "failed") return "blocked";
|
||||
return "source";
|
||||
}
|
||||
|
||||
function statusLabel(value) {
|
||||
const key = String(value ?? "unknown").trim();
|
||||
const normalized = key.toLowerCase();
|
||||
|
||||
@@ -183,6 +183,23 @@ assert.doesNotMatch(html, /主流程[\s\S]*添加草稿/u);
|
||||
assert.match(app, /sendAgentMessage/);
|
||||
assert.match(app, /fetchJson\("\/v1\/agent\/chat"/);
|
||||
assert.match(app, /Code Agent 调用失败/);
|
||||
assert.match(app, /codeAgentAvailability/);
|
||||
assert.match(app, /codeAgentAvailabilityFrom/);
|
||||
assert.match(app, /latestCompletedAgentMessage/);
|
||||
assert.match(app, /BLOCKED 凭证缺口/);
|
||||
assert.match(app, /provider_unavailable/);
|
||||
assert.match(app, /OPENAI_API_KEY/);
|
||||
assert.match(app, /hwlab-code-agent-provider\/openai-api-key/);
|
||||
assert.match(app, /只有真实 completed 回复才标 DEV-LIVE/);
|
||||
assert.match(app, /不能因为只有 conversationId 标为开发实况/);
|
||||
assert.match(app, /Boolean\(message\.provider\)/);
|
||||
assert.match(app, /Boolean\(message\.model\)/);
|
||||
assert.match(app, /Boolean\(message\.backend\)/);
|
||||
assert.match(app, /status === "completed" \? "dev-live"/);
|
||||
assert.doesNotMatch(app, /status === "failed" \? "dev-live"/);
|
||||
assert.doesNotMatch(`${html}\n${app}`, /provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu);
|
||||
assert.doesNotMatch(app, /tone:\s*state\.conversationId\s*\?\s*"dev-live"/);
|
||||
assert.doesNotMatch(`${html}\n${app}`, /sk-[A-Za-z0-9._-]{8,}/u);
|
||||
assert.match(app, /state\.conversationId/);
|
||||
assert.match(app, /conversationId/);
|
||||
assert.match(app, /messageId/);
|
||||
|
||||
Reference in New Issue
Block a user