feat: add Code Agent status summary

Merge PR #358: compact Code Agent status summary for fallback/session/skill-control observability.
This commit is contained in:
Lyon
2026-05-23 23:11:58 +08:00
committed by GitHub
10 changed files with 759 additions and 6 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+43 -1
View File
@@ -73,6 +73,8 @@ const requiredWebAssets = Object.freeze([
"index.html",
"styles.css",
"app.mjs",
"code-agent-facts.mjs",
"code-agent-status.mjs",
"live-status.mjs",
"runtime.mjs",
"gate-summary.mjs",
@@ -83,7 +85,7 @@ const requiredWebAssets = Object.freeze([
"third_party/marked/LICENSE"
]);
const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs", "live-status.mjs", "wiring-status.mjs"]);
const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs", "code-agent-facts.mjs", "code-agent-status.mjs", "live-status.mjs", "wiring-status.mjs"]);
const chineseWorkbenchLabels = Object.freeze([
"HWLAB 云工作台",
@@ -534,6 +536,18 @@ function runStaticSmoke() {
evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"]
});
addCheck(checks, blockers, "code-agent-status-summary", hasCodeAgentStatusSummaryContract(files), "Code Agent area has a compact collapsible status summary for fallback, one-shot, read-only session, skill CLI control, blockers, trace, and current deployed revision.", {
blocker: "observability_blocker",
evidence: [
"code-agent-summary",
"fallback-text-chat-only",
"stateless-one-shot",
"read-only-session-tools",
"skill-cli-api-control",
"当前部署 revision"
]
});
addCheck(checks, blockers, "default-workspace-sanitized", defaultWorkspaceIsSanitized(files), "Default user workspace hides raw blocker codes, internal route markers, and credential reference material.", {
blocker: "observability_blocker",
evidence: defaultHomepageForbiddenTerms
@@ -1632,6 +1646,8 @@ function readStaticFiles() {
html: readText("web/hwlab-cloud-web/index.html"),
styles: readText("web/hwlab-cloud-web/styles.css"),
app: readText("web/hwlab-cloud-web/app.mjs"),
codeAgentFacts: readText("web/hwlab-cloud-web/code-agent-facts.mjs"),
codeAgentStatus: readText("web/hwlab-cloud-web/code-agent-status.mjs"),
liveStatus: readText("web/hwlab-cloud-web/live-status.mjs"),
wiringStatus: readText("web/hwlab-cloud-web/wiring-status.mjs"),
runtime: readText("web/hwlab-cloud-web/runtime.mjs"),
@@ -2464,6 +2480,32 @@ function hasCodeAgentLongTimeoutContract(app) {
);
}
function hasCodeAgentStatusSummaryContract({ html, app, styles, codeAgentStatus }) {
return (
/id=["']code-agent-summary["']/u.test(html) &&
/id=["']code-agent-summary-label["']/u.test(html) &&
/id=["']code-agent-summary-capability["']/u.test(html) &&
/id=["']code-agent-summary-trace["']/u.test(html) &&
/function\s+renderCodeAgentSummary\s*\(/u.test(app) &&
/classifyCodeAgentStatusSummary/u.test(app) &&
/currentCodeAgentStatusSummary/u.test(app) &&
/codeAgent\.status/u.test(app) &&
/当前部署 revision/u.test(app) &&
/fallback-text-chat-only/u.test(codeAgentStatus) &&
/stateless-one-shot/u.test(codeAgentStatus) &&
/read-only-session-tools/u.test(codeAgentStatus) &&
/skill-cli-api-control/u.test(codeAgentStatus) &&
/currentDeploymentRevision/u.test(codeAgentStatus) &&
/unsafeGreenForNonReady/u.test(codeAgentStatus) &&
/provider_config_blocked/u.test(codeAgentStatus) &&
!/sourceMain|SOURCE main|待部署 commit|targetCommit/u.test(codeAgentStatus) &&
/\.code-agent-summary\s*\{/u.test(styles) &&
/\.tone-border-warn/u.test(styles) &&
/\.tone-border-blocked/u.test(styles) &&
/\.tone-border-ok/u.test(styles)
);
}
function hasCodeAgentConversationUxStates({ app, styles }) {
const submitBody = functionBody(app, "initCommandBar");
return (
+64 -2
View File
@@ -6,6 +6,7 @@ import {
skillsSummary as compactSkillsSummary,
toolCallsSummary as compactToolCallsSummary
} from "./code-agent-facts.mjs";
import { classifyCodeAgentStatusSummary, codeAgentAvailabilityFromLive } from "./code-agent-status.mjs";
import { classifyWorkbenchLiveStatus } from "./live-status.mjs";
import { marked } from "./third_party/marked/marked.esm.js";
import { wiringPresentationFromSummary } from "./wiring-status.mjs";
@@ -96,6 +97,12 @@ const el = {
liveStatus: byId("live-status"),
liveDetail: byId("live-detail"),
agentChatStatus: byId("agent-chat-status"),
codeAgentSummary: byId("code-agent-summary"),
codeAgentSummaryIcon: byId("code-agent-summary-icon"),
codeAgentSummaryLabel: byId("code-agent-summary-label"),
codeAgentSummaryCapability: byId("code-agent-summary-capability"),
codeAgentSummaryTrace: byId("code-agent-summary-trace"),
codeAgentSummaryDetail: byId("code-agent-summary-detail"),
conversationList: byId("conversation-list"),
taskList: byId("task-list"),
gateSourceStatus: byId("gate-source-status"),
@@ -167,6 +174,7 @@ initM3Control();
initGateControls();
renderStaticWorkbench();
renderProbePending();
renderCodeAgentSummary();
loadHelpSurface();
loadLiveSurface().then(renderLiveSurface);
@@ -638,6 +646,7 @@ function initCommandBar() {
state.chatPending = true;
el.commandInput.value = "";
renderAgentChatStatus("running");
renderCodeAgentSummary();
renderConversation();
renderDrafts();
renderRecords(state.liveSurface);
@@ -696,6 +705,7 @@ function initCommandBar() {
el.commandInput.value = value;
}
renderAgentChatStatus(status, result);
renderCodeAgentSummary();
} catch (error) {
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
const presentation = agentFailurePresentation(error, { traceId });
@@ -715,8 +725,10 @@ function initCommandBar() {
};
el.commandInput.value = value;
renderAgentChatStatus("failed", state.chatMessages[index]);
renderCodeAgentSummary();
} finally {
state.chatPending = false;
renderCodeAgentSummary();
renderConversation();
renderDrafts();
renderRecords(state.liveSurface);
@@ -730,6 +742,7 @@ function initCommandBar() {
state.chatPending = false;
el.commandInput.value = "";
renderAgentChatStatus("idle");
renderCodeAgentSummary();
renderConversation();
renderDrafts();
renderRecords(state.liveSurface);
@@ -769,12 +782,14 @@ function renderStaticWorkbench() {
renderWiringList(null);
renderRecords(null);
renderGateTable();
renderCodeAgentSummary();
}
function renderProbePending() {
el.liveStatus.textContent = "等待验证";
el.liveStatus.className = "tone-pending";
el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。";
renderCodeAgentSummary();
}
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc")) {
@@ -1067,6 +1082,7 @@ function renderLiveSurface(live) {
el.liveDetail.textContent = surfaceStatus.detail ?? codeAgentAvailabilitySummary();
renderAgentChatStatus(deriveAgentChatStatus());
renderCodeAgentSummary();
renderHardwareStatus(state.m3Control.status);
renderWiringList(state.m3Control.status ?? runtimeSummary);
renderRecords(live);
@@ -2216,6 +2232,53 @@ function renderAgentChatStatus(status, result = null) {
}
}
function renderCodeAgentSummary() {
const summary = currentCodeAgentStatusSummary();
el.codeAgentSummary.className = `code-agent-summary tone-border-${toneClass(summary.tone)}`;
el.codeAgentSummary.dataset.codeAgentStatusKind = summary.kind;
el.codeAgentSummaryIcon.textContent = summary.icon;
el.codeAgentSummaryIcon.className = `code-agent-summary-icon tone-${toneClass(summary.tone)}`;
el.codeAgentSummaryLabel.textContent = summary.label;
el.codeAgentSummaryLabel.className = `code-agent-summary-label tone-${toneClass(summary.tone)}`;
el.codeAgentSummaryCapability.textContent = summary.capabilityLevel;
el.codeAgentSummaryTrace.textContent = `trace ${summary.lastTraceId}`;
replaceChildren(el.codeAgentSummaryDetail, ...codeAgentSummaryRows(summary));
}
function currentCodeAgentStatusSummary() {
return classifyCodeAgentStatusSummary({
availability: state.codeAgentAvailability,
latestMessage: latestChatResult(),
live: state.liveSurface
});
}
function codeAgentSummaryRows(summary) {
return [
codeAgentSummaryRow("codeAgent.status", summary.codeAgentStatus, summary.tone),
codeAgentSummaryRow("provider/mode/backend", summary.providerModeBackend, "source"),
codeAgentSummaryRow("capabilityLevel", summary.capabilityLevel, summary.tone),
codeAgentSummaryRow("session", summary.sessionSummary, sessionSummaryTone(summary)),
codeAgentSummaryRow("readiness blockers", summary.blockersLabel, summary.readinessBlockers.length > 0 ? "blocked" : "source"),
codeAgentSummaryRow("last traceId", summary.lastTraceId, "source"),
codeAgentSummaryRow("当前部署 revision", summary.deploymentRevision, "source")
];
}
function codeAgentSummaryRow(label, value, tone) {
const row = document.createElement("div");
row.className = "code-agent-summary-row";
row.append(textSpan(label, "code-agent-summary-key"), textSpan(value, `code-agent-summary-value tone-${toneClass(tone)}`));
return row;
}
function sessionSummaryTone(summary) {
if (summary.kind === "long-lived-session") return "ok";
if (summary.kind === "blocked" || summary.kind === "skill-cli-api-blocked") return "blocked";
if (["read-only-session-tools", "stateless-one-shot", "fallback-text-chat-only", "degraded"].includes(summary.kind)) return "warn";
return "source";
}
function agentStatusLabel(status, result, labels) {
if (status !== "failed") return labels[status] ?? statusLabel(status);
const presentation = agentFailurePresentation(result?.error, { result });
@@ -2683,8 +2746,7 @@ function isHttpNon2xxStatus(value) {
}
function codeAgentAvailabilityFrom(live) {
const availability = live?.restIndex?.data?.codeAgent ?? live?.healthLive?.data?.codeAgent ?? null;
return sanitizeCodeAgentAvailability(availability);
return sanitizeCodeAgentAvailability(codeAgentAvailabilityFromLive(live));
}
function codeAgentAvailabilityFromResult(result) {
+313
View File
@@ -0,0 +1,313 @@
const GREEN_TONES = new Set(["ok", "available", "ready", "dev-live", "live", "pass"]);
const SECRET_MARKER = /\b(?:OPENAI_API_KEY|DATABASE_URL|secret|secretref|token|password|passwd|credential|private key|kubeconfig|密钥|密码|凭证)\b/iu;
const NON_LIVE_VALUES = new Set(["", "n/a", "none", "null", "undefined", "unknown", "not_observed", "not-observed"]);
const MAX_FIELD_LENGTH = 96;
export function classifyCodeAgentStatusSummary({ availability = null, latestMessage = null, live = null } = {}) {
const observedAvailability = availability ?? codeAgentAvailabilityFromLive(live);
const payload = safeMerge(observedAvailability, latestMessage);
const blockers = readinessBlockers(payload);
const classification = classifyPayload(payload, blockers);
const safeTone = unsafeGreenForNonReady(classification, blockers) ? "warn" : classification.tone;
const provider = safeField(firstString(payload.provider, observedAvailability?.provider));
const mode = safeField(firstString(payload.mode, payload.providerTrace?.mode, observedAvailability?.mode));
const backend = safeField(firstString(payload.backend, observedAvailability?.backend));
const codeAgentStatus = safeField(firstString(payload.status, payload.readinessStatus, observedAvailability?.status));
const capabilityLevel = safeField(firstString(payload.capabilityLevel, payload.runner?.capabilityLevel, observedAvailability?.capabilityLevel));
const sessionMode = safeField(firstString(payload.sessionMode, payload.session?.sessionMode, payload.runnerTrace?.sessionMode, payload.runner?.sessionMode));
const sessionStatus = safeField(firstString(payload.session?.status, payload.sessionStatus, payload.runnerTrace?.sessionStatus, payload.sessionReuse?.status));
const lastTraceId = safeTraceId(firstString(
latestMessage?.traceId,
payload.traceId,
payload.session?.lastTraceId,
payload.runnerTrace?.lastTraceId,
payload.runnerTrace?.traceId,
payload.providerTrace?.traceId,
observedAvailability?.traceId
));
const deploymentRevision = currentDeploymentRevision(live);
return {
...classification,
tone: safeTone,
provider,
mode,
backend,
providerModeBackend: compactJoin([provider, mode, backend], " / "),
codeAgentStatus,
capabilityLevel,
sessionMode,
sessionStatus,
sessionSummary: compactJoin([sessionMode, sessionStatus], " / "),
readinessBlockers: blockers,
blockersLabel: blockers.length > 0 ? blockers.join(", ") : "无",
lastTraceId: lastTraceId || "未观测",
deploymentRevision,
fields: {
provider,
mode,
backend,
codeAgentStatus,
capabilityLevel,
sessionMode,
sessionStatus,
readinessBlockers: blockers,
lastTraceId: lastTraceId || null,
deploymentRevision
}
};
}
export function codeAgentAvailabilityFromLive(live = {}) {
return live?.restIndex?.data?.codeAgent ?? live?.healthLive?.data?.codeAgent ?? live?.health?.data?.codeAgent ?? null;
}
export function currentDeploymentRevision(live = {}) {
const payloads = [
live?.healthLive?.data,
live?.restIndex?.data,
live?.health?.data,
live?.adapter?.data
].filter(Boolean);
for (const payload of payloads) {
const revision = firstString(
payload?.commit?.id,
payload?.commitId,
payload?.revision,
payload?.runtime?.commitId,
payload?.image?.tag,
payload?.imageTag,
payload?.runtime?.imageTag
);
if (revision && !NON_LIVE_VALUES.has(revision.toLowerCase())) {
return shortRevision(revision);
}
}
return "未观测";
}
function classifyPayload(payload, blockers) {
if (!payload || Object.keys(payload).length === 0) {
return status("unverified", "探测中", "pending", "○", "等待同源接口返回 Code Agent 状态。");
}
if (isSkillCliApiControl(payload)) {
const ready = isReadySkillCli(payload);
return ready
? status("skill-cli-api-control", "HWLAB Skill CLI 控制", "ok", "◆", "通过 HWLAB API 受控路径执行指定能力。")
: status("skill-cli-api-blocked", "Skill CLI 受阻", "blocked", "×", "HWLAB Skill CLI 控制路径当前受阻。");
}
if (isLongLivedReady(payload)) {
return status("long-lived-session", "长会话可用", "ok", "●", "repo-owned 长会话通道可复用。");
}
if (isReadOnlySessionTools(payload)) {
return status("read-only-session-tools", "只读会话工具", "warn", "◇", "只读 runner 工具可用,不能写工作区或冒充完整长会话。");
}
if (isStatelessOneShot(payload)) {
return status("stateless-one-shot", "一次性执行", "warn", "△", "当前回复来自一次性执行,不是可复用长会话。");
}
if (isBlocked(payload)) {
return status("blocked", "服务受阻", "blocked", "×", "Code Agent 通道暂不可用或 readiness 未通过。");
}
if (isFallbackTextChatOnly(payload)) {
return status("fallback-text-chat-only", "文本备用通道", "warn", "◌", "只有文本对话能力,没有 workspace tools 或 session 控制。");
}
if (isDegraded(payload, blockers)) {
return status("degraded", "降级可用", "warn", "!", "Code Agent 有部分能力,但 readiness 仍有阻塞项。");
}
if (payload.status === "available" || payload.ready === true || payload.status === "completed") {
return status("available", "通道可用", "ok", "●", "受控对话通道已返回可用状态。");
}
return status("unverified", "探测中", "pending", "○", "等待同源接口返回 Code Agent 状态。");
}
function status(kind, label, tone, icon, summary) {
return { kind, label, tone, icon, summary };
}
function safeMerge(availability, latestMessage) {
const base = availability && typeof availability === "object" ? availability : {};
const message = latestMessage && typeof latestMessage === "object" ? latestMessage : {};
const merged = {
...base,
...message,
runner: { ...(base.runner ?? {}), ...(message.runner ?? {}) },
session: mergeSession(base.session, message.session),
runnerTrace: { ...(base.runnerTrace ?? {}), ...(message.runnerTrace ?? {}) },
providerTrace: { ...(base.providerTrace ?? {}), ...(message.providerTrace ?? {}) },
longLivedSessionGate: { ...(base.longLivedSessionGate ?? {}), ...(message.longLivedSessionGate ?? {}) },
codexStdioFeasibility: { ...(base.codexStdioFeasibility ?? base.codexStdio ?? {}), ...(message.codexStdioFeasibility ?? {}) }
};
if (base.blockers || message.blockers) merged.blockers = [...(base.blockers ?? []), ...(message.blockers ?? [])];
if (base.blockerCodes || message.blockerCodes) merged.blockerCodes = [...(base.blockerCodes ?? []), ...(message.blockerCodes ?? [])];
if (base.runnerLimitations || message.runnerLimitations) merged.runnerLimitations = [...(base.runnerLimitations ?? []), ...(message.runnerLimitations ?? [])];
if (message.toolCalls !== undefined) merged.toolCalls = message.toolCalls;
if (message.skills !== undefined) merged.skills = message.skills;
return merged;
}
function mergeSession(baseSession, messageSession) {
const base = typeof baseSession === "string" ? { status: baseSession } : (baseSession ?? {});
const message = typeof messageSession === "string" ? { status: messageSession } : (messageSession ?? {});
return { ...base, ...message };
}
function isSkillCliApiControl(payload) {
return (
payload.provider === "hwlab-skill-cli" ||
payload.runner?.kind === "hwlab-m3-io-skill-cli" ||
payload.sessionMode === "controlled-m3-io-skill-cli" ||
String(payload.capabilityLevel ?? "").startsWith("hwlab-api-control") ||
array(payload.toolCalls).some((tool) => tool?.type === "skill-cli" || tool?.route === "/v1/m3/io")
);
}
function isReadySkillCli(payload) {
return (
payload.capabilityLevel === "hwlab-api-control-ready" ||
payload.providerTrace?.controlReady === true ||
array(payload.toolCalls).some((tool) => tool?.controlReady === true && tool?.status === "completed")
) && !hasBlockingSkillCliSignal(payload);
}
function hasBlockingSkillCliSignal(payload) {
return payload.capabilityLevel === "hwlab-api-control-blocked" ||
payload.providerTrace?.controlReady === false ||
array(payload.toolCalls).some((tool) => tool?.status === "blocked" || tool?.controlReady === false);
}
function isLongLivedReady(payload) {
return (
payload.longLivedSessionGate?.status === "pass" ||
payload.longLivedSessionGate?.pass === true ||
payload.codexStdioFeasibility?.canStartLongLivedCodexStdio === true ||
payload.capabilityLevel === "long-lived-codex-stdio-session" ||
payload.sessionMode === "codex-mcp-stdio-long-lived"
) && payload.status !== "failed" && payload.status !== "blocked" && payload.ready !== false;
}
function isReadOnlySessionTools(payload) {
return (
payload.capabilityLevel === "read-only-session-tools" ||
payload.capabilityLevel === "read-only-tools" ||
payload.agentKind === "controlled-readonly-session-registry" ||
payload.sessionMode === "controlled-readonly-session-registry" ||
payload.implementationType === "controlled-readonly-session-registry" ||
payload.runner?.kind === "hwlab-readonly-runner" ||
(payload.runner?.writeCapable === false && payload.capabilityLevel !== "text-chat-only" && payload.runner?.kind !== "openai-responses-fallback") ||
payload.partialReady === true
);
}
function isStatelessOneShot(payload) {
return (
payload.sessionMode === "ephemeral-one-shot" ||
payload.runner?.kind === "codex-cli-one-shot-ephemeral" ||
payload.implementationType === "codex-cli-one-shot-ephemeral" ||
array(payload.runnerLimitations).includes("one-shot")
);
}
function isFallbackTextChatOnly(payload) {
return (
payload.capabilityLevel === "text-chat-only" ||
payload.runner?.kind === "openai-responses-fallback" ||
payload.implementationType === "openai-responses-fallback" ||
payload.agentKind === "openai-fallback" ||
payload.provider === "openai-responses"
);
}
function isBlocked(payload) {
return payload.status === "blocked" ||
payload.status === "failed" ||
payload.ready === false ||
payload.capabilityLevel === "blocked" ||
Boolean(payload.error);
}
function isDegraded(payload, blockers) {
return payload.status === "partial" ||
payload.status === "degraded" ||
payload.readinessStatus === "partial" ||
payload.capabilityStatus === "controlled-readonly-session-registry" ||
blockers.length > 0;
}
function readinessBlockers(payload) {
const values = [
...array(payload?.blockerCodes),
...array(payload?.blockers).map((item) => item?.code ?? item),
...array(payload?.longLivedSessionGate?.blockers).map((item) => item?.code ?? item),
...array(payload?.codexStdioFeasibility?.blockers).map((item) => item?.code ?? item),
payload?.reason,
payload?.error?.code,
payload?.providerStatus ? `provider_http_${payload.providerStatus}` : null,
payload?.error?.providerStatus ? `provider_http_${payload.error.providerStatus}` : null,
...array(payload?.missingEnv).map(() => "provider_config_blocked")
];
return [...new Set(values.map(safeBlocker).filter(Boolean))].slice(0, 4);
}
function safeBlocker(value) {
if (value && typeof value === "object" && !Array.isArray(value) && !value.code && !value.reason && !value.message) return null;
const text = safeField(typeof value === "object" ? value?.code ?? value?.reason ?? value?.message : value);
if (!text || text === "未观测" || /^_+$/u.test(text)) return null;
if (SECRET_MARKER.test(text)) return "provider_config_blocked";
return text.replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 72);
}
function unsafeGreenForNonReady(classification, blockers) {
return GREEN_TONES.has(classification.tone) &&
(classification.kind.includes("blocked") || classification.kind === "degraded" || blockers.some((blocker) => /blocked|unavailable|failed|degraded/iu.test(blocker)));
}
function currentString(value) {
const text = String(value ?? "").trim();
if (!text || NON_LIVE_VALUES.has(text.toLowerCase())) return null;
return text;
}
function firstString(...values) {
for (const value of values) {
const text = currentString(value);
if (text) return text;
}
return null;
}
function safeField(value) {
const text = currentString(value);
if (!text) return "未观测";
if (SECRET_MARKER.test(text)) return "已隐藏";
return text.replace(/\s+/gu, " ").slice(0, MAX_FIELD_LENGTH);
}
function safeTraceId(value) {
const text = currentString(value);
if (!text || SECRET_MARKER.test(text)) return null;
return text.replace(/\s+/gu, "").slice(0, MAX_FIELD_LENGTH);
}
function shortRevision(value) {
const text = safeTraceId(value);
if (!text) return "未观测";
return /^[a-f0-9]{12,40}$/iu.test(text) ? text.slice(0, 12) : text.slice(0, 24);
}
function compactJoin(values, separator) {
const parts = values.map((value) => value === "未观测" ? null : value).filter(Boolean);
return parts.length > 0 ? parts.join(separator) : "未观测";
}
function array(value) {
return Array.isArray(value) ? value : [];
}
@@ -0,0 +1,189 @@
import assert from "node:assert/strict";
import test from "node:test";
import { classifyCodeAgentStatusSummary } from "./code-agent-status.mjs";
const liveRevision = Object.freeze({
healthLive: {
ok: true,
status: 200,
data: {
serviceId: "hwlab-cloud-api",
status: "ok",
commitId: "7de6edd2c41f54e4265e822d2593fa67057fa41a"
}
}
});
test("summarizes long-lived Code Agent as green with current deployment revision only", () => {
const summary = classifyCodeAgentStatusSummary({
live: liveRevision,
availability: {
status: "codex-stdio-feasible",
ready: true,
provider: "codex-stdio",
mode: "codex-stdio",
backend: "hwlab-cloud-api/codex-stdio",
capabilityLevel: "long-lived-codex-stdio-session",
sessionMode: "codex-mcp-stdio-long-lived",
session: {
status: "idle",
lastTraceId: "trc_ready"
},
longLivedSessionGate: {
status: "pass"
},
codexStdioFeasibility: {
canStartLongLivedCodexStdio: true
}
}
});
assert.equal(summary.kind, "long-lived-session");
assert.equal(summary.label, "长会话可用");
assert.equal(summary.tone, "ok");
assert.equal(summary.codeAgentStatus, "codex-stdio-feasible");
assert.equal(summary.providerModeBackend, "codex-stdio / codex-stdio / hwlab-cloud-api/codex-stdio");
assert.equal(summary.capabilityLevel, "long-lived-codex-stdio-session");
assert.equal(summary.sessionSummary, "codex-mcp-stdio-long-lived / idle");
assert.equal(summary.readinessBlockers.length, 0);
assert.equal(summary.lastTraceId, "trc_ready");
assert.equal(summary.deploymentRevision, "7de6edd2c41f");
assert.equal(Object.keys(summary.fields).includes("sourceMain"), false);
});
test("maps degraded readonly session tools to warning, not green", () => {
const summary = classifyCodeAgentStatusSummary({
availability: {
status: "partial",
ready: false,
provider: "codex-readonly-runner",
backend: "hwlab-cloud-api/controlled-readonly-session-registry",
capabilityLevel: "read-only-session-tools",
sessionMode: "controlled-readonly-session-registry",
session: {
status: "idle",
lastTraceId: "trc_readonly"
},
runner: {
kind: "hwlab-readonly-runner",
writeCapable: false
},
blockers: [
{ code: "stdio_protocol_not_wired" }
]
}
});
assert.equal(summary.kind, "read-only-session-tools");
assert.equal(summary.label, "只读会话工具");
assert.equal(summary.tone, "warn");
assert.equal(summary.readinessBlockers.includes("stdio_protocol_not_wired"), true);
assert.equal(summary.lastTraceId, "trc_readonly");
});
test("maps text fallback to warning with safe labels", () => {
const summary = classifyCodeAgentStatusSummary({
availability: {
status: "available",
ready: true,
provider: "openai-responses",
mode: "openai",
backend: "hwlab-cloud-api/openai-responses",
capabilityLevel: "text-chat-only",
runner: {
kind: "openai-responses-fallback"
}
}
});
assert.equal(summary.kind, "fallback-text-chat-only");
assert.equal(summary.label, "文本备用通道");
assert.equal(summary.tone, "warn");
assert.equal(summary.provider, "openai-responses");
assert.equal(summary.capabilityLevel, "text-chat-only");
});
test("maps stateless one-shot runner separately from long-lived sessions", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
status: "completed",
provider: "codex-cli",
backend: "hwlab-cloud-api/codex-cli",
capabilityLevel: "text-chat-only",
sessionMode: "ephemeral-one-shot",
runner: {
kind: "codex-cli-one-shot-ephemeral"
},
runnerLimitations: ["one-shot", "not-durable-session"],
traceId: "trc_one_shot"
}
});
assert.equal(summary.kind, "stateless-one-shot");
assert.equal(summary.label, "一次性执行");
assert.equal(summary.tone, "warn");
assert.equal(summary.sessionMode, "ephemeral-one-shot");
assert.equal(summary.lastTraceId, "trc_one_shot");
});
test("maps HWLAB Skill CLI API control to distinct green state when ready", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
status: "completed",
provider: "hwlab-skill-cli",
backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli",
capabilityLevel: "hwlab-api-control-ready",
sessionMode: "controlled-m3-io-skill-cli",
session: {
status: "idle",
lastTraceId: "trc_skill"
},
toolCalls: [
{
type: "skill-cli",
route: "/v1/m3/io",
status: "completed",
controlReady: true
}
],
providerTrace: {
controlReady: true
}
}
});
assert.equal(summary.kind, "skill-cli-api-control");
assert.equal(summary.label, "HWLAB Skill CLI 控制");
assert.equal(summary.tone, "ok");
assert.equal(summary.provider, "hwlab-skill-cli");
assert.equal(summary.capabilityLevel, "hwlab-api-control-ready");
});
test("maps blockers to blocked tone and redacts secret-like fields", () => {
const summary = classifyCodeAgentStatusSummary({
availability: {
status: "blocked",
ready: false,
provider: "openai-responses",
backend: "Secret hwlab-code-agent-provider should not show",
capabilityLevel: "blocked",
reason: "OPENAI_API_KEY_missing",
error: {
code: "provider_unavailable",
providerStatus: 503
},
missingEnv: ["OPENAI_API_KEY"],
traceId: "trc_blocked"
}
});
assert.equal(summary.kind, "blocked");
assert.equal(summary.label, "服务受阻");
assert.equal(summary.tone, "blocked");
assert.equal(summary.backend, "已隐藏");
assert.equal(summary.readinessBlockers.includes("provider_config_blocked"), true);
assert.equal(summary.readinessBlockers.includes("provider_unavailable"), true);
assert.equal(summary.readinessBlockers.includes("provider_http_503"), true);
assert.equal(summary.lastTraceId, "trc_blocked");
});
+9
View File
@@ -83,6 +83,15 @@
</div>
<span class="state-tag tone-source" id="agent-chat-status">等待输入</span>
</div>
<details class="code-agent-summary tone-border-pending" id="code-agent-summary">
<summary>
<span class="code-agent-summary-icon tone-pending" id="code-agent-summary-icon" aria-hidden="true"></span>
<strong class="code-agent-summary-label tone-pending" id="code-agent-summary-label">探测中</strong>
<span class="code-agent-summary-capability" id="code-agent-summary-capability">未观测</span>
<span class="code-agent-summary-trace" id="code-agent-summary-trace">trace 未观测</span>
</summary>
<div class="code-agent-summary-detail" id="code-agent-summary-detail"></div>
</details>
<div class="conversation-list" id="conversation-list" aria-live="polite"></div>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"m3-readonly": "node scripts/m3-readonly-contract.mjs",
"check": "node --test code-agent-facts.test.mjs wiring-status.test.mjs && node scripts/check.mjs",
"check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs && node scripts/check.mjs",
"build": "node scripts/build.mjs",
"layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report ../../reports/dev-gate/dev-cloud-workbench-layout.json",
"layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report ../../reports/dev-gate/dev-cloud-workbench-layout-build.json",
+23
View File
@@ -23,6 +23,7 @@ const requiredFiles = [
"styles.css",
"app.mjs",
"code-agent-facts.mjs",
"code-agent-status.mjs",
"live-status.mjs",
"gate-summary.mjs",
"runtime.mjs",
@@ -44,6 +45,7 @@ const html = fs.readFileSync(path.resolve(rootDir, "index.html"), "utf8");
const styles = fs.readFileSync(path.resolve(rootDir, "styles.css"), "utf8");
const app = fs.readFileSync(path.resolve(rootDir, "app.mjs"), "utf8");
const codeAgentFacts = fs.readFileSync(path.resolve(rootDir, "code-agent-facts.mjs"), "utf8");
const codeAgentStatus = fs.readFileSync(path.resolve(rootDir, "code-agent-status.mjs"), "utf8");
const liveStatus = fs.readFileSync(path.resolve(rootDir, "live-status.mjs"), "utf8");
const wiringStatus = fs.readFileSync(path.resolve(rootDir, "wiring-status.mjs"), "utf8");
const helpMarkdown = fs.readFileSync(path.resolve(rootDir, "help.md"), "utf8");
@@ -325,6 +327,10 @@ for (const workbenchElement of [
"resource-tree",
"conversation-list",
"agent-chat-status",
"code-agent-summary",
"code-agent-summary-label",
"code-agent-summary-capability",
"code-agent-summary-trace",
"task-list",
"right-sidebar",
"right-sidebar-resize",
@@ -645,6 +651,11 @@ assert.match(app, /function boundedEvidenceField/);
assert.match(app, /codeAgentFactsFromMessage/);
assert.match(app, /function codeAgentCapabilityFactsPanel/);
assert.match(app, /compactHwlabApiFact/);
assert.match(app, /function renderCodeAgentSummary/);
assert.match(app, /currentCodeAgentStatusSummary/);
assert.match(app, /classifyCodeAgentStatusSummary/);
assert.match(app, /codeAgent\.status/);
assert.match(app, /当前部署 revision/);
assert.match(codeAgentFacts, /openai-responses-fallback/);
assert.match(codeAgentFacts, /text-chat-only/);
assert.match(codeAgentFacts, /stateless-one-shot/);
@@ -655,6 +666,14 @@ assert.match(codeAgentFacts, /operationId", fact\.operationId/);
assert.match(codeAgentFacts, /auditId", fact\.auditId/);
assert.match(codeAgentFacts, /evidenceId", fact\.evidenceId/);
assert.match(codeAgentFacts, /blocker", fact\.blocker/);
assert.match(codeAgentStatus, /fallback-text-chat-only/);
assert.match(codeAgentStatus, /stateless-one-shot/);
assert.match(codeAgentStatus, /read-only-session-tools/);
assert.match(codeAgentStatus, /skill-cli-api-control/);
assert.match(codeAgentStatus, /currentDeploymentRevision/);
assert.match(codeAgentStatus, /unsafeGreenForNonReady/);
assert.match(codeAgentStatus, /provider_config_blocked/);
assert.doesNotMatch(codeAgentStatus, /sourceMain|SOURCE main|待部署 commit|targetCommit/u);
assert.match(app, /sourceKind:\s*completion\.sourceKind/);
assert.match(app, /status === "completed" \? "dev-live"/);
assert.doesNotMatch(app, /status === "failed" \? "dev-live"/);
@@ -669,6 +688,10 @@ assert.match(styles, /\.message-evidence\s*{/);
assert.match(styles, /\.message-evidence-chip\s*{/);
assert.match(styles, /\.code-agent-facts\s*{/);
assert.match(styles, /\.code-agent-fact-grid\s*{/);
assert.match(styles, /\.code-agent-summary\s*{/);
assert.match(styles, /\.tone-border-warn/);
assert.match(styles, /\.tone-border-blocked/);
assert.match(styles, /\.tone-border-ok/);
assert.match(styles, /\.hwlab-api-fact-code\s*{/);
assert.match(styles, /\.hwlab-api-fact-code\s*{[^}]*user-select:\s*all;/s);
assert.match(styles, /\.rail-button\s*{[^}]*min-width:\s*0;[^}]*min-height:\s*36px;[^}]*text-align:\s*center;[^}]*word-break:\s*keep-all;/s);
@@ -10,6 +10,7 @@ export const cloudWebDistRuntimeFiles = Object.freeze([
"styles.css",
"app.mjs",
"code-agent-facts.mjs",
"code-agent-status.mjs",
"live-status.mjs",
"gate-summary.mjs",
"runtime.mjs",
+115 -1
View File
@@ -527,7 +527,10 @@ h3 {
padding: 12px;
}
.conversation-panel,
.conversation-panel {
grid-template-rows: auto auto minmax(0, 1fr);
}
.task-panel {
grid-template-rows: auto minmax(0, 1fr);
}
@@ -580,6 +583,99 @@ h3 {
gap: 7px;
}
.code-agent-summary {
min-width: 0;
display: grid;
gap: 8px;
padding: 8px 10px;
background: rgba(15, 17, 16, 0.72);
border: 1px solid var(--line);
border-left-width: 3px;
}
.code-agent-summary summary {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(88px, auto) minmax(120px, 1fr) minmax(110px, auto);
gap: 8px;
align-items: center;
cursor: pointer;
list-style: none;
}
.code-agent-summary summary::-webkit-details-marker {
display: none;
}
.code-agent-summary summary::after {
content: "展开";
justify-self: end;
color: var(--dim);
font-size: 10px;
}
.code-agent-summary[open] summary::after {
content: "收起";
}
.code-agent-summary-icon {
font-family: var(--mono);
font-size: 13px;
line-height: 1;
}
.code-agent-summary-label {
min-width: 0;
font-size: 12px;
overflow-wrap: anywhere;
}
.code-agent-summary-capability,
.code-agent-summary-trace {
min-width: 0;
color: var(--muted);
font-family: var(--mono);
font-size: 10px;
overflow-wrap: anywhere;
}
.code-agent-summary-trace {
justify-self: end;
text-align: right;
}
.code-agent-summary-detail {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px 8px;
}
.code-agent-summary-row {
min-width: 0;
display: grid;
grid-template-columns: minmax(92px, 0.8fr) minmax(0, 1.2fr);
gap: 6px;
align-items: start;
padding: 5px 6px;
background: var(--surface);
border: 1px solid var(--line-soft);
}
.code-agent-summary-key {
min-width: 0;
color: var(--dim);
font-family: var(--mono);
font-size: 10px;
overflow-wrap: anywhere;
}
.code-agent-summary-value {
min-width: 0;
font-family: var(--mono);
font-size: 10px;
overflow-wrap: anywhere;
}
.message-card,
.info-card,
.metric {
@@ -1526,10 +1622,16 @@ tbody tr:last-child td {
}
.tone-border-source,
.tone-border-pending,
.tone-border-disabled {
border-left-color: var(--dim);
}
.tone-border-warn,
.tone-border-warning {
border-left-color: var(--warn);
}
.tone-border-degraded,
.tone-border-blocked,
.tone-border-failed {
@@ -1727,9 +1829,21 @@ tbody tr:last-child td {
}
.code-agent-facts-head,
.code-agent-fact-grid,
.code-agent-summary summary,
.code-agent-summary-detail,
.code-agent-summary-row {
grid-template-columns: 1fr;
}
.code-agent-fact-grid {
grid-template-columns: 1fr;
}
.code-agent-summary-trace {
justify-self: start;
text-align: left;
}
}
@media (max-width: 520px) {