From 0b5311e676d19b8a76172102f1e6e9215cdfcddd Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sun, 24 May 2026 04:22:17 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20Code=20Agent=20M3?= =?UTF-8?q?=20IO=20=E7=BB=93=E6=9E=84=E5=8C=96=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/code-agent-chat.mjs | 265 ++++++++++++++++-- internal/cloud/server.test.mjs | 167 ++++++++++- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 13 +- web/hwlab-cloud-web/app.mjs | 108 ++++++- .../code-agent-m3-evidence.mjs | 152 ++++++++-- .../code-agent-m3-evidence.test.mjs | 90 +++++- web/hwlab-cloud-web/scripts/check.mjs | 3 +- web/hwlab-cloud-web/styles.css | 28 ++ 8 files changed, 750 insertions(+), 76 deletions(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index ddb38ace..cf79951f 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -63,6 +63,13 @@ const M3_IO_SKILL_LIMITATION_FLAGS = Object.freeze([ "not-generic-hardware-control", "not-durable-session" ]); +const M3_IO_TOPOLOGY = Object.freeze({ + sourceResourceId: "res_boxsimu_1", + sourcePort: "DO1", + patchPanelServiceId: "hwlab-patch-panel", + targetResourceId: "res_boxsimu_2", + targetPort: "DI1" +}); const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback"; const DIRECT_HARDWARE_TARGET_PATTERN = /https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)[^\s"']*|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b/iu; const READONLY_TOOL_OUTPUT_LIMIT = 4000; @@ -375,6 +382,8 @@ function completedRunnerPayload({ base, runnerResult, messageId, now, sessionReg runner: runnerResult.runner, runnerTrace: runnerResult.runnerTrace, capabilityLevel: runnerResult.capabilityLevel, + ...(runnerResult.responseType ? { responseType: runnerResult.responseType } : {}), + ...(runnerResult.m3Io ? { m3Io: runnerResult.m3Io } : {}), reply: { messageId, role: "assistant", @@ -1039,13 +1048,21 @@ function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs ); const route = m3SkillRoute(skillResult); const method = m3SkillMethod(skillResult); + const m3Io = m3IoStructuredResult({ + skillResult, + stdioResult, + commandArgs, + route, + method + }); const toolCall = m3IoSkillToolCall({ skillResult, commandArgs, cwd: stdioResult.workspace, capabilityLevel, route, - method + method, + responseType: m3Io.type }); const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []); const runnerTrace = { @@ -1096,7 +1113,9 @@ function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs return { ...stdioResult, - content: `${stdioResult.content}\n\n${m3IoSkillReply(skillResult)}`, + content: m3IoSkillReply(skillResult, m3Io), + responseType: m3Io.type, + m3Io, toolCalls: [ ...(stdioResult.toolCalls ?? []), toolCall @@ -1118,7 +1137,7 @@ function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs toolName: HWLAB_M3_IO_SKILL_NAME, providerTrace: { ...(stdioResult.providerTrace ?? {}), - ...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }), + ...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType: m3Io.type }), runnerKind: stdioResult.runner?.kind ?? CODEX_STDIO_RUNNER_KIND, codexStdio: true, transport: "stdio+skill-cli" @@ -1126,11 +1145,12 @@ function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs }; } -function m3IoSkillToolCall({ skillResult, commandArgs, cwd, capabilityLevel, route, method }) { +function m3IoSkillToolCall({ skillResult, commandArgs, cwd, capabilityLevel, route, method, responseType }) { return { id: `tool_${randomUUID()}`, type: "skill-cli", name: HWLAB_M3_IO_SKILL_NAME, + responseType, status: skillResult.status === "succeeded" || skillResult.accepted === true ? "completed" : "blocked", cwd, command: redactText(`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs ${redactM3IoCommandArgs(commandArgs).join(" ")}`), @@ -1191,10 +1211,11 @@ function m3IoSkillItem({ capabilityLevel, route }) { }; } -function m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }) { +function m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType }) { return { runnerKind: M3_IO_SKILL_RUNNER_KIND, skill: HWLAB_M3_IO_SKILL_NAME, + responseType, route, method, traceId: skillResult.traceId, @@ -1210,6 +1231,181 @@ function m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }) }; } +function m3IoStructuredResult({ skillResult = {}, stdioResult = {}, commandArgs = [], route = HWLAB_M3_IO_API_ROUTE, method = "POST" }) { + const blocker = m3IoPrimaryBlocker(skillResult); + const isBlocker = Boolean( + blocker || + skillResult.ok === false || + skillResult.accepted === false || + String(skillResult.status ?? "").toLowerCase() === "blocked" + ); + const action = skillResult.action ?? skillResult.command?.action ?? m3IoActionFromArgs(commandArgs) ?? (route === HWLAB_M3_STATUS_API_ROUTE ? "status" : "m3.io"); + const do1Value = action === "do.write" + ? firstDefined(skillResult.command?.value, skillResult.result?.value, skillResult.value) ?? null + : null; + const di1Value = action === "di.read" + ? firstDefined(skillResult.result?.value, skillResult.readback?.value) ?? null + : firstDefined(skillResult.result?.targetReadback?.value, skillResult.readback?.value) ?? null; + const durable = skillResult.durable ?? {}; + const evidenceSourceKind = skillResult.evidence?.sourceKind ?? null; + const auditStatus = skillResult.audit?.status ?? null; + const evidenceStatus = skillResult.evidence?.status ?? null; + const trusted = durable.durable === true && + evidenceSourceKind === "DEV-LIVE" && + !skillResult.trustBlocker && + ["persisted", "green"].includes(String(auditStatus ?? evidenceStatus ?? "").toLowerCase()); + const pathSummary = `Code Agent -> Skill CLI -> HWLAB API ${route}`; + + return { + type: isBlocker ? "m3_io_blocker" : "m3_io_result", + status: skillResult.status ?? (isBlocker ? "blocked" : "succeeded"), + action, + accepted: skillResult.accepted === true, + controlReady: skillResult.controlReady === true, + capabilityLevel: skillResult.capabilityLevel ?? (isBlocker ? HWLAB_M3_IO_CAPABILITY_LEVELS.blocked : HWLAB_M3_IO_CAPABILITY_LEVELS.ready), + summary: m3IoStructuredSummary({ + action, + isBlocker, + blocker, + route, + do1Value, + di1Value, + trusted, + durable: durable.durable === true + }), + do1: { + resourceId: M3_IO_TOPOLOGY.sourceResourceId, + port: M3_IO_TOPOLOGY.sourcePort, + targetValue: do1Value + }, + di1: { + resourceId: M3_IO_TOPOLOGY.targetResourceId, + port: M3_IO_TOPOLOGY.targetPort, + observedValue: di1Value, + status: skillResult.result?.targetReadback?.status ?? skillResult.readback?.status ?? null + }, + wiring: { + from: `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}`, + via: M3_IO_TOPOLOGY.patchPanelServiceId, + to: `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}`, + label: `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort} -> ${M3_IO_TOPOLOGY.patchPanelServiceId} -> ${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}` + }, + path: { + summary: pathSummary, + segments: ["Code Agent", "Skill CLI", "HWLAB API"], + skillCli: { + provider: M3_IO_SKILL_PROVIDER, + runnerKind: M3_IO_SKILL_RUNNER_KIND, + name: HWLAB_M3_IO_SKILL_NAME, + contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION + }, + hwlabApi: { + route, + method, + source: skillResult.hwlabApi?.source ?? null, + redactedUrl: skillResult.hwlabApi?.redactedUrl ?? null, + baseUrlConfigured: skillResult.hwlabApi?.baseUrlConfigured ?? null, + cloudApiOnly: skillResult.hwlabApi?.cloudApiOnly !== false + }, + directGatewayCalls: false, + directBoxCalls: false, + directPatchPanelCalls: false, + openAiFallbackUsed: false + }, + operation: { + operationId: skillResult.operationId ?? null, + status: skillResult.status ?? null, + accepted: skillResult.accepted === true, + auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, + evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null + }, + session: { + sessionId: stdioResult.session?.sessionId ?? null, + sessionMode: stdioResult.sessionMode ?? stdioResult.runner?.sessionMode ?? null, + sessionReused: stdioResult.sessionReuse?.reused ?? stdioResult.runner?.sessionReused ?? false, + turn: stdioResult.sessionReuse?.turn ?? stdioResult.runner?.turn ?? null, + codexStdio: stdioResult.runner?.codexStdio === true, + durableSession: stdioResult.runner?.durableSession === true + }, + trace: { + traceId: skillResult.traceId ?? stdioResult.traceId ?? null, + requestId: skillResult.requestId ?? null, + actorId: skillResult.actorId ?? null, + runnerKind: M3_IO_SKILL_RUNNER_KIND, + route, + method + }, + trust: { + trusted, + durable: durable.durable === true, + durableStatus: durable.status ?? null, + durableBlocker: durable.blocker ?? null, + auditStatus, + evidenceStatus, + evidenceSourceKind, + trustBlocker: skillResult.trustBlocker ?? null, + note: trusted + ? "operation/audit/evidence 已有可信持久化记录;仍不是 M3 DEV-LIVE 验收结论。" + : "本次不伪造 DEV-LIVE:控制链路结果与可信持久化状态分开展示。" + }, + blocker, + blockers: skillResult.blockers ?? (blocker ? [blocker] : []), + safety: { + cloudApiRouteOnly: true, + allowedRoute: route, + directGatewayCalls: false, + directBoxCalls: false, + directPatchPanelCalls: false, + fallbackUsed: false, + openAiFallbackUsed: false, + acceptanceClaimed: false + } + }; +} + +function m3IoActionFromArgs(commandArgs = []) { + const actionIndex = commandArgs.indexOf("--action"); + if (actionIndex >= 0 && typeof commandArgs[actionIndex + 1] === "string") { + return commandArgs[actionIndex + 1]; + } + if (commandArgs.includes("status")) return "status"; + return null; +} + +function m3IoPrimaryBlocker(skillResult = {}) { + const capabilityCandidates = [ + skillResult.blocker, + skillResult.capabilityBlocker, + skillResult.error?.blocker + ]; + const primary = capabilityCandidates.find((item) => item?.code || item?.message || item?.zh); + if (primary) return primary; + if ( + skillResult.ok !== false && + skillResult.accepted !== false && + String(skillResult.status ?? "").toLowerCase() !== "blocked" + ) { + return null; + } + const fallbackCandidates = [ + ...(Array.isArray(skillResult.blockers) ? skillResult.blockers : []) + ]; + return fallbackCandidates.find((item) => item?.code || item?.message || item?.zh) ?? null; +} + +function m3IoStructuredSummary({ action, isBlocker, blocker, route, do1Value, di1Value, trusted, durable }) { + if (isBlocker) { + const reason = blocker?.userMessage ?? blocker?.zh ?? blocker?.message ?? blocker?.code ?? "M3 IO 链路受阻"; + return `M3 IO 阻塞:${reason};路径 Code Agent -> Skill CLI -> HWLAB API ${route}。`; + } + const actionText = action === "do.write" + ? `DO1 目标值=${formatM3Value(do1Value)},DI1 观测值=${formatM3Value(di1Value)}` + : action === "di.read" + ? `DI1 观测值=${formatM3Value(di1Value)}` + : `状态读取 DI1=${formatM3Value(di1Value)}`; + return `M3 IO 结果:${actionText};trusted=${trusted ? "true" : "false"};durable=${durable ? "true" : "false"}。`; +} + function redactM3IoCommandArgs(commandArgs = []) { const redacted = []; for (let index = 0; index < commandArgs.length; index += 1) { @@ -1591,27 +1787,29 @@ function m3IoApiBaseUrlMissingBlocker({ route = HWLAB_M3_IO_API_ROUTE } = {}) { }; } -function m3IoSkillReply(skillResult) { - const lines = [ - `M3 IO Skill CLI result: 已通过 Code Agent -> Skill CLI -> HWLAB API ${skillResult.route} 处理请求;status=${skillResult.status}; accepted=${skillResult.accepted}; traceId=${skillResult.traceId}; operationId=${skillResult.operationId ?? "null"}。` - ]; - if (skillResult.audit?.summary) { - lines.push(`Audit: ${skillResult.audit.summary}(audit 摘要)。`); - } - if (skillResult.evidence?.summary) { - lines.push(`Evidence: ${skillResult.evidence.summary}(evidence 摘要)。`); - } - if (skillResult.blocker) { - lines.push(`Blocker: ${skillResult.blocker.code}; ${skillResult.blocker.zh ?? skillResult.blocker.message}(blocker)。`); +function m3IoSkillReply(skillResult, m3Io = null) { + const route = m3Io?.trace?.route ?? skillResult.route ?? HWLAB_M3_IO_API_ROUTE; + const lines = []; + if (m3Io?.type === "m3_io_blocker") { + const blocker = m3Io.blocker ?? skillResult.blocker ?? skillResult.capabilityBlocker ?? {}; + lines.push(`M3 IO 阻塞:${blocker.userMessage ?? blocker.zh ?? blocker.message ?? blocker.code ?? "M3 控制链路仍受阻"}。`); + } else { + lines.push("M3 IO 结果:受控请求已返回。"); } if (skillResult.action === "do.write") { - lines.push(`DO1 写入请求:res_boxsimu_1:DO1=${String(skillResult.command?.value)};DI1 readback:res_boxsimu_2:DI1=${String(skillResult.result?.targetReadback?.value ?? "unknown")}。`); + lines.push(`DO1 目标值:${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}=${formatM3Value(m3Io?.do1?.targetValue ?? skillResult.command?.value)};DI1 观测值:${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(m3Io?.di1?.observedValue ?? skillResult.result?.targetReadback?.value)}。`); } else if (skillResult.action === "di.read") { - lines.push(`DI1 读取结果:res_boxsimu_2:DI1=${String(skillResult.result?.value ?? "unknown")}。`); - } else if (skillResult.readonly === true || skillResult.route === HWLAB_M3_STATUS_API_ROUTE) { - lines.push(`M3 status readback:res_boxsimu_2:DI1=${String(skillResult.readback?.value ?? "unknown")};readiness=${skillResult.readiness?.status ?? "unknown"}。`); + lines.push(`DI1 读取结果:${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(m3Io?.di1?.observedValue ?? skillResult.result?.value)}。`); + } else if (skillResult.readonly === true || route === HWLAB_M3_STATUS_API_ROUTE) { + lines.push(`M3 状态读取:${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(m3Io?.di1?.observedValue ?? skillResult.readback?.value)};readiness=${skillResult.readiness?.status ?? "unknown"}。`); } - lines.push(`Boundary/边界:本次只走 Code Agent -> Skill CLI -> HWLAB API ${skillResult.route};没有直连 gateway/box/patch-panel,也没有使用 OpenAI fallback 冒充 toolCall。这是受控操作结果,不是 M3 DEV-LIVE 验收结论。`); + lines.push(`接线关系:${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort} -> ${M3_IO_TOPOLOGY.patchPanelServiceId} -> ${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}。`); + lines.push(`执行路径:Code Agent -> Skill CLI -> HWLAB API ${route};没有使用 OpenAI fallback,也没有由 Code Agent 直连硬件服务。`); + lines.push(`排查字段:traceId=${skillResult.traceId ?? "null"};operationId=${skillResult.operationId ?? "null"};auditId=${skillResult.auditId ?? skillResult.audit?.auditId ?? "null"};evidenceId=${skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? "null"}。`); + if (m3Io?.blocker?.code) { + lines.push(`blocker=${m3Io.blocker.code};layer=${m3Io.blocker.layer ?? "unknown"}。`); + } + lines.push(`可信状态:trusted=${m3Io?.trust?.trusted === true ? "true" : "false"};durable=${m3Io?.trust?.durable === true ? "true" : "false"};这不是 M3 DEV-LIVE 验收结论。`); return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text; } @@ -1730,22 +1928,22 @@ function structuredCompletionBlocker(result, context = {}) { const m3SkillTool = Array.isArray(result.toolCalls) ? result.toolCalls.find((toolCall) => toolCall?.name === HWLAB_M3_IO_SKILL_NAME) : null; - if ((result.provider === M3_IO_SKILL_PROVIDER || m3SkillTool) && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) { - const primary = m3SkillTool?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; + if ((result.provider === M3_IO_SKILL_PROVIDER || m3SkillTool || result.m3Io?.type === "m3_io_blocker") && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) { + const primary = result.m3Io?.blocker ?? m3SkillTool?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; return structuredBlocker({ - code: primary?.code === "hwlab_api_unavailable" ? "hwlab_api_unavailable" : "m3_readiness_blocked", - layer: primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", + code: primary?.code ?? "m3_io_blocker", + layer: primary?.layer ?? (primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness"), message: primary?.message ?? "M3 IO Skill CLI did not reach a ready HWLAB API control path.", - userMessage: primary?.zh ?? "M3 控制链路仍受阻,前端应显示为能力未就绪,而不是发送失败。", + userMessage: primary?.userMessage ?? primary?.zh ?? "M3 控制链路仍受阻,前端应显示为能力未就绪,而不是发送失败。", retryable: primary?.code === "hwlab_api_unavailable", traceId: context.traceId, provider: result.provider, backend: result.backend, runner: result.runner, capabilityLevel: result.capabilityLevel, - route: HWLAB_M3_IO_API_ROUTE, + route: result.m3Io?.trace?.route ?? m3SkillTool?.route ?? HWLAB_M3_IO_API_ROUTE, toolName: HWLAB_M3_IO_SKILL_NAME, - blockers: result.skills?.blockers + blockers: result.m3Io?.blockers ?? result.skills?.blockers }); } return null; @@ -2359,6 +2557,15 @@ function nowIso(now) { return typeof now === "function" ? now() : new Date().toISOString(); } +function firstDefined(...values) { + return values.find((value) => value !== undefined && value !== null); +} + +function formatM3Value(value) { + if (value === undefined || value === null) return "未产生/不可证明"; + return String(value); +} + function firstNonEmpty(...values) { for (const value of values) { if (typeof value === "string" && value.trim()) return value.trim(); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 2bffcc88..4a648693 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -2642,6 +2642,18 @@ test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Co assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.responseType, "m3_io_result"); + assert.equal(payload.m3Io.type, "m3_io_result"); + assert.equal(payload.m3Io.action, "do.write"); + assert.equal(payload.m3Io.do1.targetValue, true); + assert.equal(payload.m3Io.di1.observedValue, true); + assert.equal(payload.m3Io.wiring.label, "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1"); + assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); + assert.equal(payload.m3Io.operation.operationId, "op_m3_do_write_topology_skill"); + assert.equal(payload.m3Io.trace.traceId, "trc_server-test-m3-topology"); + assert.equal(payload.m3Io.session.sessionId, "ses_server_m3_topology_skill"); + assert.equal(payload.m3Io.trust.trusted, false); + assert.equal(payload.m3Io.trust.durable, false); const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); assert.ok(skillTool); assert.equal(skillTool.route, "/v1/m3/io"); @@ -2732,6 +2744,13 @@ test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.responseType, "m3_io_blocker"); + assert.equal(payload.m3Io.type, "m3_io_blocker"); + assert.equal(payload.m3Io.action, "do.write"); + assert.equal(payload.m3Io.do1.targetValue, null); + assert.equal(payload.m3Io.di1.observedValue, null); + assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); + assert.equal(payload.m3Io.blocker.code, "invalid_boolean_value"); const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); assert.ok(skillTool); assert.equal(skillTool.status, "blocked"); @@ -2739,8 +2758,10 @@ test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without assert.equal(skillTool.blocker.code, "invalid_boolean_value"); assert.equal(skillTool.accepted, false); assert.equal(skillTool.operationId, null); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); + assert.equal(payload.blocker.code, "invalid_boolean_value"); + assert.match(payload.reply.content, /^M3 IO 阻塞:/u); assert.match(payload.reply.content, /M3 DO1 写入需要 --value true 或 --value false/u); + assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u); assert.equal(payload.providerTrace.fallbackUsed, false); assert.equal(payload.providerTrace.transport, "stdio+skill-cli"); assert.equal(calls.length, 0); @@ -2753,6 +2774,134 @@ test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without } }); +test("cloud api /v1/agent/chat promotes /v1/m3/io blocker into Chinese M3 IO reply", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-blocker-")); + const codexHome = path.join(workspace, "codex-home"); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(codexHome, { recursive: true }); + const calls = []; + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_server_m3_api_blocker", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + async callTool() { + return { + structuredContent: { + threadId: "thread_server_m3_api_blocker", + content: "stdio session ready for M3 API blocker fixture." + } + }; + }, + close() {} + }) + }); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_WORKSPACE: workspace, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" + }, + codexStdioManager: manager, + m3IoSkillRequestJson: async (url, request) => { + calls.push({ url, request }); + return { + ok: true, + status: 200, + body: { + serviceId: "hwlab-cloud-api", + contractVersion: "m3-io-control-v1", + route: "/v1/m3/io", + method: "POST", + status: "blocked", + accepted: false, + action: "do.write", + traceId: "trc_server-test-m3-api-blocker", + operationId: "op_m3_do_write_api_blocker", + auditId: "aud_m3_do_write_api_blocker_failed", + evidenceId: "evd_m3_do_write_api_blocker_failed", + blocker: { + code: "m3_wiring_missing", + layer: "hwlab-patch-panel", + zh: "hwlab-patch-panel 未确认 active res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 接线" + }, + auditState: { + status: "written_non_durable" + }, + evidenceState: { + status: "blocked", + sourceKind: "BLOCKED", + blocker: "runtime_durable_not_green", + writeStatus: "written_non_durable" + }, + durableStatus: { + status: "degraded", + durable: false, + blocker: "runtime_durable_not_green" + }, + result: { + value: true, + targetReadback: null + }, + controlPath: { + status: "blocked", + cloudApi: true, + gatewaySimu: true, + boxSimu: true, + patchPanel: false, + frontendBypass: false + } + } + }; + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const payload = await postAgent(port, { + conversationId: "cnv_server-test-m3-api-blocker", + traceId: "trc_server-test-m3-api-blocker", + message: "把 M3 DO1 打开,然后读取 DI1。" + }); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.responseType, "m3_io_blocker"); + assert.equal(payload.m3Io.type, "m3_io_blocker"); + assert.equal(payload.m3Io.action, "do.write"); + assert.equal(payload.m3Io.do1.targetValue, true); + assert.equal(payload.m3Io.di1.observedValue, null); + assert.equal(payload.m3Io.blocker.code, "m3_wiring_missing"); + assert.equal(payload.blocker.code, "m3_wiring_missing"); + assert.match(payload.reply.content, /^M3 IO 阻塞:/u); + assert.match(payload.reply.content, /hwlab-patch-panel 未确认 active/u); + assert.match(payload.reply.content, /res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1/u); + assert.match(payload.reply.content, /trusted=false;durable=false/u); + assert.equal(calls.length, 1); + assert.equal(new URL(calls[0].url).pathname, "/v1/m3/io"); + assert.equal(calls[0].request.body.action, "do.write"); + assert.equal(calls[0].request.body.value, true); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(workspace, { recursive: true, force: true }); + await rm(fakeCodex.root, { recursive: true, force: true }); + } +}); + test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWLAB API handler", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-m3-skill-")); const fakeCodex = await createFakeCodexCommand(); @@ -2822,6 +2971,16 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL assert.equal(payload.runner.codexStdio, true); assert.equal(payload.codexStdioFeasibility.ready, true); assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); + assert.equal(payload.responseType, "m3_io_result"); + assert.equal(payload.m3Io.type, "m3_io_result"); + assert.equal(payload.m3Io.action, "do.write"); + assert.equal(payload.m3Io.do1.targetValue, true); + assert.equal(payload.m3Io.di1.observedValue, true); + assert.equal(payload.m3Io.operation.operationId.startsWith("op_m3_do_write_"), true); + assert.equal(payload.m3Io.session.sessionId, "ses_server_stdio_m3_skill"); + assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); + assert.equal(payload.m3Io.trust.trusted, false); + assert.equal(payload.m3Io.trust.durable, false); const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); assert.ok(skillTool); assert.equal(skillTool.type, "skill-cli"); @@ -2922,6 +3081,9 @@ test("cloud api /v1/agent/chat blocks direct M3 API base targets only after Code assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.responseType, "m3_io_blocker"); + assert.equal(payload.m3Io.type, "m3_io_blocker"); + assert.equal(payload.m3Io.blocker.code, "direct_hardware_target_blocked"); assert.equal(payload.toolCalls[0].name, "codex"); assert.equal(payload.toolCalls[0].status, "completed"); const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); @@ -2998,6 +3160,9 @@ test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base after Codex assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.responseType, "m3_io_blocker"); + assert.equal(payload.m3Io.type, "m3_io_blocker"); + assert.equal(payload.m3Io.blocker.code, "skill_cli_api_base_missing"); assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing"); assert.equal(payload.toolCalls[0].name, "codex"); assert.equal(payload.toolCalls[0].status, "completed"); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 1d92d95e..bee1fdb9 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -2522,7 +2522,9 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { /isRealCompletedChatResult\(result\)/u.test(app) && /classifyCodeAgentCompletion\(result\)/u.test(app) && /status:\s*"completed"/u.test(functionBody(app, "classifyCodeAgentCompletion")) && - /sourceKind:\s*"DEV-LIVE"/u.test(functionBody(app, "classifyCodeAgentCompletion")) && + /m3_io_result/u.test(functionBody(app, "classifyCodeAgentCompletion")) && + /sourceKind:\s*result\?\.m3Io\?\.type === "m3_io_result"[\s\S]*?"CONTROLLED"[\s\S]*?"DEV-LIVE"/u.test(functionBody(app, "classifyCodeAgentCompletion")) && + /m3_io_blocker/u.test(app) && /Code Agent 完成证据不足/u.test(app) && /本次不会标记为真实完成/u.test(app) ); @@ -2573,9 +2575,15 @@ function hasCodeAgentStatusSummaryContract({ html, app, styles, codeAgentStatus function hasCodeAgentConversationUxStates({ app, styles }) { const submitBody = `${functionBody(app, "initCommandBar")}\n${functionBody(app, "submitAgentMessage")}`; + const agentToneBody = functionBody(app, "agentStatusTone"); const statusToneContract = /status === "completed" \? "dev-live" : status === "failed" \|\| status === "blocked" \? "blocked" : status === "running" \? "pending" : "source"/u.test(app) || - /status === "completed" \? "dev-live" : \["failed",\s*"blocked",\s*"timeout",\s*"canceled",\s*"error"\]\.includes\(status\) \? "blocked" : status === "running" \? "pending" : "source"/u.test(app); + /status === "completed" \? "dev-live" : \["failed",\s*"blocked",\s*"timeout",\s*"canceled",\s*"error"\]\.includes\(status\) \? "blocked" : status === "running" \? "pending" : "source"/u.test(app) || + ( + /function\s+agentStatusTone\s*\(/u.test(app) && + /m3_io_result/u.test(agentToneBody) && + /\["failed",\s*"blocked",\s*"timeout",\s*"canceled",\s*"error"\]\.includes\(status\)/u.test(agentToneBody) + ); return ( /function\s+classifyCodeAgentCompletion\s*\(/u.test(app) && /function\s+isSourceFixtureChatResult\s*\(/u.test(app) && @@ -2614,6 +2622,7 @@ function hasCodeAgentConversationUxStates({ app, styles }) { !/message-evidence\s*\{/u.test(styles) && /width:\s*min\(100%,\s*780px\)/u.test(styles) && statusToneContract && + /m3_io_blocker/u.test(functionBody(app, "agentStatusLabel")) && !/sourceKind:\s*"SOURCE"[\s\S]{0,160}status:\s*"completed"/u.test(app) ); } diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index fab0d949..75d1d2e4 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -694,16 +694,17 @@ async function submitAgentMessage(value, options = {}) { const completion = classifyCodeAgentCompletion(result); const status = completion.status; const structuredBlockedError = structuredBlockedErrorFromResult(result); + const m3Presentation = m3IoPresentation(result); state.chatMessages[index] = { ...pendingMessage, - title: completion.title, - text: completion.replied + title: m3Presentation?.title ?? completion.title, + text: m3Presentation?.text ?? (completion.replied ? result.reply?.content || "Code Agent 没有返回文本。" : structuredBlockedError ? failureMessage({ ...result, error: structuredBlockedError }) : result.status === "completed" ? untrustedCompletionMessage(result) - : failureMessage(result), + : failureMessage(result)), status, traceId: result.traceId, conversationId: result.conversationId || result.sessionId || state.conversationId, @@ -2490,7 +2491,7 @@ function renderAgentChatStatus(status, result = null) { blocked: "服务受阻" }; el.agentChatStatus.textContent = agentStatusLabel(status, result, labels); - el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : ["failed", "blocked", "timeout", "canceled", "error"].includes(status) ? "blocked" : status === "running" ? "pending" : "source")}`; + el.agentChatStatus.className = `state-tag tone-${toneClass(agentStatusTone(status, result))}`; el.commandInput.disabled = status === "running"; el.commandSend.disabled = status === "running"; el.commandSend.textContent = status === "running" ? "发送中" : "发送"; @@ -2558,7 +2559,11 @@ function sessionSummaryTone(summary) { } function agentStatusLabel(status, result, labels) { + if (status === "completed" && result?.m3Io?.type === "m3_io_result") { + return result.m3Io.trust?.trusted === true ? "M3 IO 可信回复" : "M3 IO 受控结果"; + } if (status !== "failed") return labels[status] ?? statusLabel(status); + if (result?.m3Io?.type === "m3_io_blocker") return "M3 IO 阻塞"; const presentation = agentFailurePresentation(result?.error, { result }); return ( { @@ -2578,6 +2583,16 @@ function agentStatusLabel(status, result, labels) { ); } +function agentStatusTone(status, result) { + if (status === "completed" && result?.m3Io?.type === "m3_io_result") { + return result.m3Io.trust?.trusted === true ? "dev-live" : "warn"; + } + if (status === "completed") return "dev-live"; + if (["failed", "blocked", "timeout", "canceled", "error"].includes(status)) return "blocked"; + if (status === "running") return "pending"; + return "source"; +} + function sourceTitle(result) { return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; } @@ -2615,6 +2630,14 @@ function classifyCodeAgentCompletion(result) { title: "Code Agent 返回错误" }; } + if (result?.m3Io?.type === "m3_io_blocker") { + return { + status: "failed", + replied: false, + sourceKind: "BLOCKED", + title: "M3 IO 阻塞" + }; + } if (isStructuredBlockedChatResult(result)) { return { status: "failed", @@ -2627,7 +2650,7 @@ function classifyCodeAgentCompletion(result) { return { status: "completed", replied: true, - sourceKind: "DEV-LIVE", + sourceKind: result?.m3Io?.type === "m3_io_result" && result.m3Io.trust?.trusted !== true ? "CONTROLLED" : "DEV-LIVE", title: sourceTitle(result) }; } @@ -2662,6 +2685,8 @@ function isStructuredBlockedChatResult(result) { typeof result === "object" && ( result.blocker?.code || + result.m3Io?.type === "m3_io_blocker" || + result.m3Io?.blocker?.code || error.blocker?.code || result.capabilityLevel === "hwlab-api-control-blocked" ) @@ -2672,19 +2697,44 @@ function structuredBlockedErrorFromResult(result) { if (!isStructuredBlockedChatResult(result)) return null; const error = result?.error && typeof result.error === "object" ? result.error : {}; const blocker = error.blocker ?? result?.blocker ?? {}; - const code = normalizeErrorCode(error.code ?? blocker.code ?? "code_agent_blocked"); + const m3Blocker = result?.m3Io?.blocker ?? {}; + const code = normalizeErrorCode(error.code ?? m3Blocker.code ?? blocker.code ?? "code_agent_blocked"); + const selectedBlocker = result?.m3Io?.type === "m3_io_blocker" && m3Blocker.code ? m3Blocker : blocker; return { ...error, code, - layer: error.layer ?? blocker.layer, - category: error.category ?? blocker.category, - blocker, - retryable: error.retryable ?? blocker.retryable, - userMessage: error.userMessage ?? blocker.userMessage, - message: error.message ?? blocker.summary ?? code, + layer: error.layer ?? selectedBlocker.layer, + category: error.category ?? selectedBlocker.category, + blocker: selectedBlocker, + retryable: error.retryable ?? selectedBlocker.retryable, + userMessage: error.userMessage ?? selectedBlocker.userMessage, + message: error.message ?? selectedBlocker.summary ?? selectedBlocker.zh ?? selectedBlocker.message ?? code, traceId: error.traceId ?? result?.traceId, - route: error.route ?? blocker.route, - toolName: error.toolName ?? blocker.toolName + route: error.route ?? selectedBlocker.route, + toolName: error.toolName ?? selectedBlocker.toolName + }; +} + +function m3IoPresentation(result) { + const m3 = result?.m3Io && typeof result.m3Io === "object" ? result.m3Io : null; + if (!m3) return null; + const do1 = m3.do1?.targetValue === undefined || m3.do1?.targetValue === null ? "未产生/不可证明" : String(m3.do1.targetValue); + const di1 = m3.di1?.observedValue === undefined || m3.di1?.observedValue === null ? "未产生/不可证明" : String(m3.di1.observedValue); + const fields = `traceId=${m3.trace?.traceId ?? result?.traceId ?? "null"};operationId=${m3.operation?.operationId ?? "null"}`; + const trust = `trusted=${m3.trust?.trusted === true ? "true" : "false"};durable=${m3.trust?.durable === true ? "true" : "false"}`; + const path = m3.path?.summary ?? "Code Agent -> Skill CLI -> HWLAB API"; + const wiring = m3.wiring?.label ?? "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1"; + if (m3.type === "m3_io_blocker") { + const blocker = m3.blocker ?? {}; + const reason = blocker.userMessage ?? blocker.zh ?? blocker.message ?? blocker.code ?? "M3 控制链路仍受阻"; + return { + title: "M3 IO 阻塞", + text: `M3 IO 阻塞:${reason}。\nDO1 目标值:${do1};DI1 观测值:${di1}。\n路径:${path}。接线:${wiring}。\n排查字段:${fields};blocker=${blocker.code ?? "unknown"}。\n可信状态:${trust};未伪造 DEV-LIVE,也未使用 OpenAI fallback 冒充执行。` + }; + } + return { + title: "M3 IO 结果", + text: `M3 IO 结果:DO1 目标值=${do1};DI1 观测值=${di1}。\n路径:${path}。接线:${wiring}。\n排查字段:${fields};auditId=${m3.operation?.auditId ?? "null"};evidenceId=${m3.operation?.evidenceId ?? "null"}。\n可信状态:${trust};这是受控操作返回,不是 M3 DEV-LIVE 验收结论。` }; } @@ -3074,6 +3124,7 @@ function messageM3EvidencePanel(message) { rows.className = "message-m3-rows"; rows.append(...m3EvidenceRows(evidence).map(m3EvidenceRowElement)); section.append(rows); + section.append(m3EvidenceRawDetails(evidence, message)); return section; } @@ -3099,6 +3150,35 @@ function m3EvidenceRowElement(row) { return item; } +function m3EvidenceRawDetails(evidence, message) { + const details = document.createElement("details"); + details.className = "message-m3-raw"; + const summary = document.createElement("summary"); + summary.textContent = "原始字段"; + const pre = document.createElement("pre"); + pre.textContent = boundedJson({ + responseType: message.responseType, + m3Io: message.m3Io ?? null, + toolCall: evidence.source ?? null, + providerTrace: message.providerTrace ?? null, + runnerTrace: message.runnerTrace ?? null, + blocker: message.blocker ?? null, + blockers: message.blockers ?? null + }, 6000); + details.append(summary, pre); + return details; +} + +function boundedJson(value, maxLength = 6000) { + let text = ""; + try { + text = JSON.stringify(value, null, 2); + } catch { + text = String(value ?? ""); + } + return text.length > maxLength ? `${text.slice(0, maxLength)}\n...字段已截断` : text; +} + function messageTracePanel(message) { const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null; if (!trace && !message.traceId) return null; diff --git a/web/hwlab-cloud-web/code-agent-m3-evidence.mjs b/web/hwlab-cloud-web/code-agent-m3-evidence.mjs index fdd5f24c..aed91468 100644 --- a/web/hwlab-cloud-web/code-agent-m3-evidence.mjs +++ b/web/hwlab-cloud-web/code-agent-m3-evidence.mjs @@ -9,6 +9,7 @@ export const CODE_AGENT_M3_TRUSTED_ROUTE = Object.freeze({ export const CODE_AGENT_M3_SKILL_PROVIDER = "hwlab-skill-cli"; export const CODE_AGENT_M3_SKILL_RUNNER_KIND = "hwlab-m3-io-skill-cli"; export const CODE_AGENT_M3_SKILL_NAME = "hwlab-agent-runtime.m3-io"; +export const CODE_AGENT_M3_PATH_LABEL = "Code Agent -> Skill CLI -> HWLAB API"; const missingProofLabel = "未产生/不可证明"; const nonProofIds = new Set(["", "n/a", "none", "null", "undefined", "not_observed", "not-observed"]); @@ -45,57 +46,71 @@ export function isCodeAgentM3SkillCompletion(value) { export function extractCodeAgentM3Evidence(message) { const toolCall = findM3ToolCall(message); + const m3Io = objectOrNull(message?.m3Io); const source = toolCall ?? message?.providerTrace ?? message?.runnerTrace ?? null; - if (!source && !isM3ProviderMessage(message)) return null; + if (!source && !m3Io && !isM3ProviderMessage(message)) return null; const providerTrace = objectOrNull(message?.providerTrace); const runnerTrace = objectOrNull(message?.runnerTrace); - const route = firstNonEmpty(source?.route, providerTrace?.route, runnerTrace?.route, CODE_AGENT_M3_IO_ROUTE); + const route = firstNonEmpty(source?.route, m3Io?.trace?.route, m3Io?.path?.hwlabApi?.route, providerTrace?.route, runnerTrace?.route, CODE_AGENT_M3_IO_ROUTE); const provider = firstNonEmpty(message?.provider, source?.provider, providerTrace?.provider); const runnerKind = firstNonEmpty(message?.runner?.kind, source?.runnerKind, providerTrace?.runnerKind, runnerTrace?.runnerKind); const command = objectOrNull(source?.command) ?? objectOrNull(message?.command); const result = objectOrNull(source?.result) ?? objectOrNull(message?.result); const response = parseToolStdout(source?.stdout); const blocker = firstBlocker(source, response, providerTrace, runnerTrace, message); - const target = normalizeTarget(source, response, command); - const readback = normalizeReadback(source, response, result); + const trustBlocker = firstTrustBlocker(m3Io, source, response, providerTrace, runnerTrace, message); + const target = normalizeTarget(source, response, command, m3Io); + const readback = normalizeReadback(source, response, result, m3Io); + const action = firstNonEmpty(m3Io?.action, source?.action, response?.action, command?.action); + const trust = normalizeTrust(m3Io, source, response, trustBlocker); const ids = { - operationId: firstNonEmpty(source?.operationId, response?.operationId, providerTrace?.operationId, runnerTrace?.operationId, message?.operationId), - traceId: firstNonEmpty(source?.traceId, response?.traceId, providerTrace?.traceId, runnerTrace?.traceId, message?.traceId), - auditId: firstNonEmpty(source?.audit?.auditId, response?.audit?.auditId, message?.auditId, message?.audit?.auditId), - evidenceId: firstNonEmpty(source?.evidence?.evidenceId, response?.evidence?.evidenceId, message?.evidenceId, message?.evidence?.evidenceId) + operationId: firstNonEmpty(m3Io?.operation?.operationId, source?.operationId, response?.operationId, providerTrace?.operationId, runnerTrace?.operationId, message?.operationId), + traceId: firstNonEmpty(m3Io?.trace?.traceId, source?.traceId, response?.traceId, providerTrace?.traceId, runnerTrace?.traceId, message?.traceId), + auditId: firstNonEmpty(m3Io?.operation?.auditId, source?.audit?.auditId, response?.audit?.auditId, message?.auditId, message?.audit?.auditId), + evidenceId: firstNonEmpty(m3Io?.operation?.evidenceId, source?.evidence?.evidenceId, response?.evidence?.evidenceId, message?.evidenceId, message?.evidence?.evidenceId) }; const directPathInvalid = classifyDirectPathInvalid(source, response, message); const readbackMismatch = classifyReadbackMismatch({ - action: firstNonEmpty(source?.action, response?.action, command?.action), + action, target, readback, blocker }); - const accepted = normalizeBoolean(firstDefined(source?.accepted, response?.accepted, providerTrace?.accepted)); - const status = firstNonEmpty(source?.status, response?.status, providerTrace?.status, runnerTrace?.status, message?.status); + const accepted = normalizeBoolean(firstDefined(m3Io?.accepted, source?.accepted, response?.accepted, providerTrace?.accepted)); + const status = firstNonEmpty(m3Io?.status, source?.status, response?.status, providerTrace?.status, runnerTrace?.status, message?.status); const links = normalizeEvidenceLinks(source, response, providerTrace, message); + const path = normalizePath(m3Io, route); + const wiring = normalizeWiring(m3Io); - if (!isM3EvidenceShape({ route, provider, runnerKind, source })) return null; + if (!isM3EvidenceShape({ route, provider, runnerKind, source, m3Io })) return null; return { kind: "m3-io", + responseType: firstNonEmpty(m3Io?.type, source?.responseType, message?.responseType), provider, runnerKind, route, + action, status, accepted, blocker, + trustBlocker, + trust, directPathInvalid, readbackMismatch, target, readback, + path, + wiring, ids, links, verdict: classifyM3Verdict({ accepted, status, blocker, + trustBlocker, + trust, directPathInvalid, readbackMismatch, ids @@ -116,11 +131,17 @@ export function m3EvidenceRows(evidence) { const readback = evidence.readback; return [ evidenceValueRow("状态", evidence.verdict.label, { tone: evidence.verdict.tone }), + evidenceValueRow("类型", evidence.responseType ?? evidence.kind, { tone: evidence.responseType === "m3_io_blocker" ? "blocked" : "source" }), + evidenceValueRow("动作", evidence.action ?? missingProofLabel), evidenceValueRow("accepted", evidence.accepted === null ? "unknown" : String(evidence.accepted), { tone: evidence.accepted === true ? "source" : "blocked" }), evidenceValueRow("status", evidence.status ?? missingProofLabel, { tone: evidence.status === "blocked" ? "blocked" : "source" }), + evidenceValueRow("路径", evidence.path.summary, { copyable: true }), + evidenceValueRow("接线", evidence.wiring.label, { copyable: true }), evidenceValueRow("route", evidence.route ?? missingProofLabel, { copyable: true, href: evidence.links.route }), - evidenceValueRow("target", resourcePortValueLabel(target), { copyable: false }), - evidenceValueRow("readback", resourcePortValueLabel(readback), { tone: evidence.readbackMismatch ? "blocked" : "source" }), + evidenceValueRow("DO1", resourcePortValueLabel(target), { copyable: false }), + evidenceValueRow("DI1", resourcePortValueLabel(readback), { tone: evidence.readbackMismatch ? "blocked" : "source" }), + evidenceValueRow("trusted", booleanStateLabel(evidence.trust.trusted), { tone: evidence.trust.trusted === true ? "dev-live" : evidence.trust.trusted === false ? "warn" : "source" }), + evidenceValueRow("durable", booleanStateLabel(evidence.trust.durable), { tone: evidence.trust.durable === true ? "dev-live" : evidence.trust.durable === false ? "warn" : "source" }), ...idRows, evidenceValueRow("blocker", blockerLabel(evidence.blocker), { tone: evidence.blocker ? "blocked" : "source" }) ]; @@ -128,7 +149,7 @@ export function m3EvidenceRows(evidence) { export function m3EvidenceSummaryText(evidence) { if (!evidence) return ""; - return `${evidence.verdict.label} / route=${evidence.route ?? missingProofLabel} / operation=${proofLabel(evidence.ids.operationId)} / evidence=${proofLabel(evidence.ids.evidenceId)}`; + return `${evidence.verdict.label} / ${evidence.path.summary} / DO1=${formatValue(evidence.target.value)} / DI1=${formatValue(evidence.readback.value)} / operation=${proofLabel(evidence.ids.operationId)}`; } export function proofLabel(value) { @@ -147,7 +168,7 @@ function evidenceValueRow(label, value, options = {}) { }; } -function classifyM3Verdict({ accepted, status, blocker, directPathInvalid, readbackMismatch, ids }) { +function classifyM3Verdict({ accepted, status, blocker, trustBlocker, trust, directPathInvalid, readbackMismatch, ids }) { if (directPathInvalid) { return { key: "direct-path-invalid", @@ -173,6 +194,14 @@ function classifyM3Verdict({ accepted, status, blocker, directPathInvalid, readb }; } if (accepted === true && ["succeeded", "completed", "accepted"].includes(String(status ?? "").toLowerCase())) { + if (trustBlocker || trust?.trusted === false) { + return { + key: "accepted-untrusted", + tone: "degraded", + label: "accepted / 可信持久化未通过", + summary: trustBlocker ? blockerLabel(trustBlocker) : "受控路径已返回,但 trusted/durable 未 green。" + }; + } const missingIds = Object.entries(ids).filter(([, value]) => proofLabel(value) === missingProofLabel); if (missingIds.length > 0) { return { @@ -229,26 +258,66 @@ function classifyDirectPathInvalid(...sources) { return false; } -function normalizeTarget(source, response, command) { +function normalizeTarget(source, response, command, m3Io = null) { const target = objectOrNull(source?.target) ?? objectOrNull(response?.target) ?? {}; const commandObject = objectOrNull(command) ?? objectOrNull(response?.command) ?? {}; return { - resourceId: firstNonEmpty(target.resourceId, commandObject.resourceId, source?.resourceId, response?.resourceId, CODE_AGENT_M3_TRUSTED_ROUTE.fromResourceId), - port: firstNonEmpty(target.port, commandObject.port, source?.port, response?.port, CODE_AGENT_M3_TRUSTED_ROUTE.fromPort), - value: firstDefined(target.value, commandObject.value, source?.value, response?.value) + resourceId: firstNonEmpty(m3Io?.do1?.resourceId, target.resourceId, commandObject.resourceId, source?.resourceId, response?.resourceId, CODE_AGENT_M3_TRUSTED_ROUTE.fromResourceId), + port: firstNonEmpty(m3Io?.do1?.port, target.port, commandObject.port, source?.port, response?.port, CODE_AGENT_M3_TRUSTED_ROUTE.fromPort), + value: firstDefined(m3Io?.do1?.targetValue, target.value, commandObject.value, source?.value, response?.value) }; } -function normalizeReadback(source, response, result) { +function normalizeReadback(source, response, result, m3Io = null) { const targetReadback = objectOrNull(result?.targetReadback) ?? objectOrNull(response?.result?.targetReadback) ?? objectOrNull(source?.result?.targetReadback) ?? objectOrNull(source?.targetReadback) ?? {}; return { - resourceId: firstNonEmpty(targetReadback.resourceId, CODE_AGENT_M3_TRUSTED_ROUTE.toResourceId), - port: firstNonEmpty(targetReadback.port, CODE_AGENT_M3_TRUSTED_ROUTE.toPort), - value: firstDefined(targetReadback.value, result?.value) + resourceId: firstNonEmpty(m3Io?.di1?.resourceId, targetReadback.resourceId, CODE_AGENT_M3_TRUSTED_ROUTE.toResourceId), + port: firstNonEmpty(m3Io?.di1?.port, targetReadback.port, CODE_AGENT_M3_TRUSTED_ROUTE.toPort), + value: firstDefined(m3Io?.di1?.observedValue, targetReadback.value, result?.value) + }; +} + +function normalizePath(m3Io, route) { + return { + summary: firstNonEmpty(m3Io?.path?.summary, `${CODE_AGENT_M3_PATH_LABEL} ${route ?? CODE_AGENT_M3_IO_ROUTE}`), + segments: Array.isArray(m3Io?.path?.segments) && m3Io.path.segments.length > 0 + ? m3Io.path.segments + : ["Code Agent", "Skill CLI", "HWLAB API"] + }; +} + +function normalizeWiring(m3Io) { + return { + from: firstNonEmpty(m3Io?.wiring?.from, `${CODE_AGENT_M3_TRUSTED_ROUTE.fromResourceId}:${CODE_AGENT_M3_TRUSTED_ROUTE.fromPort}`), + via: firstNonEmpty(m3Io?.wiring?.via, CODE_AGENT_M3_TRUSTED_ROUTE.patchPanelServiceId), + to: firstNonEmpty(m3Io?.wiring?.to, `${CODE_AGENT_M3_TRUSTED_ROUTE.toResourceId}:${CODE_AGENT_M3_TRUSTED_ROUTE.toPort}`), + label: firstNonEmpty( + m3Io?.wiring?.label, + `${CODE_AGENT_M3_TRUSTED_ROUTE.fromResourceId}:${CODE_AGENT_M3_TRUSTED_ROUTE.fromPort} -> ${CODE_AGENT_M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${CODE_AGENT_M3_TRUSTED_ROUTE.toResourceId}:${CODE_AGENT_M3_TRUSTED_ROUTE.toPort}` + ) + }; +} + +function normalizeTrust(m3Io, source, response, trustBlocker) { + const trust = objectOrNull(m3Io?.trust) ?? {}; + const durableValue = firstDefined( + trust.durable, + source?.durable?.durable, + response?.durable?.durable + ); + const trustedValue = firstDefined(trust.trusted, source?.trusted, response?.trusted); + const durable = normalizeBoolean(durableValue); + const trusted = normalizeBoolean(trustedValue); + return { + trusted: trusted === null ? (trustBlocker ? false : null) : trusted, + durable: durable === null ? null : durable, + blocker: trustBlocker, + sourceKind: firstNonEmpty(trust.evidenceSourceKind, source?.evidence?.sourceKind, response?.evidence?.sourceKind), + durableStatus: firstNonEmpty(trust.durableStatus, source?.durable?.status, response?.durable?.status) }; } @@ -260,16 +329,34 @@ function resourcePortValueLabel(item) { function firstBlocker(...sources) { for (const source of sources) { if (!source || typeof source !== "object") continue; - const candidate = objectOrNull(source.blocker) ?? objectOrNull(source.capabilityBlocker) ?? objectOrNull(source.trustBlocker); + const candidate = objectOrNull(source.blocker) ?? objectOrNull(source.capabilityBlocker) ?? objectOrNull(source.error?.blocker); if (candidate) return candidate; if (Array.isArray(source.blockers)) { - const blocker = source.blockers.find((item) => item?.code || item?.message || item?.zh); + const blocker = source.blockers.find((item) => (item?.code || item?.message || item?.zh) && !isTrustOnlyBlocker(item)); if (blocker) return blocker; } } return null; } +function firstTrustBlocker(...sources) { + for (const source of sources) { + if (!source || typeof source !== "object") continue; + const candidate = objectOrNull(source.trustBlocker) ?? objectOrNull(source.trust?.trustBlocker); + if (candidate) return candidate; + if (Array.isArray(source.blockers)) { + const blocker = source.blockers.find((item) => (item?.code || item?.message || item?.zh) && isTrustOnlyBlocker(item)); + if (blocker) return blocker; + } + } + return null; +} + +function isTrustOnlyBlocker(blocker) { + const text = `${blocker?.code ?? ""} ${blocker?.layer ?? ""} ${blocker?.category ?? ""}`; + return /durable|runtime|trust|evidence/i.test(text); +} + function blockerLabel(blocker) { if (!blocker) return "none"; return [blocker.code, blocker.zh ?? blocker.message ?? blocker.reason].filter(Boolean).join(": "); @@ -313,8 +400,9 @@ function parseToolStdout(stdout) { } } -function isM3EvidenceShape({ route, provider, runnerKind, source }) { +function isM3EvidenceShape({ route, provider, runnerKind, source, m3Io }) { if (route !== CODE_AGENT_M3_IO_ROUTE) return false; + if (m3Io?.type === "m3_io_result" || m3Io?.type === "m3_io_blocker") return true; if (provider === CODE_AGENT_M3_SKILL_PROVIDER) return true; if (runnerKind === CODE_AGENT_M3_SKILL_RUNNER_KIND) return true; if (source?.name === CODE_AGENT_M3_SKILL_NAME) return true; @@ -367,3 +455,13 @@ function stringOrNull(value) { const text = String(value ?? "").trim(); return text ? text : null; } + +function formatValue(value) { + return value === undefined || value === null ? missingProofLabel : String(value); +} + +function booleanStateLabel(value) { + if (value === true) return "true"; + if (value === false) return "false"; + return "unknown"; +} diff --git a/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs b/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs index a547ef42..f28b8d81 100644 --- a/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs +++ b/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs @@ -22,6 +22,7 @@ test("renders accepted M3 Skill CLI operation/audit/evidence metadata", () => { })); assert.equal(evidence.verdict.key, "accepted"); + assert.equal(evidence.responseType, "m3_io_result"); assert.equal(evidence.route, CODE_AGENT_M3_IO_ROUTE); assert.equal(evidence.target.resourceId, "res_boxsimu_1"); assert.equal(evidence.target.port, "DO1"); @@ -34,6 +35,10 @@ test("renders accepted M3 Skill CLI operation/audit/evidence metadata", () => { assert.equal(evidence.ids.auditId, "aud_m3_cli_accepted_succeeded"); assert.equal(evidence.ids.evidenceId, "evd_m3_cli_accepted_succeeded"); assert.equal(isCodeAgentM3SkillCompletion(m3Message()), true); + assert.equal(rowValue(evidence, "路径"), "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); + assert.equal(rowValue(evidence, "接线"), "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1"); + assert.match(rowValue(evidence, "DO1"), /res_boxsimu_1:DO1 value=true/u); + assert.match(rowValue(evidence, "DI1"), /res_boxsimu_2:DI1 value=true/u); assert.equal(rowValue(evidence, "evidenceId"), "evd_m3_cli_accepted_succeeded"); }); @@ -49,11 +54,38 @@ test("renders blocker state from Skill CLI without trusted pass", () => { })); assert.equal(evidence.verdict.key, "blocked"); + assert.equal(evidence.responseType, "m3_io_blocker"); assert.equal(evidence.verdict.tone, "blocked"); assert.match(rowValue(evidence, "blocker"), /m3_wiring_missing/u); assert.equal(isCodeAgentM3SkillCompletion({ ...m3Message(), status: "failed" }), false); }); +test("renders accepted but non-durable M3 IO result as degraded, not DEV-LIVE trusted", () => { + const evidence = extractCodeAgentM3Evidence(m3Message({ + status: "succeeded", + accepted: true, + value: false, + readbackValue: false, + trust: { + trusted: false, + durable: false, + durableStatus: "degraded", + durableBlocker: "runtime_durable_not_green" + }, + trustBlocker: { + code: "runtime_durable_not_green", + layer: "runtime-durable", + zh: "runtime durable 未 green" + } + })); + + assert.equal(evidence.verdict.key, "accepted-untrusted"); + assert.equal(evidence.verdict.tone, "degraded"); + assert.equal(rowValue(evidence, "trusted"), "false"); + assert.equal(rowValue(evidence, "durable"), "false"); + assert.match(rowValue(evidence, "DI1"), /value=false/u); +}); + test("shows missing evidenceId as unavailable proof instead of fabricating one", () => { const evidence = extractCodeAgentM3Evidence(m3Message({ status: "succeeded", @@ -81,7 +113,7 @@ test("classifies DO1 target and DI1 readback mismatch as blocked proof", () => { assert.equal(evidence.readbackMismatch, true); assert.equal(evidence.verdict.key, "readback-mismatch"); - assert.match(rowValue(evidence, "readback"), /res_boxsimu_2:DI1 value=false/u); + assert.match(rowValue(evidence, "DI1"), /res_boxsimu_2:DI1 value=false/u); }); test("classifies direct gateway/box/patch-panel path as invalid and never trusted", () => { @@ -133,10 +165,18 @@ function m3Message(overrides = {}) { const readbackValue = Object.hasOwn(overrides, "readbackValue") ? overrides.readbackValue : value; const status = overrides.status ?? "succeeded"; const accepted = Object.hasOwn(overrides, "accepted") ? overrides.accepted : true; + const responseType = overrides.responseType ?? (accepted && status !== "blocked" ? "m3_io_result" : "m3_io_blocker"); + const trust = overrides.trust ?? { + trusted: responseType === "m3_io_result", + durable: responseType === "m3_io_result", + durableStatus: responseType === "m3_io_result" ? "green" : "blocked", + durableBlocker: responseType === "m3_io_result" ? null : "m3_io_blocked" + }; const toolCall = { id: "tool_m3_fixture", type: "skill-cli", name: "hwlab-agent-runtime.m3-io", + responseType, status: accepted ? "completed" : "blocked", route: CODE_AGENT_M3_IO_ROUTE, accepted, @@ -165,7 +205,8 @@ function m3Message(overrides = {}) { } }, blocker: overrides.blocker ?? null, - blockers: overrides.blocker ? [overrides.blocker] : [], + trustBlocker: overrides.trustBlocker ?? null, + blockers: [overrides.blocker, overrides.trustBlocker].filter(Boolean), controlPath: overrides.controlPath ?? { cloudApi: true, gatewaySimu: true, @@ -203,6 +244,50 @@ function m3Message(overrides = {}) { return { status: "completed", + responseType, + m3Io: { + type: responseType, + status, + action: "do.write", + accepted, + do1: { + resourceId: "res_boxsimu_1", + port: "DO1", + targetValue: value + }, + di1: { + resourceId: "res_boxsimu_2", + port: "DI1", + observedValue: readbackValue + }, + wiring: { + from: "res_boxsimu_1:DO1", + via: "hwlab-patch-panel", + to: "res_boxsimu_2:DI1", + label: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1" + }, + path: { + summary: "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io", + segments: ["Code Agent", "Skill CLI", "HWLAB API"], + hwlabApi: { + route: CODE_AGENT_M3_IO_ROUTE, + method: "POST" + } + }, + operation: { + operationId, + auditId, + evidenceId + }, + trace: { + traceId, + route: CODE_AGENT_M3_IO_ROUTE, + method: "POST" + }, + trust, + blocker: overrides.blocker ?? null, + blockers: [overrides.blocker, overrides.trustBlocker].filter(Boolean) + }, provider: "hwlab-skill-cli", model: "controlled-m3-io", backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", @@ -224,6 +309,7 @@ function m3Message(overrides = {}) { providerTrace: { runnerKind: "hwlab-m3-io-skill-cli", skill: "hwlab-agent-runtime.m3-io", + responseType, route: CODE_AGENT_M3_IO_ROUTE, status, accepted, diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 2bc58fa7..922c113d 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -841,7 +841,8 @@ 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.match(app, /function\s+agentStatusTone\s*\(/); +assert.match(app, /m3_io_result/); assert.doesNotMatch(app, /status === "failed" \? "dev-live"/); assert.doesNotMatch(`${html}\n${app}`, /provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu); assert.doesNotMatch(app, /tone:\s*state\.conversationId\s*\?\s*"dev-live"/); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 8b7bafe8..3cd9f2eb 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -1135,6 +1135,34 @@ h3 { text-underline-offset: 2px; } +.message-m3-raw { + min-width: 0; + padding: 6px; + background: rgba(20, 24, 23, 0.72); + border: 1px solid var(--line-soft); +} + +.message-m3-raw summary { + cursor: pointer; + color: var(--dim); + font-family: var(--mono); + font-size: 10px; +} + +.message-m3-raw pre { + min-width: 0; + max-height: 240px; + margin: 7px 0 0; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + line-height: 1.4; +} + .copy-chip { min-width: 42px; min-height: 24px;