From cac46e45f7086d2899d3e8140c7d7837f2e37272 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 16:20:09 +0000 Subject: [PATCH] feat: show code agent message attribution metadata --- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 57 +++++ web/hwlab-cloud-web/app.mjs | 125 +++++++++++ web/hwlab-cloud-web/code-agent-facts.mjs | 201 ++++++++++++++++-- web/hwlab-cloud-web/code-agent-facts.test.mjs | 63 +++++- web/hwlab-cloud-web/scripts/check.mjs | 13 ++ web/hwlab-cloud-web/styles.css | 23 ++ 6 files changed, 453 insertions(+), 29 deletions(-) diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index b9602ffc..bec2003f 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -271,6 +271,10 @@ const workbenchMarkers = Object.freeze([ ]); const requiredCodeAgentEvidenceTerms = Object.freeze([ + "消息归因", + "字段缺失:证据不足", + "只是文本回答", + "当前不能执行", "provider", "model", "backend", @@ -291,11 +295,14 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "message", "providerTrace", "openai-responses", + "openai-responses-fallback", + "text-chat-only / 只是文本回答", "codex-stdio", "codex-cli", "codex-mcp-stdio-long-lived", "repo-owned-codex-mcp-stdio-session", "controlled-readonly-session-registry", + "read-only-session-tools:只读工具", "not-codex-stdio", "not-write-capable", "not-durable-session", @@ -304,6 +311,7 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "SOURCE fixture", "Code Agent SOURCE 回复", "message-evidence-chip", + "message-attribution-chip", "hwlab-skill-cli", "hwlab-m3-io-skill-cli", "message-m3-evidence" @@ -2411,6 +2419,9 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { /conversationId:\s*result\.conversationId \|\| result\.sessionId \|\| state\.conversationId/u.test(app) && /providerTrace:\s*result\.providerTrace/u.test(app) && /function\s+providerTraceSummary\s*\(/u.test(app) && + /function\s+messageAttributionPanel\s*\(/u.test(app) && + /codeAgentAttributionFromMessage/u.test(app) && + /attribution\.fields\.map\(\(field\) => field\.chip\)/u.test(messageEvidenceBody) && /recordField\("provider",\s*message\.provider\)/u.test(messageEvidenceBody) && /recordField\("model",\s*message\.model\)/u.test(messageEvidenceBody) && /recordField\("backend",\s*message\.backend\)/u.test(messageEvidenceBody) && @@ -2428,6 +2439,9 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { /recordField\("toolCalls",\s*toolCallsSummary\(message\.toolCalls\)\)/u.test(messageEvidenceBody) && /recordField\("skills",\s*skillsSummary\(message\.skills\)\)/u.test(messageEvidenceBody) && /recordField\("runnerTrace",\s*runnerTraceSummary\(message\.runnerTrace\)\)/u.test(messageEvidenceBody) && + /recordField\("operationId",\s*operationIdSummary\(message\)\)/u.test(messageEvidenceBody) && + /recordField\("audit",\s*auditSummary\(message\)\)/u.test(messageEvidenceBody) && + /recordField\("evidence",\s*evidenceSummary\(message\)\)/u.test(messageEvidenceBody) && /recordField\("conversation",\s*message\.conversationId\)/u.test(messageEvidenceBody) && /recordField\("trace",\s*message\.traceId\)/u.test(messageEvidenceBody) && /recordField\("message",\s*message\.messageId\)/u.test(messageEvidenceBody) && @@ -2517,8 +2531,12 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /function\s+isSourceFixtureCompletion\s*\(/u.test(app) && /function\s+isSourceFixtureCompletedChatMessage\s*\(/u.test(app) && /function\s+messageEvidencePanel\s*\(/u.test(app) && + /function\s+messageAttributionPanel\s*\(/u.test(app) && /function\s+messageEvidenceSummary\s*\(/u.test(app) && /function\s+boundedEvidenceField\s*\(/u.test(app) && + /codeAgentAttributionFromMessage/u.test(app) && + /字段缺失:证据不足/u.test(app) && + /当前不能执行/u.test(app) && /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) && /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*150000/u.test(app) && /timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/u.test(app) && @@ -2533,6 +2551,8 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /message-user/u.test(styles) && /message-agent/u.test(styles) && /message-m3-evidence\s*\{/u.test(styles) && + /message-attribution\s*\{/u.test(styles) && + /message-attribution-chip/u.test(styles) && /message-m3-rows\s*\{/u.test(styles) && /copy-chip\s*\{/u.test(styles) && /message-evidence\s*\{/u.test(styles) && @@ -3652,6 +3672,20 @@ async function inspectJourneyUi(page) { sourceMessageHasNonSensitiveMeta: Boolean( [...document.querySelectorAll(".message-card.status-source .message-meta")] .some((element) => /SOURCE|source-fixture|trc_/u.test(element.textContent ?? "")) + ), + attributionText: [...document.querySelectorAll(".message-card.message-agent .message-attribution")] + .map((element) => element.textContent ?? "") + .join("\n"), + attributionHasTraceId: [...document.querySelectorAll(".message-card.message-agent .message-attribution [data-field='traceId']")] + .some((element) => /trc_/u.test(element.textContent ?? "")), + attributionShowsToolCalls: [...document.querySelectorAll(".message-card.message-agent .message-attribution [data-field='toolCalls']")] + .some((element) => /toolCalls=/u.test(element.textContent ?? "")), + attributionMissingFieldsStructured: [...document.querySelectorAll(".message-card.message-agent .message-attribution-chip.is-missing")] + .some((element) => /字段缺失:证据不足/u.test(element.textContent ?? "")), + attributionFallbackNotRunnerControl: !/openai-responses-fallback[\s\S]{0,80}(真实 runner|Codex stdio 长会话|workspace-write)/u.test( + [...document.querySelectorAll(".message-card.message-agent .message-attribution")] + .map((element) => element.textContent ?? "") + .join("\n") ) }; }); @@ -4022,6 +4056,10 @@ async function runLocalAgentFixtureSmoke({ ui.agentChatStatus === "SOURCE 回复" && ui.sourceMessageVisible && ui.sourceMessageHasNonSensitiveMeta && + ui.attributionHasTraceId && + ui.attributionShowsToolCalls && + ui.attributionMissingFieldsStructured && + ui.attributionFallbackNotRunnerControl && !ui.completedMessageVisible && !ui.failedMessageVisible ); @@ -5086,6 +5124,25 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} model: "gpt-source-fixture", backend: "local-source-fixture", projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId), + runner: { + kind: "openai-responses-fallback", + codexStdio: false, + writeCapable: false, + durableSession: false + }, + capabilityLevel: "text-chat-only", + sessionMode: "provider-text-request", + toolCalls: [], + skills: { + status: "not_requested", + count: 0, + items: [] + }, + runnerTrace: { + traceId: stringOrFallback(body?.traceId, "trc_source_fixture_browser"), + runnerKind: "openai-responses-fallback", + events: ["fixture:text-chat-only"] + }, reply: { messageId, role: "assistant", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index fdc28515..37aafd77 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1,5 +1,6 @@ import { gateSummary } from "./gate-summary.mjs"; import { + codeAgentAttributionFromMessage, codeAgentFactsFromMessage, compactHwlabApiFact, runnerTraceSummary as compactRunnerTraceSummary, @@ -2318,6 +2319,8 @@ function messageCard(message) { article.append(textSpan(roleLabel(message.role), "message-role")); article.append(textSpan(message.title, "message-title")); article.append(textSpan(message.text, "message-copy")); + const attribution = messageAttributionPanel(message); + if (attribution) article.append(attribution); const capabilityFacts = codeAgentCapabilityFactsPanel(message); if (capabilityFacts) article.append(capabilityFacts); const m3Evidence = messageM3EvidencePanel(message); @@ -2327,6 +2330,34 @@ function messageCard(message) { return article; } +function messageAttributionPanel(message) { + const attribution = codeAgentAttributionFromMessage(message); + if (!attribution) return null; + const section = document.createElement("section"); + section.className = `message-attribution tone-border-${toneClass(attribution.tone)}`; + section.setAttribute( + "aria-label", + "消息归因:字段缺失:证据不足;fallback 只是文本回答;blocker 当前不能执行;text-chat-only / 只是文本回答;read-only-session-tools:只读工具。" + ); + section.dataset.codeAgentAttributionKind = attribution.kind; + + const header = document.createElement("div"); + header.className = "message-attribution-head"; + header.append(badge(attribution.label, attribution.tone), textSpan(attribution.summary, "message-attribution-summary")); + + const chips = document.createElement("div"); + chips.className = "message-attribution-chips"; + for (const field of attribution.fields) { + const chip = textSpan(field.chip, field.missing ? "message-attribution-chip is-missing" : "message-attribution-chip"); + chip.dataset.field = field.key; + chip.dataset.missing = field.missing ? "true" : "false"; + chips.append(chip); + } + + section.append(header, chips); + return section; +} + function codeAgentCapabilityFactsPanel(message) { const facts = codeAgentFactsFromMessage(message); if (!facts) return null; @@ -2427,7 +2458,9 @@ function messageEvidenceSummary(message, fields) { } function messageEvidenceFields(message) { + const attribution = codeAgentAttributionFromMessage(message); return [ + ...(attribution ? attribution.fields.map((field) => field.chip) : []), recordField("source", message.sourceKind), recordField("provider", message.provider), recordField("model", message.model), @@ -2446,6 +2479,9 @@ function messageEvidenceFields(message) { recordField("toolCalls", toolCallsSummary(message.toolCalls)), recordField("skills", skillsSummary(message.skills)), recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)), + recordField("operationId", operationIdSummary(message)), + recordField("audit", auditSummary(message)), + recordField("evidence", evidenceSummary(message)), recordField("conversation", message.conversationId), recordField("trace", message.traceId), recordField("message", message.messageId), @@ -2466,6 +2502,95 @@ function providerTraceSummary(providerTrace) { return null; } +function operationIdSummary(message) { + return operationAuditEvidenceSummary(message).operationId; +} + +function auditSummary(message) { + const ids = operationAuditEvidenceSummary(message); + return ids.auditId ?? ids.auditStatus; +} + +function evidenceSummary(message) { + const ids = operationAuditEvidenceSummary(message); + return ids.evidenceId ?? ids.evidenceStatus; +} + +function operationAuditEvidenceSummary(message) { + const providerTrace = objectOrNull(message.providerTrace); + const runnerTrace = objectOrNull(message.runnerTrace); + const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls : []; + const parsedToolPayloads = toolCalls.map((toolCall) => parseJsonFromText(toolCall?.stdout)).filter(Boolean); + return { + operationId: firstEvidenceText( + message.operationId, + message.operation?.operationId, + providerTrace?.operationId, + runnerTrace?.operationId, + ...toolCalls.map((toolCall) => toolCall?.operationId ?? toolCall?.hwlabApi?.operationId), + ...parsedToolPayloads.map((payload) => payload?.operationId ?? payload?.operation?.operationId) + ), + auditId: firstEvidenceText( + message.auditId, + message.audit?.auditId, + providerTrace?.auditId, + runnerTrace?.auditId, + ...toolCalls.map((toolCall) => toolCall?.auditId ?? toolCall?.audit?.auditId ?? toolCall?.hwlabApi?.auditId), + ...parsedToolPayloads.map((payload) => payload?.auditId ?? payload?.audit?.auditId) + ), + evidenceId: firstEvidenceText( + message.evidenceId, + message.evidence?.evidenceId, + providerTrace?.evidenceId, + runnerTrace?.evidenceId, + ...toolCalls.map((toolCall) => toolCall?.evidenceId ?? toolCall?.evidence?.evidenceId ?? toolCall?.hwlabApi?.evidenceId), + ...parsedToolPayloads.map((payload) => payload?.evidenceId ?? payload?.evidence?.evidenceId) + ), + auditStatus: firstEvidenceText( + message.audit?.status, + providerTrace?.auditStatus, + ...toolCalls.map((toolCall) => toolCall?.audit?.status), + ...parsedToolPayloads.map((payload) => payload?.audit?.status) + ), + evidenceStatus: firstEvidenceText( + message.evidence?.status, + providerTrace?.evidenceStatus, + ...toolCalls.map((toolCall) => toolCall?.evidence?.status), + ...parsedToolPayloads.map((payload) => payload?.evidence?.status) + ) + }; +} + +function firstEvidenceText(...values) { + return values.map((value) => { + if (value === undefined || value === null || value === "") return null; + return String(value).replace(/\s+/gu, " ").trim(); + }).find(Boolean) ?? null; +} + +function objectOrNull(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} + +function parseJsonFromText(stdout) { + if (typeof stdout !== "string" || !stdout.trim()) return null; + const text = stdout.trim(); + const direct = parseJsonObject(text); + if (direct) return direct; + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + return start >= 0 && end > start ? parseJsonObject(text.slice(start, end + 1)) : null; +} + +function parseJsonObject(text) { + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + function toolCallsSummary(toolCalls) { const summary = compactToolCallsSummary(toolCalls); return summary === "none" ? null : summary; diff --git a/web/hwlab-cloud-web/code-agent-facts.mjs b/web/hwlab-cloud-web/code-agent-facts.mjs index 79e1b679..42b4d27b 100644 --- a/web/hwlab-cloud-web/code-agent-facts.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.mjs @@ -4,6 +4,19 @@ const CODEX_ONE_SHOT_RUNNER = "codex-cli-one-shot-ephemeral"; const READONLY_SESSION_MODE = "controlled-readonly-session-registry"; const CODEX_STDIO_SESSION_MODE = "codex-mcp-stdio-long-lived"; const HWLAB_SKILL_RUNNER = "hwlab-m3-io-skill-cli"; +const MISSING_FIELD_VALUE = "字段缺失:证据不足"; +const REQUIRED_ATTRIBUTION_KEYS = Object.freeze([ + "provider", + "backend", + "runner", + "sessionMode", + "capabilityLevel", + "toolCalls", + "traceId", + "operationId", + "audit", + "evidence" +]); export function codeAgentFactsFromMessage(message) { if (!hasCodeAgentFields(message)) return null; @@ -12,9 +25,9 @@ export function codeAgentFactsFromMessage(message) { { label: "runner/provider/backend", value: compactFields([ - field("runner", message.runner?.kind ?? runnerKindFromMessage(message)), - field("provider", message.provider), - field("backend", message.backend) + requiredField("runner", message.runner?.kind ?? runnerKindFromMessage(message)), + requiredField("provider", message.provider), + requiredField("backend", message.backend) ]) }, { @@ -22,20 +35,20 @@ export function codeAgentFactsFromMessage(message) { value: compactFields([ field("id", message.session?.sessionId ?? message.sessionId ?? message.runner?.sessionId), field("status", message.session?.status), - field("sessionMode", message.sessionMode ?? message.runner?.sessionMode ?? message.runner?.session) + requiredField("sessionMode", message.sessionMode ?? message.runner?.sessionMode ?? message.runner?.session) ]) }, { label: "workspace", - value: message.workspace ?? message.runner?.workspace ?? "none" + value: valueOrMissing(message.workspace ?? message.runner?.workspace) }, { label: "sandbox", - value: message.sandbox ?? message.runner?.sandbox ?? "none" + value: valueOrMissing(message.sandbox ?? message.runner?.sandbox) }, { label: "capabilityLevel", - value: message.capabilityLevel ?? message.runner?.capabilityLevel ?? "unknown" + value: valueOrMissing(message.capabilityLevel ?? message.runner?.capabilityLevel) }, { label: "toolCalls", @@ -48,7 +61,7 @@ export function codeAgentFactsFromMessage(message) { { label: "runnerTrace/traceId", value: compactFields([ - field("traceId", message.traceId), + requiredField("traceId", message.traceId), field("runnerTrace", runnerTraceSummary(message.runnerTrace)) ]) } @@ -72,9 +85,9 @@ export function classifyCodeAgentFacts(message) { if (provider === "codex-stdio" && longLivedGate?.status === "pass" && longLivedGate?.pass === true) { return { kind: "codex-stdio-long-lived", - statusLabel: "完整 Code Agent session", + statusLabel: "真实 runner:Codex stdio 长会话", tone: "dev-live", - summary: "Codex stdio long-lived session gate passed; session lifecycle and trace capture are available.", + summary: "真实 Codex stdio runner 已通过长会话 gate;具备 session 生命周期和 trace capture 证据。", fullCodeAgent: true, codexSessionGatePass: true }; @@ -83,9 +96,9 @@ export function classifyCodeAgentFacts(message) { if (provider === "hwlab-skill-cli" || runnerKind === HWLAB_SKILL_RUNNER || hasM3IoToolCall(message?.toolCalls)) { return { kind: "hwlab-skill-cli", - statusLabel: "HWLAB Skill CLI", + statusLabel: "Skill CLI:HWLAB API 受控路径", tone: "source", - summary: "Controlled Skill CLI -> HWLAB API path only; this is not OpenAI fallback and not a generic Codex session.", + summary: "回复来自 hwlab-skill-cli 受控路径;它只代表 Skill CLI -> HWLAB API,不是 OpenAI fallback,也不是通用 Codex session。", fullCodeAgent: false, codexSessionGatePass: false }; @@ -94,9 +107,9 @@ export function classifyCodeAgentFacts(message) { if (runnerKind === CODEX_ONE_SHOT_RUNNER || sessionMode === "ephemeral-one-shot" || implementationType === "codex-cli-one-shot-ephemeral") { return { kind: "stateless-one-shot", - statusLabel: "stateless-one-shot", + statusLabel: "一次性 runner:非长会话", tone: "source", - summary: "Ephemeral one-shot runner result; do not treat it as a reusable or long-lived Code Agent session.", + summary: "回复来自一次性 runner;不可当作可复用或长会话 Code Agent。", fullCodeAgent: false, codexSessionGatePass: false }; @@ -105,9 +118,9 @@ export function classifyCodeAgentFacts(message) { if (runnerKind === TEXT_FALLBACK_RUNNER || provider === "openai-responses" || capabilityLevel === "text-chat-only") { return { kind: "text-chat-only", - statusLabel: "openai-responses-fallback / text-chat-only", + statusLabel: "OpenAI fallback:只是文本回答 / text-chat-only", tone: "source", - summary: "Text chat fallback can answer ordinary messages, but it has no workspace, tools, skills, or Codex session gate.", + summary: "只是文本回答;当前不能执行 workspace、tools、skills 或 Codex session 控制。", fullCodeAgent: false, codexSessionGatePass: false }; @@ -116,9 +129,9 @@ export function classifyCodeAgentFacts(message) { if (provider === "codex-readonly-runner" || runnerKind === "hwlab-readonly-runner" || sessionMode === READONLY_SESSION_MODE) { return { kind: READONLY_SESSION_MODE, - statusLabel: "只读 session registry", + statusLabel: "只读工具:controlled-readonly-session-registry", tone: "source", - summary: "Bounded read-only tools and session mapping are available, but Codex stdio, write capability, and durable session remain blocked.", + summary: "只读 session registry 可用;只能使用 read-only-session-tools,不能写工作区,也不能冒充完整 Codex stdio 长会话。", fullCodeAgent: false, codexSessionGatePass: false }; @@ -127,9 +140,9 @@ export function classifyCodeAgentFacts(message) { if (provider === "codex-stdio" || sessionMode === CODEX_STDIO_SESSION_MODE) { return { kind: "codex-stdio-blocked", - statusLabel: "Codex stdio blocked", + statusLabel: "Codex stdio 受阻:当前不能执行", tone: "blocked", - summary: "Codex stdio fields are present, but the long-lived session gate has not passed.", + summary: "Codex stdio 字段存在,但长会话 gate 未通过;当前不能执行 runner 控制。", fullCodeAgent: false, codexSessionGatePass: false }; @@ -137,14 +150,47 @@ export function classifyCodeAgentFacts(message) { return { kind: "unknown-code-agent-path", - statusLabel: "Code Agent facts", + statusLabel: message?.status === "failed" ? "BLOCKED:当前不能执行" : "证据不足:字段缺失", tone: message?.status === "failed" ? "blocked" : "source", - summary: "Provider response is shown with its raw runner/session facts; completion is not promoted without a passing gate.", + summary: "payload 缺少足够 provider/backend/runner/session 证据;只展示结构化字段缺失,不假定具备执行能力。", fullCodeAgent: false, codexSessionGatePass: false }; } +export function codeAgentAttributionFromMessage(message) { + if (!message || typeof message !== "object") return null; + if (message.role && message.role !== "agent") return null; + if (!hasCodeAgentFields(message)) return null; + + const classification = classifyCodeAgentFacts(message); + const ids = operationAuditEvidenceIds(message); + const runnerKind = message.runner?.kind ?? runnerKindFromMessage(message); + const sessionMode = message.sessionMode ?? message.runner?.sessionMode ?? message.runner?.session; + const capabilityLevel = message.capabilityLevel ?? message.runner?.capabilityLevel; + const fields = [ + attributionField("provider", message.provider, { message }), + attributionField("backend", message.backend, { message }), + attributionField("runner", runnerKind, { message }), + attributionField("sessionMode", sessionMode, { message }), + attributionField("capabilityLevel", capabilityLevel, { message }), + attributionField("toolCalls", toolCallsAttributionSummary(message.toolCalls), { message, missingWhenNone: false }), + attributionField("traceId", message.traceId ?? message.runnerTrace?.traceId ?? message.providerTrace?.traceId, { message }), + attributionField("operationId", ids.operationId, { message }), + attributionField("audit", ids.auditId, { message }), + attributionField("evidence", ids.evidenceId, { message }) + ]; + const missingFields = fields.filter((field) => field.missing).map((field) => field.key); + return { + kind: classification.kind, + tone: classification.tone, + label: messageAttributionLabel(message, classification), + summary: messageAttributionSummary(message, classification, missingFields), + fields, + missingFields + }; +} + export function hwlabApiFactsFromMessage(message) { const facts = []; for (const toolCall of Array.isArray(message?.toolCalls) ? message.toolCalls : []) { @@ -246,11 +292,111 @@ function hasCodeAgentFields(message) { message.sessionMode || message.capabilityLevel || Array.isArray(message.toolCalls) || - message.runnerTrace + message.runnerTrace || + message.providerTrace || + (message.role === "agent" && (message.traceId || message.error || message.status === "failed" || message.status === "running")) ) ); } +function messageAttributionLabel(message, classification) { + if (message?.status === "failed" || message?.sourceKind === "BLOCKED") return "BLOCKED:当前不能执行"; + if (classification.kind === "text-chat-only") return "OpenAI fallback:只是文本回答"; + if (classification.kind === READONLY_SESSION_MODE) return "read-only-session-tools:只读工具"; + if (classification.kind === "hwlab-skill-cli") return "hwlab-skill-cli:Skill CLI 受控路径"; + if (classification.kind === "codex-stdio-long-lived") return "真实 runner:Codex stdio 长会话"; + if (classification.kind === "stateless-one-shot") return "一次性 runner:非长会话"; + if (classification.kind === "codex-stdio-blocked") return "Codex stdio 受阻:当前不能执行"; + return classification.statusLabel; +} + +function messageAttributionSummary(message, classification, missingFields) { + const prefix = messageAttributionLabel(message, classification); + const missing = missingFields.length > 0 + ? `证据不足:${missingFields.join(",")} 字段缺失。` + : "关键归因字段已观测。"; + if (classification.kind === "text-chat-only") { + return `${prefix};只是文本回答,当前不能执行工具、Skill CLI 或 runner 控制。${missing}`; + } + if (message?.status === "failed" || message?.sourceKind === "BLOCKED") { + return `${prefix};本次回复没有执行能力。${missing}`; + } + return `${prefix};${missing}`; +} + +function attributionField(key, rawValue, { message, missingWhenNone = true } = {}) { + const text = nullableText(rawValue); + const noneLike = !text || ["none", "unknown", "null", "undefined"].includes(text.toLowerCase()); + const missing = missingWhenNone && noneLike; + const value = missing ? MISSING_FIELD_VALUE : localizedAttributionValue(key, text ?? "none", message); + return { + key, + label: key, + value, + missing, + chip: `${key}=${value}` + }; +} + +function localizedAttributionValue(key, value, message) { + const normalized = String(value ?? "").trim(); + if (key === "provider" && normalized === "openai-responses") return `${normalized} / OpenAI fallback:只是文本回答`; + if (key === "provider" && normalized === "hwlab-skill-cli") return `${normalized} / Skill CLI 受控路径`; + if (key === "runner" && normalized === TEXT_FALLBACK_RUNNER) return `${normalized} / 不是 runner 控制`; + if (key === "runner" && normalized === HWLAB_SKILL_RUNNER) return `${normalized} / Skill CLI runner`; + if (key === "runner" && normalized === "hwlab-readonly-runner") return `${normalized} / 只读 runner`; + if (key === "sessionMode" && normalized === READONLY_SESSION_MODE) return `${normalized} / 只读 session registry`; + if (key === "capabilityLevel" && normalized === "text-chat-only") return "text-chat-only / 只是文本回答"; + if (key === "capabilityLevel" && normalized === "read-only-session-tools") return `${normalized} / 只读工具`; + if (key === "capabilityLevel" && normalized === "hwlab-api-control-ready") return `${normalized} / HWLAB API 受控路径`; + if (key === "toolCalls" && normalized === "none") { + return message?.capabilityLevel === "text-chat-only" ? "0/0 / 无工具调用:只是文本回答" : "0/0 / 无工具调用"; + } + return normalized; +} + +function operationAuditEvidenceIds(message) { + const providerTrace = objectOrNull(message?.providerTrace); + const runnerTrace = objectOrNull(message?.runnerTrace); + const toolCalls = Array.isArray(message?.toolCalls) ? message.toolCalls : []; + const parsedToolPayloads = toolCalls.map((toolCall) => parseToolStdout(toolCall?.stdout)).filter(Boolean); + return { + operationId: firstText( + message?.operationId, + message?.operation?.operationId, + providerTrace?.operationId, + runnerTrace?.operationId, + ...toolCalls.map((toolCall) => toolCall?.operationId ?? toolCall?.hwlabApi?.operationId), + ...parsedToolPayloads.map((payload) => payload?.operationId ?? payload?.operation?.operationId) + ), + auditId: firstText( + message?.auditId, + message?.audit?.auditId, + providerTrace?.auditId, + runnerTrace?.auditId, + ...toolCalls.map((toolCall) => toolCall?.auditId ?? toolCall?.audit?.auditId ?? toolCall?.hwlabApi?.auditId), + ...parsedToolPayloads.map((payload) => payload?.auditId ?? payload?.audit?.auditId) + ), + evidenceId: firstText( + message?.evidenceId, + message?.evidence?.evidenceId, + providerTrace?.evidenceId, + runnerTrace?.evidenceId, + ...toolCalls.map((toolCall) => toolCall?.evidenceId ?? toolCall?.evidence?.evidenceId ?? toolCall?.hwlabApi?.evidenceId), + ...parsedToolPayloads.map((payload) => payload?.evidenceId ?? payload?.evidence?.evidenceId) + ) + }; +} + +function toolCallsAttributionSummary(toolCalls) { + const summary = toolCallsSummary(toolCalls); + return summary === "none" ? "none" : summary; +} + +function objectOrNull(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} + function hasM3IoToolCall(toolCalls) { return Array.isArray(toolCalls) && toolCalls.some((toolCall) => toolCall?.route === M3_IO_ROUTE || @@ -301,6 +447,15 @@ function field(label, value) { return normalized ? `${label}=${normalized}` : null; } +function requiredField(label, value) { + const normalized = nullableText(value); + return `${label}=${normalized ?? MISSING_FIELD_VALUE}`; +} + +function valueOrMissing(value) { + return nullableText(value) ?? MISSING_FIELD_VALUE; +} + function firstText(...values) { return values.map(nullableText).find(Boolean) ?? null; } diff --git a/web/hwlab-cloud-web/code-agent-facts.test.mjs b/web/hwlab-cloud-web/code-agent-facts.test.mjs index 403fa77f..69619360 100644 --- a/web/hwlab-cloud-web/code-agent-facts.test.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + codeAgentAttributionFromMessage, codeAgentFactsFromMessage, compactHwlabApiFact } from "./code-agent-facts.mjs"; @@ -100,14 +101,14 @@ test("long-lived Codex stdio session fields are shown as full Code Agent session }); assert.equal(facts.kind, "codex-stdio-long-lived"); - assert.equal(facts.statusLabel, "完整 Code Agent session"); + assert.equal(facts.statusLabel, "真实 runner:Codex stdio 长会话"); assert.equal(facts.fullCodeAgent, true); assert.equal(facts.codexSessionGatePass, true); assert.match(facts.rows.find((row) => row.label === "sandbox").value, /workspace-write/u); }); test("openai-responses fallback is explicitly text-chat-only", () => { - const facts = codeAgentFactsFromMessage({ + const message = { status: "source", provider: "openai-responses", model: "gpt-5.5", @@ -133,13 +134,20 @@ test("openai-responses fallback is explicitly text-chat-only", () => { events: ["fallback:text-chat-only"] }, traceId: "trc_fallback" - }); + }; + const facts = codeAgentFactsFromMessage(message); + const attribution = codeAgentAttributionFromMessage(message); assert.equal(facts.kind, "text-chat-only"); - assert.equal(facts.statusLabel, "openai-responses-fallback / text-chat-only"); + assert.equal(facts.statusLabel, "OpenAI fallback:只是文本回答 / text-chat-only"); assert.equal(facts.fullCodeAgent, false); assert.equal(facts.codexSessionGatePass, false); - assert.match(facts.summary, /no workspace, tools, skills, or Codex session gate/u); + assert.match(facts.summary, /只是文本回答/u); + assert.match(attribution.label, /只是文本回答/u); + assert.match(attribution.summary, /当前不能执行工具/u); + assert.equal(attribution.fields.find((field) => field.key === "runner").value, "openai-responses-fallback / 不是 runner 控制"); + assert.equal(attribution.fields.find((field) => field.key === "capabilityLevel").value, "text-chat-only / 只是文本回答"); + assert.equal(attribution.fields.find((field) => field.key === "toolCalls").value, "0/0 / 无工具调用:只是文本回答"); }); test("stateless one-shot is not wrapped as a long-lived session", () => { @@ -176,7 +184,7 @@ test("stateless one-shot is not wrapped as a long-lived session", () => { assert.equal(facts.kind, "stateless-one-shot"); assert.equal(facts.fullCodeAgent, false); assert.equal(facts.codexSessionGatePass, false); - assert.match(facts.summary, /Ephemeral one-shot runner/u); + assert.match(facts.summary, /一次性 runner/u); }); test("HWLAB Skill CLI toolCall shows compact copyable /v1/m3/io facts", () => { @@ -281,4 +289,47 @@ test("HWLAB Skill CLI toolCall shows compact copyable /v1/m3/io facts", () => { compactHwlabApiFact(facts.hwlabApiFacts[0]), "route=/v1/m3/io / method=POST / operationId=op_m3_do_write_cli / traceId=trc_m3_skill_cli / auditId=aud_m3_do_write_cli_succeeded / evidenceId=evd_m3_do_write_cli_succeeded / blocker=runtime_durable_not_green" ); + const attribution = codeAgentAttributionFromMessage({ + role: "agent", + status: "source", + provider: "hwlab-skill-cli", + backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", + capabilityLevel: "hwlab-api-control-ready", + sessionMode: "controlled-m3-io-skill-cli", + runner: { kind: "hwlab-m3-io-skill-cli" }, + toolCalls: facts.hwlabApiFacts.map((fact) => ({ + name: fact.tool, + status: fact.status, + route: fact.route, + operationId: fact.operationId, + traceId: fact.traceId, + auditId: fact.auditId, + evidenceId: fact.evidenceId + })), + traceId: "trc_m3_skill_cli" + }); + assert.equal(attribution.kind, "hwlab-skill-cli"); + assert.equal(attribution.fields.find((field) => field.key === "operationId").value, "op_m3_do_write_cli"); + assert.equal(attribution.fields.find((field) => field.key === "audit").value, "aud_m3_do_write_cli_succeeded"); + assert.equal(attribution.fields.find((field) => field.key === "evidence").value, "evd_m3_do_write_cli_succeeded"); + assert.match(attribution.fields.find((field) => field.key === "toolCalls").value, /hwlab-agent-runtime\.m3-io\(completed\)@\/v1\/m3\/io/u); +}); + +test("missing attribution fields render structured evidence insufficiency", () => { + const attribution = codeAgentAttributionFromMessage({ + role: "agent", + status: "failed", + traceId: "trc_missing_fields", + error: { + code: "provider_unavailable", + message: "blocked" + } + }); + + assert.equal(attribution.label, "BLOCKED:当前不能执行"); + assert.equal(attribution.fields.find((field) => field.key === "traceId").value, "trc_missing_fields"); + assert.equal(attribution.fields.find((field) => field.key === "provider").missing, true); + assert.equal(attribution.fields.find((field) => field.key === "backend").value, "字段缺失:证据不足"); + assert.equal(attribution.fields.find((field) => field.key === "runner").chip, "runner=字段缺失:证据不足"); + assert.match(attribution.summary, /字段缺失/u); }); diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 5b791203..ddcd4ed0 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -704,6 +704,9 @@ assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/); assert.match(app, /providerTraceSummary\(message\.providerTrace\)/); assert.match(app, /recordField\("conversation", message\.conversationId\)/); assert.match(app, /recordField\("provider", message\.provider\)/); +assert.match(app, /recordField\("operationId", operationIdSummary\(message\)\)/); +assert.match(app, /recordField\("audit", auditSummary\(message\)\)/); +assert.match(app, /recordField\("evidence", evidenceSummary\(message\)\)/); assert.match(app, /recordField\("session", sessionSummary\(message\.session\)\)/); assert.match(app, /recordField\("sessionMode", message\.sessionMode\)/); assert.match(app, /recordField\("sessionReuse", sessionReuseSummary\(message\.sessionReuse\)\)/); @@ -735,8 +738,10 @@ assert.match(app, /function isSourceFixtureCompletedChatMessage/); assert.match(app, /Code Agent SOURCE 回复/); assert.match(app, /SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/); assert.match(app, /function messageEvidencePanel/); +assert.match(app, /function messageAttributionPanel/); assert.match(app, /function messageEvidenceSummary/); assert.match(app, /function boundedEvidenceField/); +assert.match(app, /codeAgentAttributionFromMessage/); assert.match(app, /codeAgentFactsFromMessage/); assert.match(app, /function codeAgentCapabilityFactsPanel/); assert.match(app, /compactHwlabApiFact/); @@ -747,8 +752,12 @@ assert.match(app, /codeAgent\.status/); assert.match(app, /当前部署 revision/); assert.match(codeAgentFacts, /openai-responses-fallback/); assert.match(codeAgentFacts, /text-chat-only/); +assert.match(codeAgentFacts, /OpenAI fallback:只是文本回答/); +assert.match(codeAgentFacts, /字段缺失:证据不足/); +assert.match(codeAgentFacts, /codeAgentAttributionFromMessage/); assert.match(codeAgentFacts, /stateless-one-shot/); assert.match(codeAgentFacts, /controlled-readonly-session-registry/); +assert.match(codeAgentFacts, /read-only-session-tools:只读工具/); assert.match(codeAgentFacts, /hwlab-m3-io-skill-cli/); assert.match(codeAgentFacts, /route", fact\.route/); assert.match(codeAgentFacts, /operationId", fact\.operationId/); @@ -773,6 +782,9 @@ assert.match(styles, /\.message-card\.status-completed\s*{/); assert.match(styles, /\.message-card\.status-source\s*{/); assert.match(styles, /\.message-user\s*{/); assert.match(styles, /\.message-agent,\s*\n\.message-system\s*{/); +assert.match(styles, /\.message-attribution\s*{/); +assert.match(styles, /\.message-attribution-chip,\s*\n\.message-evidence-chip\s*{/); +assert.match(styles, /\.message-attribution-chip\.is-missing\s*{/); assert.match(styles, /\.message-m3-evidence\s*{/); assert.match(styles, /\.message-m3-rows\s*{/); assert.match(styles, /\.message-m3-row\s*{/); @@ -793,6 +805,7 @@ assert.match(styles, /\.(?:status-dot|state-tag|badge)[^{]*{[^}]*max-width:\s*10 assert.match(styles, /\.probe-card\s*{[^}]*min-width:\s*0;/s); assert.match(styles, /\.probe-card strong\s*{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/s); assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-m3-rows\s*{[\s\S]*?grid-template-columns:\s*1fr;/); +assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-attribution,\s*\n\s*\.code-agent-facts,\s*\n\s*\.message-m3-evidence\s*{[\s\S]*?grid-column:\s*1;/); assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-evidence\s*{[\s\S]*?grid-column:\s*1;/); assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.code-agent-fact-grid\s*{[\s\S]*?grid-template-columns:\s*1fr;/); for (const userFacingFunctionName of [ diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index e7c8f88a..7e76398b 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -591,6 +591,7 @@ h3 { min-width: 0; } +.message-attribution, .code-agent-facts, .message-m3-evidence { grid-column: 2; @@ -601,6 +602,11 @@ h3 { border: 1px solid var(--line); } +.message-attribution { + background: rgba(20, 24, 23, 0.76); + border-left-width: 3px; +} + .code-agent-facts { background: rgba(15, 17, 16, 0.72); border-left-width: 3px; @@ -610,6 +616,7 @@ h3 { background: rgba(15, 17, 16, 0.44); } +.message-attribution-head, .code-agent-facts-head { min-width: 0; display: grid; @@ -618,6 +625,7 @@ h3 { align-items: start; } +.message-attribution-summary, .code-agent-facts-summary { min-width: 0; color: var(--muted); @@ -776,6 +784,14 @@ h3 { margin-top: 6px; } +.message-attribution-chips { + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.message-attribution-chip, .message-evidence-chip { max-width: 100%; padding: 2px 5px; @@ -787,6 +803,11 @@ h3 { overflow-wrap: anywhere; } +.message-attribution-chip.is-missing { + border-color: rgba(223, 170, 85, 0.55); + color: var(--accent); +} + .message-blocker { border-color: rgba(231, 110, 94, 0.48); } @@ -1705,6 +1726,7 @@ tbody tr:last-child td { grid-template-columns: 1fr; } + .message-attribution, .code-agent-facts, .message-m3-evidence { grid-column: 1; @@ -1722,6 +1744,7 @@ tbody tr:last-child td { grid-column: 1; } + .message-attribution-head, .code-agent-facts-head, .code-agent-fact-grid, .code-agent-summary summary,