diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 3a895832..eeb2951d 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -132,7 +132,10 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "openai-responses", "codex-cli", "echo/mock/stub", - "untrusted_completion" + "untrusted_completion", + "SOURCE fixture", + "Code Agent SOURCE 回复", + "message-evidence-chip" ]); const forbiddenWritePatterns = Object.freeze([ @@ -163,7 +166,10 @@ export function runDevCloudWorkbenchStaticSmoke() { export async function runDevCloudWorkbenchSmoke(argv = []) { const args = parseSmokeArgs(argv); - return args.mode === "live" ? runLiveSmoke(args) : runStaticSmoke(); + if (args.mode === "live") return runLiveSmoke(args); + if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke(); + if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke(); + return runStaticSmoke(); } export function parseSmokeArgs(argv) { @@ -175,7 +181,10 @@ export function parseSmokeArgs(argv) { } else if (arg === "--live") { args.mode = "live"; } else if (arg === "--mobile") { + args.mode = "mobile"; args.mobile = true; + } else if (arg === "--local-agent-fixture") { + args.mode = "local-agent-fixture"; } else if (arg === "--url") { index += 1; if (!argv[index]) throw new Error("--url requires a value"); @@ -296,6 +305,17 @@ function runStaticSmoke() { evidence: requiredCodeAgentEvidenceTerms }); + addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and provider_unavailable states as distinct conversation replies with bounded evidence.", { + blocker: "observability_blocker", + evidence: [ + "DEV-LIVE 回复", + "SOURCE 回复", + "发送失败", + "BLOCKED 凭证缺口", + "message-evidence/details/chips" + ] + }); + addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(source), "Workbench source does not expose hardware write APIs or live mutation commands.", { blocker: "safety_blocker", evidence: forbiddenWritePatterns.map(String) @@ -413,6 +433,7 @@ async function runLiveSmoke(args) { function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, help = undefined, safety, url = null, runtimeIdentity = null }) { const sourceIdentity = observeSourceIdentity(); + const liveJourneyPassed = mode === "live" && status === "pass"; return { $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json", @@ -465,15 +486,19 @@ function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, he "node --check scripts/dev-cloud-workbench-smoke.mjs", "node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs", "node scripts/dev-cloud-workbench-smoke.mjs --static", + "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json", "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json", "node scripts/validate-dev-gate-report.mjs" ], localSmoke: { status: mode === "static" ? status : "not_run", - commands: ["node scripts/dev-cloud-workbench-smoke.mjs --static"], - evidence: ["Static mode verifies the source contract and same-origin Code Agent wiring only."], - summary: "Static workbench smoke is SOURCE-level evidence and does not prove the deployed browser journey." + commands: [ + "node scripts/dev-cloud-workbench-smoke.mjs --static", + "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture" + ], + evidence: ["Static mode verifies the source contract and same-origin Code Agent wiring only.", "Local fixture browser mode verifies send/reply UX at SOURCE level."], + summary: "Static and local fixture workbench smokes are SOURCE-level evidence and do not prove the deployed browser journey." }, dryRun: { status: "not_applicable", @@ -482,7 +507,7 @@ function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, he summary: "The journey evidence is collected from the real DEV browser endpoint, not a dry-run fixture." }, devPreconditions: { - status: status === "pass" ? "pass" : "blocked", + status: liveJourneyPassed ? "pass" : "blocked", requirements: [ "GET http://74.48.78.17:16666/ serves the Cloud Workbench HTML.", "Browser DOM exposes the default workbench and core controls.", @@ -492,9 +517,11 @@ function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, he commands: [ "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json" ], - summary: status === "pass" + summary: liveJourneyPassed ? "Deployed 16666 browser journey and same-origin Code Agent chat completed." - : "Deployed browser journey is blocked; see checks and blockers." + : mode === "live" + ? "Deployed browser journey is blocked; see checks and blockers." + : "Static/local fixture evidence does not establish the deployed 16666 browser journey." }, endpoints: { frontend: runtime.endpoints.frontend, @@ -862,12 +889,39 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) { /UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/u.test(realEvidenceBody) && /value\?\.conversationId \|\| value\?\.sessionId/u.test(realEvidenceBody) && /isRealCompletedChatResult\(result\)/u.test(app) && - /realCompleted \? "completed" : "failed"/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")) && /Code Agent 完成证据不足/u.test(app) && /本次不会标记为真实完成/u.test(app) ); } +function hasCodeAgentConversationUxStates({ app, styles }) { + const submitBody = functionBody(app, "initCommandBar"); + return ( + /function\s+classifyCodeAgentCompletion\s*\(/u.test(app) && + /function\s+isSourceFixtureChatResult\s*\(/u.test(app) && + /function\s+isSourceFixtureCompletion\s*\(/u.test(app) && + /function\s+isSourceFixtureCompletedChatMessage\s*\(/u.test(app) && + /function\s+messageEvidencePanel\s*\(/u.test(app) && + /function\s+messageEvidenceSummary\s*\(/u.test(app) && + /function\s+boundedEvidenceField\s*\(/u.test(app) && + /Code Agent SOURCE 回复/u.test(app) && + /SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/u.test(app) && + /sourceKind:\s*completion\.sourceKind/u.test(submitBody) && + /completion\.replied/u.test(submitBody) && + /status-source/u.test(styles) && + /message-user/u.test(styles) && + /message-agent/u.test(styles) && + /message-evidence\s*\{/u.test(styles) && + /message-evidence-chip/u.test(styles) && + /width:\s*min\(100%,\s*780px\)/u.test(styles) && + /status === "completed" \? "dev-live" : status === "failed" \|\| status === "blocked" \? "blocked" : "source"/u.test(app) && + !/sourceKind:\s*"SOURCE"[\s\S]{0,160}status:\s*"completed"/u.test(app) + ); +} + function noForbiddenWriteSurface(source) { return forbiddenWritePatterns.every((pattern) => !pattern.test(source)); } @@ -1286,7 +1340,7 @@ async function inspectLiveUserJourney(url) { Boolean(responseSummary.provider) && Boolean(responseSummary.model) && Boolean(responseSummary.traceId); - const uiCompleted = ui.agentChatStatus === "已回复" || ui.completedMessageVisible; + const uiCompleted = ui.agentChatStatus === "DEV-LIVE 回复" || ui.completedMessageVisible; const pass = backendCompleted && uiCompleted && !ui.failedMessageVisible; return { @@ -1357,11 +1411,16 @@ async function inspectJourneyUi(page) { agentChatStatus: document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null, inputCleared: document.querySelector("#command-input")?.value === "", completedMessageVisible: Boolean(document.querySelector(".message-card.status-completed")), + sourceMessageVisible: Boolean(document.querySelector(".message-card.status-source")), failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")), messageCount: document.querySelectorAll(".message-card").length, completedMessageHasNonSensitiveMeta: Boolean( [...document.querySelectorAll(".message-card.status-completed .message-meta")] .some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? "")) + ), + sourceMessageHasNonSensitiveMeta: Boolean( + [...document.querySelectorAll(".message-card.status-source .message-meta")] + .some((element) => /SOURCE|source-fixture|trc_/u.test(element.textContent ?? "")) ) })); } @@ -1370,6 +1429,8 @@ function sanitizeAgentChatBody(body) { if (!body || typeof body !== "object") return null; return { status: typeof body.status === "string" ? body.status : null, + sourceKind: typeof body.sourceKind === "string" ? body.sourceKind : null, + evidenceLevel: typeof body.evidenceLevel === "string" ? body.evidenceLevel : null, provider: typeof body.provider === "string" ? body.provider : null, model: typeof body.model === "string" ? body.model : null, backend: typeof body.backend === "string" ? body.backend : null, @@ -1384,6 +1445,145 @@ function sanitizeAgentChatBody(body) { }; } +export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { + let chromium; + try { + ({ chromium } = await import("playwright")); + } catch (error) { + return { + status: "skip", + task: "DC-DCSN-P0-2026-003", + mode: "local-agent-fixture-browser", + evidenceLevel: "SOURCE", + devLive: false, + summary: `Local Code Agent browser fixture smoke skipped because Playwright is unavailable: ${error.message}`, + checks: [], + blockers: [], + safety: staticSafety() + }; + } + + const server = await startStaticWebServer({ agentFixture: true }); + let browser; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }); + const agentResponses = []; + page.on("response", async (response) => { + if (!response.url().includes("/v1/agent/chat")) return; + let body = null; + try { + body = await response.json(); + } catch { + body = null; + } + agentResponses.push({ + method: response.request().method(), + status: response.status(), + urlPath: new URL(response.url()).pathname, + body: sanitizeAgentChatBody(body) + }); + }); + + await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 }); + await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 }); + const controls = await inspectJourneyControls(page); + await page.locator("#command-input").fill("请用一句话说明当前 HWLAB 工作台可以做什么。"); + const responsePromise = page.waitForResponse( + (response) => response.url().includes("/v1/agent/chat") && response.request().method() === "POST", + { timeout: 15000 } + ); + await page.locator("#command-send").click(); + const response = await responsePromise; + const responseBody = await response.json().catch(() => null); + await page.waitForFunction( + () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复", + null, + { timeout: 8000 } + ); + const ui = await inspectJourneyUi(page); + const responseSummary = sanitizeAgentChatBody(responseBody); + const pass = + response.ok() && + responseSummary?.status === "completed" && + responseSummary?.sourceKind === "SOURCE" && + responseSummary?.hasReply === true && + ui.agentChatStatus === "SOURCE 回复" && + ui.sourceMessageVisible && + ui.sourceMessageHasNonSensitiveMeta && + !ui.completedMessageVisible && + !ui.failedMessageVisible; + const checks = [ + { + id: "local-agent-fixture-controls", + status: Object.values(controls).every(Boolean) ? "pass" : "blocked", + summary: "Local SOURCE fixture browser smoke can locate the Code Agent input, send button, thread, and right-side evidence panels.", + observations: controls + }, + { + id: "local-agent-fixture-replied-state", + status: pass ? "pass" : "blocked", + summary: "Local SOURCE fixture sends input and reaches a user-visible replied state without marking the fixture as DEV-LIVE.", + observations: { + response: responseSummary, + ui, + networkEvents: agentResponses + } + } + ]; + const blockers = checks + .filter((check) => check.status !== "pass") + .map((check) => ({ + type: "observability_blocker", + scope: check.id, + status: "open", + summary: check.summary + })); + return { + status: blockers.length === 0 ? "pass" : "blocked", + task: "DC-DCSN-P0-2026-003", + mode: "local-agent-fixture-browser", + url: server.url, + generatedAt: new Date().toISOString(), + evidenceLevel: "SOURCE", + devLive: false, + summary: "Local browser smoke uses a sanitized SOURCE fixture for /v1/agent/chat; it proves the UI conversation path only and does not claim DEV-LIVE.", + checks, + blockers, + safety: { + ...staticSafety(), + localFixtureOnly: true, + fixtureTrustBoundary: "SOURCE fixture replies render as SOURCE 回复 and never as DEV-LIVE 回复.", + preservedLiveFields: ["provider", "model", "backend", "conversationId", "traceId", "messageId", "providerTrace"] + } + }; + } catch (error) { + return { + status: "blocked", + task: "DC-DCSN-P0-2026-003", + mode: "local-agent-fixture-browser", + url: server.url, + generatedAt: new Date().toISOString(), + evidenceLevel: "SOURCE", + devLive: false, + summary: `Local Code Agent browser fixture smoke failed: ${error.message}`, + checks: [], + blockers: [ + { + type: "observability_blocker", + scope: "local-agent-fixture-browser", + status: "open", + summary: error.message + } + ], + safety: staticSafety() + }; + } finally { + if (browser) await browser.close(); + await server.close(); + } +} + export async function runDevCloudWorkbenchMobileSmoke() { let chromium; try { @@ -1537,9 +1737,12 @@ export async function runDevCloudWorkbenchMobileSmoke() { } } -async function startStaticWebServer() { +async function startStaticWebServer(options = {}) { const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url })) { + return; + } let pathname = decodeURIComponent(url.pathname); if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate") { pathname = "/index.html"; @@ -1570,6 +1773,140 @@ async function startStaticWebServer() { }; } +async function handleLocalAgentFixtureApi({ request, response, url }) { + if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { + jsonResponse(response, 200, { + serviceId: "hwlab-cloud-api", + status: "degraded", + sourceKind: "SOURCE", + codeAgent: localSourceFixtureAvailability(), + methods: [...readOnlyRpcMethods], + runtime: { + durable: false, + status: "degraded", + counts: { + gatewaySessions: gateSummary.gatewaySessionCount, + boxResources: gateSummary.boxResourceCount + } + } + }); + return true; + } + if (request.method === "POST" && url.pathname === "/json-rpc") { + const body = await readJsonBody(request); + jsonResponse(response, 200, { + jsonrpc: "2.0", + id: body?.id ?? "req_source_fixture", + result: localRpcFixtureResult(body?.method) + }); + return true; + } + if (request.method === "POST" && url.pathname === "/v1/agent/chat") { + const body = await readJsonBody(request); + const timestamp = "2026-05-22T00:00:00.000Z"; + const conversationId = stringOrFallback(body?.conversationId, "cnv_source_fixture_browser"); + jsonResponse(response, 200, { + conversationId, + sessionId: conversationId, + messageId: "msg_source_fixture_browser", + status: "completed", + sourceKind: "SOURCE", + evidenceLevel: "SOURCE", + createdAt: timestamp, + updatedAt: timestamp, + traceId: stringOrFallback(body?.traceId, "trc_source_fixture_browser"), + provider: "source-fixture", + model: "gpt-source-fixture", + backend: "local-source-fixture", + projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId), + reply: { + messageId: "msg_source_fixture_browser", + role: "assistant", + content: "SOURCE fixture 回复:HWLAB 工作台可以整理任务、查看资源,并保持硬件变更受控。", + createdAt: timestamp + }, + usage: null, + providerTrace: { + source: "SOURCE-local-browser-fixture", + sourceKind: "SOURCE", + responseId: "rsp_source_fixture_sanitized", + redacted: true + } + }); + return true; + } + return false; +} + +function localSourceFixtureAvailability() { + return { + endpoint: "POST /v1/agent/chat", + provider: "source-fixture", + model: "gpt-source-fixture", + backend: "local-source-fixture", + mode: "local-source-fixture", + status: "available", + sourceKind: "SOURCE", + evidenceLevel: "SOURCE", + ready: true, + summary: "Local browser smoke SOURCE fixture; it is not DEV-LIVE provider evidence.", + missingEnv: [], + secretRefs: [], + safety: { + localFixtureOnly: true, + secretsRead: false, + valuesRedacted: true + } + }; +} + +function localRpcFixtureResult(method) { + if (method === "system.health" || method === "cloud.adapter.describe") { + return { + status: "degraded", + sourceKind: "SOURCE", + methods: [...readOnlyRpcMethods], + runtime: { + durable: false, + counts: { + gatewaySessions: gateSummary.gatewaySessionCount, + boxResources: gateSummary.boxResourceCount + } + } + }; + } + if (method === "audit.event.query") { + return { count: 0, events: [] }; + } + if (method === "evidence.record.query") { + return { count: 0, records: [] }; + } + return { status: "not_implemented", sourceKind: "SOURCE" }; +} + +async function readJsonBody(request) { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + const text = Buffer.concat(chunks).toString("utf8"); + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function jsonResponse(response, status, body) { + response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(body)); +} + +function stringOrFallback(value, fallback) { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + function contentTypeFor(filePath) { if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; @@ -1825,10 +2162,11 @@ function escapeRegExp(value) { export function printSmokeHelp() { return { status: "usage", - command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --mobile | --live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]", + command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --mobile | --local-agent-fixture | --live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]", notes: [ "Static mode reads repository files and emits SOURCE-level evidence only.", "--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.", + "--local-agent-fixture runs a local browser send/reply path with a sanitized SOURCE fixture and does not claim DEV-LIVE.", "Live mode opens the deployed workbench in a browser, verifies core controls, sends one Code Agent message, and retains only non-sensitive response fields." ] }; diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 130c0068..bffe9fb1 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -283,12 +283,12 @@ function initCommandBar() { const result = await sendAgentMessage(value, state.conversationId, traceId); state.conversationId = result.conversationId || result.sessionId || state.conversationId; const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); - const realCompleted = isRealCompletedChatResult(result); - const status = realCompleted ? "completed" : "failed"; + const completion = classifyCodeAgentCompletion(result); + const status = completion.status; state.chatMessages[index] = { ...pendingMessage, - title: realCompleted ? sourceTitle(result) : result.status === "completed" ? "Code Agent 完成证据不足" : "Code Agent 返回失败", - text: realCompleted + title: completion.title, + text: completion.replied ? result.reply?.content || "Code Agent 没有返回文本。" : result.status === "completed" ? untrustedCompletionMessage(result) @@ -300,9 +300,10 @@ function initCommandBar() { provider: result.provider, model: result.model, backend: result.backend, + sourceKind: completion.sourceKind, providerTrace: result.providerTrace, updatedAt: result.updatedAt, - error: result.error ?? (result.status === "completed" && !realCompleted + error: result.error ?? (result.status === "completed" && !completion.replied ? { code: "untrusted_completion", message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。" @@ -313,7 +314,7 @@ function initCommandBar() { if (result.availability) { state.codeAgentAvailability = result.availability; } - if (!realCompleted) { + if (!completion.replied) { el.commandInput.value = value; } renderAgentChatStatus(status, result); @@ -1289,6 +1290,7 @@ function renderGateDiagnostics() { "node --check web/hwlab-cloud-web/app.mjs", "node --check web/hwlab-cloud-web/scripts/check.mjs", "node web/hwlab-cloud-web/scripts/check.mjs", + "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", "node scripts/l6-cli-web-smoke.mjs", "git diff --check" ].map((command) => { @@ -1307,7 +1309,8 @@ function renderAgentChatStatus(status, result = null) { const labels = { idle: "等待输入", running: "处理中", - completed: "已回复", + completed: "DEV-LIVE 回复", + source: "SOURCE 回复", failed: "发送失败", blocked: "BLOCKED 凭证缺口" }; @@ -1327,6 +1330,35 @@ function sourceTitle(result) { return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; } +function sourceFixtureTitle(result) { + return `Code Agent SOURCE 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; +} + +function classifyCodeAgentCompletion(result) { + if (isRealCompletedChatResult(result)) { + return { + status: "completed", + replied: true, + sourceKind: "DEV-LIVE", + title: sourceTitle(result) + }; + } + if (isSourceFixtureChatResult(result)) { + return { + status: "source", + replied: true, + sourceKind: "SOURCE", + title: sourceFixtureTitle(result) + }; + } + return { + status: "failed", + replied: false, + sourceKind: "BLOCKED", + title: result?.status === "completed" ? "Code Agent 完成证据不足" : "Code Agent 返回失败" + }; +} + function failureMessage(result) { if (result?.error?.code === "provider_unavailable" || result?.availability?.status === "blocked") { const availability = result.availability ?? codeAgentAvailabilityFromResult(result); @@ -1357,15 +1389,35 @@ 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 messageMeta = messageEvidenceFields(message).join(" / "); - if (messageMeta) { - article.append(textSpan(messageMeta, "message-meta")); - } + const messageEvidence = messageEvidencePanel(message); + if (messageEvidence) article.append(messageEvidence); return article; } +function messageEvidencePanel(message) { + const fields = messageEvidenceFields(message).map(boundedEvidenceField); + if (fields.length === 0) return null; + const details = document.createElement("details"); + details.className = "message-evidence"; + details.open = message.status === "completed" || message.status === "source"; + const summary = document.createElement("summary"); + summary.className = "message-meta"; + summary.textContent = messageEvidenceSummary(message, fields); + const chips = document.createElement("div"); + chips.className = "message-evidence-chips"; + chips.append(...fields.map((field) => textSpan(field, "message-evidence-chip"))); + details.append(summary, chips); + return details; +} + +function messageEvidenceSummary(message, fields) { + const sourceKind = message.sourceKind ?? (message.status === "completed" ? "DEV-LIVE" : message.status === "failed" ? "BLOCKED" : "SOURCE"); + return `证据 ${sourceKind} / ${fields.slice(0, 3).join(" / ")}`; +} + function messageEvidenceFields(message) { return [ + recordField("source", message.sourceKind), recordField("provider", message.provider), recordField("model", message.model), recordField("backend", message.backend), @@ -1376,6 +1428,11 @@ function messageEvidenceFields(message) { ].filter(Boolean); } +function boundedEvidenceField(field) { + const text = String(field ?? "").replace(/\s+/gu, " ").trim(); + return text.length > 96 ? `${text.slice(0, 93)}...` : text; +} + function providerTraceSummary(providerTrace) { if (!providerTrace || typeof providerTrace !== "object") return null; const responseId = providerTrace.responseId ?? providerTrace.id; @@ -1491,6 +1548,14 @@ function codeAgentStatusMessage(availability) { status: "blocked" }; } + if (String(availability?.sourceKind ?? availability?.evidenceLevel ?? "").toUpperCase() === "SOURCE") { + return { + role: "system", + title: "Code Agent 状态:SOURCE fixture", + text: "当前只用于本地浏览器 smoke:可以验证发送、处理中、回复渲染和 evidence 展示,但不会标记为 DEV-LIVE。", + status: "source" + }; + } if (availability?.status === "available") { return { role: "system", @@ -1520,6 +1585,9 @@ function codeAgentControlSummary(availability) { ? `当前会话 ${state.conversationId};最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口,并带有 provider/model/trace/conversation 证据。` : "最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口,并带有 provider/model/trace/conversation 证据。"; } + if (isSourceFixtureCompletedChatMessage(latestChatResult())) { + return "最近一次为 SOURCE fixture 回复;它证明本地浏览器对话 UX,不作为 DEV-LIVE 或 #143 完成证据。"; + } if (availability?.status === "blocked") { return codeAgentBlockedSummary(availability); } @@ -1532,13 +1600,16 @@ function codeAgentBlockedSummary(availability) { function untrustedCompletionMessage(result) { const provider = result?.provider ?? "unknown"; - return `Code Agent 返回 completed,但缺少真实 provider/model/trace/conversation 证据,或 provider=${provider} 属于 echo/mock/stub;本次不会标记为真实完成。`; + return `Code Agent 返回 completed,但缺少真实 provider/model/trace/conversation 证据,或 provider=${provider} 属于 echo/mock/stub;本次不会标记为真实完成。SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE。`; } function codeAgentAvailabilitySummary() { if (state.codeAgentAvailability?.status === "blocked") { return "同源 API 可响应;Code Agent 为 BLOCKED/凭证缺口,真实后端已接入但 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入。"; } + if (String(state.codeAgentAvailability?.sourceKind ?? state.codeAgentAvailability?.evidenceLevel ?? "").toUpperCase() === "SOURCE") { + return "同源 API 可响应;当前 Code Agent 回复来源是 SOURCE fixture,仅用于本地浏览器 smoke,不是 DEV-LIVE。"; + } if (state.codeAgentAvailability?.status === "available") { return "同源 API 可响应;Code Agent 真实通道已接入,只有 completed 回复才标 DEV-LIVE。"; } @@ -1554,13 +1625,22 @@ function latestCompletedAgentMessage() { function isRealCompletedChatResult(result) { const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; - return result?.status === "completed" && assistantReply.length > 0 && hasRealCodeAgentEvidence(result); + return result?.status === "completed" && assistantReply.length > 0 && !isSourceFixtureCompletion(result) && hasRealCodeAgentEvidence(result); +} + +function isSourceFixtureChatResult(result) { + const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : ""; + return result?.status === "completed" && assistantReply.length > 0 && isSourceFixtureCompletion(result); } function isRealCompletedChatMessage(message) { return message?.status === "completed" && !message.error && hasRealCodeAgentEvidence(message); } +function isSourceFixtureCompletedChatMessage(message) { + return message?.status === "source" && !message.error && message.sourceKind === "SOURCE"; +} + function hasRealCodeAgentEvidence(value) { return ( isTrustedCodeAgentProvider(value?.provider) && @@ -1577,6 +1657,12 @@ function isTrustedCodeAgentProvider(provider) { return TRUSTED_CODE_AGENT_PROVIDERS.includes(normalized); } +function isSourceFixtureCompletion(value) { + const sourceKind = String(value?.sourceKind ?? value?.evidenceLevel ?? value?.providerTrace?.sourceKind ?? "").trim().toUpperCase(); + const traceSource = String(value?.providerTrace?.source ?? value?.providerTrace?.mode ?? "").trim().toUpperCase(); + return sourceKind === "SOURCE" || traceSource.startsWith("SOURCE"); +} + function latestChatResult() { return [...state.chatMessages].reverse().find((message) => message.role === "agent") ?? null; } @@ -1585,6 +1671,7 @@ function deriveAgentChatStatus() { if (state.chatPending) return "running"; const latest = latestChatResult(); if (latest?.status === "completed") return "completed"; + if (latest?.status === "source") return "source"; if (latest?.status === "failed") return latest.error?.code === "provider_unavailable" ? "blocked" : "failed"; if (state.codeAgentAvailability?.status === "blocked") return "blocked"; return "idle"; @@ -1592,6 +1679,7 @@ function deriveAgentChatStatus() { function currentConversationTone() { if (latestCompletedAgentMessage()) return "dev-live"; + if (isSourceFixtureCompletedChatMessage(latestChatResult())) return "source"; if (state.codeAgentAvailability?.status === "blocked" || latestChatResult()?.status === "failed") return "blocked"; return "source"; } diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 141b5169..6d3191c8 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs"; import { + runDevCloudWorkbenchLocalAgentFixtureSmoke, runDevCloudWorkbenchMobileSmoke, runDevCloudWorkbenchStaticSmoke } from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs"; @@ -60,17 +61,23 @@ const requiredTrustedRecordTerms = Object.freeze([ const workbenchSmoke = runDevCloudWorkbenchStaticSmoke(); const helpSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "help-md-contract"); const mobileLayoutSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "mobile-workbench-layout-contract"); +const conversationUxSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "code-agent-conversation-ux-states"); const mobileWorkbenchSmoke = await runDevCloudWorkbenchMobileSmoke(); +const localAgentFixtureSmoke = await runDevCloudWorkbenchLocalAgentFixtureSmoke(); assert.equal(workbenchSmoke.status, "pass", JSON.stringify(workbenchSmoke.blockers, null, 2)); assert.equal(workbenchSmoke.evidenceLevel, "SOURCE"); assert.equal(workbenchSmoke.devLive, false); assert.equal(helpSmokeCheck?.status, "pass", "PR #114 help Markdown route must be present and ready"); assert.equal(mobileLayoutSmokeCheck?.status, "pass", "mobile 390x844 workbench layout contract must be present"); +assert.equal(conversationUxSmokeCheck?.status, "pass", "Code Agent conversation UX states must remain distinct"); assert.equal(workbenchSmoke.help.status, "pass", "smoke report must not leave #114 help contract pending"); assert.notEqual(mobileWorkbenchSmoke.status, "blocked", JSON.stringify(mobileWorkbenchSmoke.blockers, null, 2)); assert.equal(mobileWorkbenchSmoke.evidenceLevel, "SOURCE"); assert.equal(mobileWorkbenchSmoke.devLive, false); +assert.equal(localAgentFixtureSmoke.status, "pass", JSON.stringify(localAgentFixtureSmoke.blockers, null, 2)); +assert.equal(localAgentFixtureSmoke.evidenceLevel, "SOURCE"); +assert.equal(localAgentFixtureSmoke.devLive, false); if (mobileWorkbenchSmoke.status === "pass") { assert.equal(mobileWorkbenchSmoke.viewport.width, 390); assert.equal(mobileWorkbenchSmoke.viewport.height, 844); @@ -311,11 +318,27 @@ assert.match(app, /recordField\("provider", message\.provider\)/); assert.match(app, /Code Agent 完成证据不足/); assert.match(app, /本次不会标记为真实完成/); assert.match(app, /untrusted_completion/); +assert.match(app, /function classifyCodeAgentCompletion/); +assert.match(app, /function isSourceFixtureChatResult/); +assert.match(app, /function isSourceFixtureCompletion/); +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 messageEvidenceSummary/); +assert.match(app, /function boundedEvidenceField/); +assert.match(app, /sourceKind:\s*completion\.sourceKind/); assert.match(app, /status === "completed" \? "dev-live"/); 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"/); assert.doesNotMatch(`${html}\n${app}`, /sk-[A-Za-z0-9._-]{8,}/u); +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-evidence\s*{/); +assert.match(styles, /\.message-evidence-chip\s*{/); assert.match(app, /state\.conversationId/); assert.match(app, /conversationId/); assert.match(app, /messageId/); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 3e581917..349b0007 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -507,16 +507,44 @@ h3 { grid-template-columns: 82px minmax(0, 1fr); gap: 6px 12px; padding: 10px; + width: min(100%, 780px); + align-self: start; } .message-card.status-running { border-color: rgba(223, 170, 85, 0.52); } +.message-card.status-completed { + border-color: rgba(127, 190, 116, 0.62); + background: rgba(31, 47, 32, 0.78); +} + +.message-card.status-source { + border-color: rgba(111, 183, 169, 0.46); + background: rgba(24, 39, 37, 0.72); +} + .message-card.status-failed { border-color: rgba(231, 110, 94, 0.58); } +.message-user { + justify-self: end; + border-color: rgba(212, 173, 67, 0.46); + background: rgba(52, 47, 29, 0.68); +} + +.message-agent, +.message-system { + justify-self: start; +} + +.message-system { + width: 100%; + background: rgba(24, 27, 24, 0.76); +} + .message-role { grid-row: span 2; color: var(--accent); @@ -538,13 +566,40 @@ h3 { } .message-meta { - grid-column: 2; color: var(--dim); font-family: var(--mono); font-size: 10px; overflow-wrap: anywhere; } +.message-evidence { + grid-column: 2; + min-width: 0; +} + +.message-evidence summary { + cursor: pointer; + list-style-position: inside; +} + +.message-evidence-chips { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; +} + +.message-evidence-chip { + max-width: 100%; + padding: 2px 5px; + background: var(--surface); + border: 1px solid var(--line); + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + overflow-wrap: anywhere; +} + .message-blocker { border-color: rgba(231, 110, 94, 0.48); }