feat: add Cloud Workbench Code Agent chat

Adds the Cloud Workbench Code Agent chat path and keeps provider/credential gaps explicit instead of faking replies.
This commit is contained in:
Lyon
2026-05-22 21:17:11 +08:00
committed by GitHub
parent ae23972fd9
commit c7de4745f4
14 changed files with 1040 additions and 34 deletions
+4 -1
View File
@@ -181,7 +181,10 @@
"HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy",
"HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url",
"HWLAB_CLOUD_DB_SSL_MODE": "require",
"HWLAB_CLOUD_DB_CONTRACT": "dev-redacted-presence-only"
"HWLAB_CLOUD_DB_CONTRACT": "dev-redacted-presence-only",
"HWLAB_CODE_AGENT_PROVIDER": "openai",
"HWLAB_CODE_AGENT_MODEL": "gpt-5.5",
"OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key"
}
},
{
+18
View File
@@ -80,6 +80,24 @@
{
"name": "HWLAB_CLOUD_DB_CONTRACT",
"value": "dev-redacted-presence-only"
},
{
"name": "HWLAB_CODE_AGENT_PROVIDER",
"value": "openai"
},
{
"name": "HWLAB_CODE_AGENT_MODEL",
"value": "gpt-5.5"
},
{
"name": "OPENAI_API_KEY",
"valueFrom": {
"secretKeyRef": {
"name": "hwlab-code-agent-provider",
"key": "openai-api-key",
"optional": true
}
}
}
],
"readinessProbe": {
+515
View File
@@ -0,0 +1,515 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { access, mkdir, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
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_SYSTEM_PROMPT = [
"你是 HWLAB 云工作台的 Code Agent。",
"请用中文直接回答用户的工作台问题。",
"当前最小版本可以帮助用户整理任务、查看云工作台资源和说明下一步;不要声称已经执行硬件变更。",
"不要输出 secret、token 或环境变量原文。"
].join("\n");
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export async function handleCodeAgentChat(params = {}, options = {}) {
const timestamp = nowIso(options.now);
const conversationId = cleanProtocolId(params.conversationId, "cnv") || `cnv_${randomUUID()}`;
const sessionId = conversationId;
const messageId = `msg_${randomUUID()}`;
const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`;
const providerPlan = resolveProviderPlan(options.env ?? process.env, options);
const base = {
conversationId,
sessionId,
messageId,
status: "running",
createdAt: timestamp,
updatedAt: timestamp,
traceId,
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
projectId: cleanProjectId(params.projectId) || DEFAULT_PROJECT_ID
};
try {
const message = normalizeUserMessage(params.message);
const providerResult = await callConfiguredProvider({
providerPlan,
message,
conversationId,
traceId,
timeoutMs: options.timeoutMs,
env: options.env ?? process.env,
now: options.now,
callProvider: options.callProvider
});
const completedAt = nowIso(options.now);
return {
...base,
status: "completed",
updatedAt: completedAt,
provider: providerResult.provider ?? base.provider,
model: providerResult.model ?? base.model,
backend: providerResult.backend ?? base.backend,
reply: {
messageId,
role: "assistant",
content: providerResult.content,
createdAt: completedAt
},
usage: providerResult.usage ?? null,
providerTrace: providerResult.providerTrace ?? null
};
} catch (error) {
const failedAt = nowIso(options.now);
return {
...base,
status: "failed",
updatedAt: failedAt,
error: normalizeChatError(error)
};
}
}
export function validateCodeAgentChatSchema(payload) {
const required = [
"conversationId",
"sessionId",
"messageId",
"status",
"createdAt",
"updatedAt",
"traceId",
"provider",
"model",
"backend"
];
for (const field of required) {
if (!payload || typeof payload !== "object" || !Object.hasOwn(payload, field)) {
throw new Error(`code agent chat response missing ${field}`);
}
}
if (!["running", "completed", "failed"].includes(payload.status)) {
throw new Error(`code agent chat response has invalid status ${JSON.stringify(payload.status)}`);
}
if (Number.isNaN(Date.parse(payload.createdAt)) || Number.isNaN(Date.parse(payload.updatedAt))) {
throw new Error("code agent chat response timestamps must be RFC 3339 strings");
}
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");
}
}
function resolveProviderPlan(env, options = {}) {
const provider = String(env.HWLAB_CODE_AGENT_PROVIDER || "auto").trim().toLowerCase();
const model = firstNonEmpty(
options.model,
env.HWLAB_CODE_AGENT_MODEL,
env.OPENAI_MODEL,
env.CODE_QUEUE_DEFAULT_MODEL,
DEFAULT_MODEL
);
if (provider === "openai") {
return {
provider: "openai-responses",
model,
backend: "hwlab-cloud-api/openai-responses",
mode: "openai"
};
}
if (provider === "codex-cli" || provider === "codex") {
return {
provider: "codex-cli",
model,
backend: "hwlab-cloud-api/codex-cli",
mode: "codex-cli"
};
}
return {
provider: env.OPENAI_API_KEY ? "openai-responses" : "codex-cli",
model,
backend: env.OPENAI_API_KEY ? "hwlab-cloud-api/openai-responses" : "hwlab-cloud-api/codex-cli",
mode: env.OPENAI_API_KEY ? "openai" : "codex-cli",
auto: true
};
}
async function callConfiguredProvider({
providerPlan,
message,
conversationId,
traceId,
timeoutMs,
env,
now,
callProvider
}) {
if (callProvider) {
return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now });
}
if (providerPlan.mode === "openai") {
return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env });
}
return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env });
}
async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }) {
if (!env.OPENAI_API_KEY) {
throw providerUnavailable("OPENAI_API_KEY is not configured for openai-responses", {
missingEnv: ["OPENAI_API_KEY"],
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
}
const endpoint = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL || "https://api.openai.com/v1/responses";
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), effectiveTimeout(timeoutMs));
let response;
let payload;
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: providerPlan.model,
instructions: CODE_AGENT_SYSTEM_PROMPT,
input: [
{
role: "user",
content: [
{
type: "input_text",
text: buildAgentPrompt({ message, conversationId, traceId })
}
]
}
],
store: false
}),
signal: controller.signal
});
payload = await response.json().catch(() => null);
} catch (error) {
if (error.name === "AbortError") {
throw providerUnavailable(`OpenAI Responses request timed out after ${effectiveTimeout(timeoutMs)}ms`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
}
throw providerUnavailable(`OpenAI Responses request failed: ${error.message}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
} finally {
clearTimeout(timer);
}
if (!response.ok) {
throw providerUnavailable(`OpenAI Responses returned HTTP ${response.status}: ${payload?.error?.message || "request rejected"}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
providerStatus: response.status
});
}
const content = extractOpenAiOutputText(payload);
if (!content) {
throw providerUnavailable("OpenAI Responses returned no assistant text", {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
}
return {
provider: providerPlan.provider,
model: payload.model || providerPlan.model,
backend: providerPlan.backend,
content,
usage: payload.usage ?? null,
providerTrace: {
responseId: payload.id ?? null
}
};
}
async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }) {
const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND);
if (!(await commandExists(command, env))) {
throw providerUnavailable(`Codex CLI command is not available: ${command}`, {
missingCommands: [command],
missingEnv: env.OPENAI_API_KEY ? [] : ["OPENAI_API_KEY"],
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
command
});
}
const outputDir = await makeTempDir();
const outputFile = path.join(outputDir, "last-message.txt");
const args = [
"exec",
"--cd",
repoRoot,
"--skip-git-repo-check",
"--sandbox",
"read-only",
"--ephemeral",
"--output-last-message",
outputFile
];
if (providerPlan.model) {
args.push("--model", providerPlan.model);
}
args.push("-");
const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId }), {
env,
timeoutMs: effectiveTimeout(timeoutMs)
});
try {
if (result.code !== 0) {
throw providerUnavailable(`Codex CLI exited with code ${result.code}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
command: commandLine(command, args),
exitCode: result.code,
stderrSummary: redactText(tailText(result.stderr || result.stdout))
});
}
const content = (await readFile(outputFile, "utf8")).trim();
if (!content) {
throw providerUnavailable("Codex CLI completed without an assistant message", {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
command: commandLine(command, args),
stderrSummary: redactText(tailText(result.stderr || result.stdout))
});
}
return {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
content,
usage: null,
providerTrace: {
command: commandLine(command, args),
stdoutSummary: redactText(tailText(result.stdout, 600))
}
};
} finally {
await rm(outputDir, { recursive: true, force: true });
}
}
function buildAgentPrompt({ message, conversationId, traceId }) {
return [
CODE_AGENT_SYSTEM_PROMPT,
"",
`conversationId: ${conversationId}`,
`traceId: ${traceId}`,
"",
"用户消息:",
message
].join("\n");
}
function normalizeUserMessage(value) {
if (typeof value !== "string") {
throw badRequest("message must be a string");
}
const message = value.trim();
if (!message) {
throw badRequest("message is required");
}
if (message.length > 4000) {
throw badRequest("message exceeds 4000 characters");
}
return message;
}
function normalizeChatError(error) {
const normalized = {
code: error.code || "code_agent_failed",
message: error.message || "Code Agent request failed"
};
for (const key of [
"missingEnv",
"missingCommands",
"provider",
"model",
"backend",
"command",
"exitCode",
"providerStatus",
"stderrSummary"
]) {
if (error[key] !== undefined) {
normalized[key] = error[key];
}
}
return normalized;
}
function badRequest(message) {
const error = new Error(message);
error.code = "invalid_params";
return error;
}
function providerUnavailable(message, details = {}) {
const error = new Error(message);
error.code = "provider_unavailable";
Object.assign(error, details);
return error;
}
function extractOpenAiOutputText(payload) {
if (typeof payload?.output_text === "string" && payload.output_text.trim()) {
return payload.output_text.trim();
}
const chunks = [];
for (const item of payload?.output ?? []) {
for (const content of item.content ?? []) {
if (typeof content.text === "string") chunks.push(content.text);
else if (typeof content.output_text === "string") chunks.push(content.output_text);
}
}
return chunks.join("\n").trim();
}
async function commandExists(command, env) {
if (command.includes("/") || command.includes("\\")) {
try {
await access(command, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
const result = await spawnWithInput("sh", ["-lc", `command -v ${shellQuote(command)}`], "", {
env,
timeoutMs: 3000
});
return result.code === 0;
}
function spawnWithInput(command, args, input, { env, timeoutMs }) {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: repoRoot,
env,
stdio: ["pipe", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
child.kill("SIGTERM");
}
}, timeoutMs);
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
settled = true;
clearTimeout(timer);
resolve({ code: 127, stdout, stderr: `${stderr}\n${error.message}` });
});
child.on("close", (code) => {
settled = true;
clearTimeout(timer);
resolve({ code: code ?? 1, stdout, stderr });
});
child.stdin.end(input);
});
}
async function makeTempDir() {
const dir = path.join(os.tmpdir(), `hwlab-code-agent-${randomUUID()}`);
await mkdir(dir, { recursive: true });
return dir;
}
function cleanProtocolId(value, prefix) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (/^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/u.test(trimmed)) return trimmed;
return `${prefix}_${trimmed.replace(/[^A-Za-z0-9._:-]/gu, "-").slice(0, 80)}`;
}
function cleanProjectId(value) {
return cleanProtocolId(value, "prj");
}
function nowIso(now) {
return typeof now === "function" ? now() : new Date().toISOString();
}
function effectiveTimeout(timeoutMs) {
return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODE_AGENT_TIMEOUT_MS;
}
function firstNonEmpty(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
function commandLine(command, args) {
return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" ");
}
function tailText(value, maxLength = 1200) {
const text = String(value ?? "").trim();
if (text.length <= maxLength) return text;
return text.slice(text.length - maxLength);
}
function redactText(value) {
return String(value ?? "")
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***")
.replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***")
.replace(/([A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*=)[^\s]+/giu, "$1***")
.replace(/([A-Za-z0-9_]*KEY[A-Za-z0-9_]*=)[^\s]+/giu, "$1***");
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
+85
View File
@@ -12,6 +12,7 @@ import {
createErrorEnvelope,
handleJsonRpcRequest
} from "./json-rpc.mjs";
import { handleCodeAgentChat } from "./code-agent-chat.mjs";
import { buildDbRuntimeReadiness } from "./db-contract.mjs";
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
@@ -146,6 +147,23 @@ 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"
]
},
methods: SUPPORTED_RPC_METHODS,
auditFields: AUDIT_FIELD_NAMES,
db: await buildDbRuntimeReadiness(process.env, options.dbProbe),
@@ -154,6 +172,11 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (request.method === "POST" && url.pathname === "/v1/agent/chat") {
await handleCodeAgentChatHttp(request, response, options);
return;
}
if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) {
sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 cloud-api runtime", {
operation: `${request.method || "GET"} ${url.pathname}`,
@@ -218,6 +241,68 @@ async function handleRestAdapter(request, response, url, options) {
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
}
async function handleCodeAgentChatHttp(request, response, options) {
const body = await readBody(request, options.bodyLimitBytes);
let params = {};
try {
params = body ? JSON.parse(body) : {};
} catch (error) {
sendJson(response, 400, {
conversationId: "cnv_unassigned",
sessionId: "cnv_unassigned",
messageId: "msg_unassigned",
status: "failed",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
provider: "unassigned",
model: process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown",
backend: "hwlab-cloud-api/code-agent-chat",
error: {
code: "parse_error",
message: "Invalid JSON body",
reason: error.message
}
});
return;
}
if (!params || typeof params !== "object" || Array.isArray(params)) {
sendJson(response, 400, {
conversationId: "cnv_unassigned",
sessionId: "cnv_unassigned",
messageId: "msg_unassigned",
status: "failed",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
provider: "unassigned",
model: process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown",
backend: "hwlab-cloud-api/code-agent-chat",
error: {
code: "invalid_params",
message: "Code Agent chat body must be a JSON object"
}
});
return;
}
const payload = await handleCodeAgentChat(
{
...params,
traceId: getHeader(request, "x-trace-id") || params.traceId
},
{
callProvider: options.callCodeAgentProvider,
timeoutMs: options.codeAgentTimeoutMs,
env: options.env
}
);
sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload);
}
function sendRestError(request, response, statusCode, code, message, context = {}) {
const requestId = getHeader(request, "x-request-id") || "req_unassigned";
sendJson(response, statusCode, {
+91
View File
@@ -178,3 +178,94 @@ test("cloud api health does not treat DB env presence-only as live readiness", a
});
}
});
test("cloud api /v1/agent/chat returns structured completed Code Agent payload", async () => {
const server = createCloudApiServer({
callCodeAgentProvider: async ({ providerPlan, message, traceId }) => ({
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
content: `真实 provider stub: ${message} / ${traceId}`,
usage: null,
providerTrace: {
source: "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",
"x-trace-id": "trc_server-test-agent-chat"
},
body: JSON.stringify({
conversationId: "cnv_server-test-agent-chat",
message: "请用一句话说明当前 HWLAB 工作台可以做什么。"
})
});
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.conversationId, "cnv_server-test-agent-chat");
assert.equal(payload.sessionId, "cnv_server-test-agent-chat");
assert.match(payload.messageId, /^msg_/);
assert.equal(payload.status, "completed");
assert.equal(payload.traceId, "trc_server-test-agent-chat");
assert.equal(payload.provider.length > 0, true);
assert.equal(payload.model.length > 0, true);
assert.equal(payload.backend.length > 0, true);
assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false);
assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false);
assert.match(payload.reply.content, /HWLAB 工作台/);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api /v1/agent/chat reports provider gaps without faking a reply", async () => {
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 {
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({
message: "你好"
})
});
assert.equal(response.status, 200);
const payload = await response.json();
assert.match(payload.conversationId, /^cnv_/);
assert.match(payload.messageId, /^msg_/);
assert.equal(payload.status, "failed");
assert.equal(payload.provider, "codex-cli");
assert.equal(payload.model, "gpt-test");
assert.equal(payload.backend, "hwlab-cloud-api/codex-cli");
assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false);
assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false);
assert.equal(payload.error.code, "provider_unavailable");
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(Object.hasOwn(payload, "reply"), false);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import {
handleCodeAgentChat,
validateCodeAgentChatSchema
} from "../internal/cloud/code-agent-chat.mjs";
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"
}
})
}
);
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 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(Object.hasOwn(failed, "reply"), false);
logOk("failed provider-gap schema");
process.stdout.write("[code-agent-chat-smoke] passed\n");
+21 -1
View File
@@ -460,7 +460,7 @@ async function serveCloudWeb() {
if (
(request.method === "GET" && (url.pathname === "/v1" || url.pathname.startsWith("/v1/"))) ||
(request.method === "POST" && url.pathname === "/json-rpc")
(request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat"))
) {
await proxyCloudApi(request, response, url);
return;
@@ -700,6 +700,26 @@ async function buildService({ args, repo, commitId, shortCommit, service }) {
};
}
if (service.runtimeKind === "cloud-web") {
const build = await run(process.execPath, ["web/hwlab-cloud-web/scripts/build.mjs"]);
if (build.code !== 0) {
return {
...service,
image: ref,
imageTag: tag,
status: "build_failed",
digest: "not_published",
blocker: blocker({
type: "environment_blocker",
scope: service.serviceId,
summary: "cloud web dist build failed before Docker build",
next: "Run node web/hwlab-cloud-web/scripts/build.mjs, fix the static asset build, then rerun --publish."
}),
logTail: tailText(`${build.stdout}\n${build.stderr}`)
};
}
}
const port = servicePorts.get(service.serviceId) ?? 8080;
const labels = [
["org.opencontainers.image.source", "https://github.com/pikasTech/HWLAB"],
+24 -1
View File
@@ -81,6 +81,7 @@ const workbenchMarkers = Object.freeze([
"resource-tree",
"center-workspace",
"conversation-list",
"agent-chat-status",
"task-list",
"right-sidebar",
"hardware-list",
@@ -198,7 +199,12 @@ function runStaticSmoke() {
addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", {
blocker: "safety_blocker",
evidence: ["/health/live", "/v1", "/json-rpc", ...readOnlyRpcMethods]
evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods]
});
addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", {
blocker: "runtime_blocker",
evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"]
});
addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(source), "Workbench source does not expose hardware write APIs or live mutation commands.", {
@@ -446,12 +452,29 @@ function hasSameOriginReadOnlyBoundary(app) {
return (
/fetchJson\("\/health\/live"\)/u.test(app) &&
/fetchJson\("\/v1"\)/u.test(app) &&
/fetchJson\("\/v1\/agent\/chat"/u.test(app) &&
/fetchJson\("\/json-rpc"/u.test(app) &&
JSON.stringify(rpcCalls) === JSON.stringify([...readOnlyRpcMethods].sort()) &&
!/fetchJson\(\s*["']https?:\/\//u.test(app)
);
}
function hasCodeAgentChatContract({ html, app }) {
return (
/id=["']agent-chat-status["']/u.test(html) &&
/id=["']command-send["']/u.test(html) &&
/>发送<\/button>/u.test(html) &&
!/>添加草稿<\/button>/u.test(html) &&
/placeholder=["']输入要发送给 Code Agent 的消息;不会直接触发硬件变更。["']/u.test(html) &&
/fetchJson\("\/v1\/agent\/chat"/u.test(app) &&
/conversationId/u.test(app) &&
/messageId/u.test(app) &&
/updatedAt/u.test(app) &&
/error\?\.message/u.test(app) &&
/Code Agent 调用失败/u.test(app)
);
}
function noForbiddenWriteSurface(source) {
return forbiddenWritePatterns.every((pattern) => !pattern.test(source));
}
+3
View File
@@ -153,5 +153,8 @@ assert.equal(cloudApi.env.HWLAB_IMAGE_TAG, deployManifest.commitId.slice(0, 7),
assert.equal(cloudApi.env.HWLAB_CLOUD_DB_URL, "secretRef:hwlab-cloud-api-dev-db/database-url", "cloud-api DB URL must be a Secret reference placeholder");
assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SSL_MODE, "require", "cloud-api DB SSL mode");
assert.equal(cloudApi.env.HWLAB_CLOUD_DB_CONTRACT, "dev-redacted-presence-only", "cloud-api DB contract marker");
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, "openai", "cloud-api Code Agent provider");
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5", "cloud-api Code Agent model");
assert.equal(cloudApi.env.OPENAI_API_KEY, "secretRef:hwlab-code-agent-provider/openai-api-key", "cloud-api Code Agent OpenAI key must be a Secret reference placeholder");
console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`);
+156 -27
View File
@@ -25,9 +25,11 @@ const STATUS_LABELS = Object.freeze({
open: "未关闭",
pass: "通过",
pending: "等待",
running: "处理中",
probing: "探测中",
ready: "就绪",
recorded: "已记录",
sent: "已发送",
requires: "依赖",
source: "来源 SOURCE",
"dry-run": "演练 DRY-RUN",
@@ -49,6 +51,7 @@ const el = {
routePath: byId("route-path"),
liveStatus: byId("live-status"),
liveDetail: byId("live-detail"),
agentChatStatus: byId("agent-chat-status"),
conversationList: byId("conversation-list"),
taskList: byId("task-list"),
traceList: byId("trace-list"),
@@ -60,6 +63,7 @@ const el = {
validationList: byId("validation-list"),
commandForm: byId("command-form"),
commandInput: byId("command-input"),
commandSend: byId("command-send"),
commandClear: byId("command-clear"),
hardwareList: byId("hardware-list"),
controlList: byId("control-list"),
@@ -72,7 +76,9 @@ const el = {
};
const state = {
drafts: []
conversationId: null,
chatMessages: [],
chatPending: false
};
initRoutes();
@@ -196,20 +202,85 @@ function selectSideTab(tabId) {
}
function initCommandBar() {
el.commandForm.addEventListener("submit", (event) => {
el.commandForm.addEventListener("submit", async (event) => {
event.preventDefault();
const value = el.commandInput.value.trim();
if (!value) return;
state.drafts.unshift({
if (!value || state.chatPending) return;
const userMessage = {
id: nextProtocolId("msg"),
role: "user",
title: `用户 ${shortTime(new Date().toISOString())}`,
text: value,
status: "sent",
createdAt: new Date().toISOString()
});
};
const pendingMessage = {
id: nextProtocolId("msg"),
role: "agent",
title: "Code Agent 处理中",
text: "正在调用真实 Code Agent / Codex 后端,请稍候。",
status: "running",
createdAt: new Date().toISOString()
};
state.chatMessages.push(userMessage, pendingMessage);
state.chatPending = true;
el.commandInput.value = "";
renderAgentChatStatus("running");
renderConversation();
renderDrafts();
try {
const result = await sendAgentMessage(value, state.conversationId);
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
const status = result.status === "completed" ? "completed" : "failed";
state.chatMessages[index] = {
...pendingMessage,
title: result.status === "completed" ? sourceTitle(result) : "Code Agent 返回失败",
text: result.status === "completed"
? result.reply?.content || "Code Agent 没有返回文本。"
: failureMessage(result),
status,
traceId: result.traceId,
messageId: result.messageId,
provider: result.provider,
model: result.model,
backend: result.backend,
updatedAt: result.updatedAt,
error: result.error
};
if (result.status !== "completed") {
el.commandInput.value = value;
}
renderAgentChatStatus(status, result);
} catch (error) {
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
state.chatMessages[index] = {
...pendingMessage,
title: "发送失败",
text: `发送失败:${error.message}`,
status: "failed",
updatedAt: new Date().toISOString(),
error: {
message: error.message
}
};
el.commandInput.value = value;
renderAgentChatStatus("failed");
} finally {
state.chatPending = false;
renderConversation();
renderDrafts();
}
});
el.commandClear.addEventListener("click", () => {
state.drafts = [];
state.chatMessages = [];
state.conversationId = null;
state.chatPending = false;
el.commandInput.value = "";
renderAgentChatStatus("idle");
renderConversation();
renderDrafts();
});
}
@@ -240,6 +311,28 @@ function renderProbePending() {
renderMethodList(rpcReadMethods, "pending");
}
async function sendAgentMessage(message, conversationId) {
const traceId = nextProtocolId("trc");
const response = await fetchJson("/v1/agent/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Trace-Id": traceId
},
body: JSON.stringify({
message,
conversationId,
traceId,
projectId: gateSummary.topology.projectId
})
});
if (!response.ok) {
throw new Error(response.error || "Code Agent 请求失败");
}
return response.data;
}
async function loadLiveSurface() {
const projectId = gateSummary.topology.projectId;
const [healthLive, restIndex, health, adapter, audit, evidence] = await Promise.all([
@@ -440,24 +533,21 @@ function renderCapabilityList() {
}
function renderConversation() {
const messages = [
const introMessages = [
{
role: "system",
title: "界面模式",
text: "这里是用户工作台:可以整理任务、查看资源、核对接线和可信记录。页面不会发送硬件变更。"
text: "这里是用户工作台:可以整理任务、查看资源、核对接线和可信记录。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。",
status: "source"
},
{
role: "agent",
title: "建议起步",
text: "先描述要检查的设备、端口或现象;我会把它保存成当前浏览器会话的任务草稿。"
},
{
role: "user",
title: "可查看内容",
text: "左侧是项目资源和常用能力;右侧提供硬件状态、接线表和可信记录。技术复核内容在二级页面。"
title: "Code Agent",
text: "请在底部输入中文消息并点击发送,我会通过 cloud-api 调用真实 Code Agent / Codex 能力返回回复。",
status: "source"
}
];
replaceChildren(el.conversationList, ...messages.map(messageCard));
replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard));
}
function renderTaskList() {
@@ -557,9 +647,11 @@ function controlRows() {
tone: "source"
},
{
title: "Agent 自动化",
detail: "输入区用于整理 Agent 任务草稿,不会直接调用 cloud-api 变更方法。",
tone: "source"
title: "Code Agent 对话",
detail: state.conversationId
? `当前会话 ${state.conversationId};回复来自 cloud-api 受控 Code Agent 接口。`
: "输入区会调用 cloud-api 受控 Code Agent 接口,不会直接调用硬件写入方法。",
tone: state.conversationId ? "dev-live" : "source"
},
{
title: "只读探测",
@@ -571,16 +663,16 @@ function controlRows() {
function renderDrafts() {
const rows = [
...state.drafts.map((draft) => ({
title: `本地草稿 ${shortTime(draft.createdAt)}`,
detail: draft.text,
tone: "dry-run"
...state.chatMessages.slice(-3).reverse().map((message) => ({
title: `${roleLabel(message.role)} ${statusLabel(message.status)}`,
detail: message.text,
tone: message.status || "source"
})),
...(state.drafts.length === 0
...(state.chatMessages.length === 0
? [
{
title: "草稿队列",
detail: "暂无本地草稿。输入区只在本地记录文本,不调用 cloud-api 变更方法。",
title: "对话状态",
detail: "暂无消息。输入区会发送到 Code Agent 后端,失败时会保留输入内容。",
tone: "source"
}
]
@@ -763,6 +855,38 @@ function renderMethodList(methods, tone) {
replaceChildren(el.methodList, ...methods.map((method) => badge(method, tone === "live" ? "dev-live" : tone)));
}
function renderAgentChatStatus(status, result = null) {
const labels = {
idle: "等待输入",
running: "处理中",
completed: "已回复",
failed: "发送失败"
};
el.agentChatStatus.textContent = labels[status] ?? statusLabel(status);
el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" ? "blocked" : "source")}`;
el.commandInput.disabled = status === "running";
el.commandSend.disabled = status === "running";
el.commandSend.textContent = status === "running" ? "发送中" : "发送";
if (result?.conversationId) {
el.agentChatStatus.title = `${result.provider ?? "provider"} / ${result.model ?? "model"} / ${result.backend ?? "backend"}`;
} else {
el.agentChatStatus.removeAttribute("title");
}
}
function sourceTitle(result) {
return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`;
}
function failureMessage(result) {
const missing = [
...(result.error?.missingCommands ?? []),
...(result.error?.missingEnv ?? [])
].filter(Boolean);
const suffix = missing.length ? ` 缺口:${missing.join("、")}` : "";
return `Code Agent 调用失败:${result.error?.message ?? "未知错误"}${suffix}请稍后重试或联系维护者补齐后端 provider。`;
}
function blockerCard(blocker) {
return infoCard({
title: blockerTitle(blocker),
@@ -773,10 +897,15 @@ function blockerCard(blocker) {
function messageCard(message) {
const article = document.createElement("article");
article.className = `message-card message-${toneClass(message.role)}`;
article.className = `message-card message-${toneClass(message.role)} status-${toneClass(message.status)}`;
article.append(textSpan(roleLabel(message.role), "message-role"));
article.append(textSpan(message.title, "message-title"));
article.append(textSpan(message.text, "message-copy"));
const meta = [message.provider, message.model, message.backend, message.traceId].filter(Boolean).join(" / ");
const messageMeta = [message.messageId, meta].filter(Boolean).join(" / ");
if (messageMeta) {
article.append(textSpan(messageMeta, "message-meta"));
}
return article;
}
+3 -3
View File
@@ -68,7 +68,7 @@
<p class="eyebrow">Agent 工作区</p>
<h2 id="workspace-title">Agent 对话</h2>
</div>
<span class="state-tag tone-source">本地草稿</span>
<span class="state-tag tone-source" id="agent-chat-status">等待输入</span>
</div>
<div class="conversation-list" id="conversation-list" aria-live="polite"></div>
</div>
@@ -196,10 +196,10 @@
id="command-input"
autocomplete="off"
type="text"
placeholder="在此编写 Agent 指令草稿;仅本地记录,不触发硬件变更。"
placeholder="输入要发送给 Code Agent 的消息;不会直接触发硬件变更。"
/>
</div>
<button class="command-button" type="submit">添加草稿</button>
<button class="command-button" id="command-send" type="submit">发送</button>
<button class="command-button secondary" id="command-clear" type="button">清空</button>
</form>
</section>
+16
View File
@@ -86,9 +86,11 @@ for (const workbenchElement of [
"explorer",
"resource-tree",
"conversation-list",
"agent-chat-status",
"task-list",
"right-sidebar",
"command-form",
"command-send",
"hardware-list",
"control-list",
"wiring-body",
@@ -174,6 +176,19 @@ assert.match(html, /quick-actions/);
assert.match(html, /编写任务/);
assert.match(html, /查看接线/);
assert.match(html, /查看状态/);
assert.match(html, /placeholder="输入要发送给 Code Agent 的消息;不会直接触发硬件变更。"/);
assert.match(html, />发送<\/button>/);
assert.doesNotMatch(html, />添加草稿<\/button>/);
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, /state\.conversationId/);
assert.match(app, /conversationId/);
assert.match(app, /messageId/);
assert.match(app, /createdAt/);
assert.match(app, /updatedAt/);
assert.match(app, /error\?\.message/);
assert.doesNotMatch(html, /M3 Diagnostics Console/);
assert.match(styles, /html,\s*\nbody\s*{[^}]*height:\s*100%;[^}]*overflow:\s*hidden;/s);
assert.match(styles, /body\s*>\s*\[data-app-shell\]\s*{[^}]*min-height:\s*0;/s);
@@ -191,6 +206,7 @@ assert.match(artifactPublisher, /readOnlyRpcMethods/);
assert.match(artifactPublisher, /requestUpstream/);
assert.match(artifactPublisher, /from "node:http"/);
assert.doesNotMatch(artifactPublisher, /await fetch\(target/);
assert.match(artifactPublisher, /url\.pathname === "\/v1\/agent\/chat"/);
assert.match(artifactPublisher, /"system\.health"/);
assert.match(artifactPublisher, /"cloud\.adapter\.describe"/);
assert.match(artifactPublisher, /"audit\.event\.query"/);
+23
View File
@@ -466,6 +466,14 @@ h3 {
padding: 10px;
}
.message-card.status-running {
border-color: rgba(223, 170, 85, 0.52);
}
.message-card.status-failed {
border-color: rgba(231, 110, 94, 0.58);
}
.message-role {
grid-row: span 2;
color: var(--accent);
@@ -482,6 +490,15 @@ h3 {
.message-copy {
color: var(--muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message-meta {
grid-column: 2;
color: var(--dim);
font-family: var(--mono);
font-size: 10px;
overflow-wrap: anywhere;
}
@@ -632,6 +649,12 @@ h3 {
cursor: pointer;
}
.command-button:disabled,
.input-shell input:disabled {
cursor: progress;
opacity: 0.68;
}
.command-button.secondary {
border-color: var(--line-strong);
background: var(--surface-2);