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) => {
|
||||
|
||||
Reference in New Issue
Block a user