fix: integrate code agent readiness state

This commit is contained in:
HWLAB Code Queue
2026-05-22 15:11:04 +00:00
parent c7de4745f4
commit eb4c85a34f
8 changed files with 739 additions and 102 deletions
+70 -4
View File
@@ -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(
+4 -18
View File
@@ -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),
+107 -1
View File
@@ -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) => {