fix: 收敛 Code Agent M3 IO 结构化结果
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
+94
-14
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user