diff --git a/scripts/dev-cloud-workbench-smoke.mjs b/scripts/dev-cloud-workbench-smoke.mjs index 7aee094d..86359caa 100644 --- a/scripts/dev-cloud-workbench-smoke.mjs +++ b/scripts/dev-cloud-workbench-smoke.mjs @@ -7,6 +7,7 @@ import { parseSmokeArgs, printSmokeHelp, runDevCloudWorkbenchMobileSmoke, + runDevCloudWorkbenchQuickPromptsFixtureSmoke, runM3StatusFixtureSmoke, runDevCloudWorkbenchSmoke } from "./src/dev-cloud-workbench-smoke-lib.mjs"; @@ -53,9 +54,11 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { ? printSmokeHelp() : args.mobile ? await runDevCloudWorkbenchMobileSmoke() - : args.mode === "m3-status-fixture" - ? await runM3StatusFixtureSmoke() - : await runDevCloudWorkbenchSmoke(process.argv.slice(2)); + : args.mode === "m3-status-fixture" + ? await runM3StatusFixtureSmoke() + : args.mode === "quick-prompts-fixture" + ? await runDevCloudWorkbenchQuickPromptsFixtureSmoke() + : await runDevCloudWorkbenchSmoke(process.argv.slice(2)); const reportWrite = decideSmokeReportWrite(args, report); if (reportWrite.write) { await mkdir(path.dirname(reportWrite.reportPath), { recursive: true }); diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 4d2a74ca..0fe75424 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -8,6 +8,7 @@ import { classifyLiveWebAssetIdentity, parseSmokeArgs, runDevCloudWorkbenchLayoutSmoke, + runDevCloudWorkbenchQuickPromptsFixtureSmoke, runDevCloudWorkbenchStaticSmoke, runDevCloudWorkbenchTimeoutFixtureSmoke, sanitizeAgentChatBody @@ -241,6 +242,15 @@ test("source/default smoke covers #288 gate single-table contract", () => { assert.equal(report.checks.some((item) => item.id === "feedback-119-gate-retains-diagnostics"), false); }); +test("source/default smoke covers Code Agent quick prompt fill-only contract", () => { + const report = runDevCloudWorkbenchStaticSmoke(); + const check = report.checks.find((item) => item.id === "code-agent-quick-prompts-contract"); + assert.equal(check?.status, "pass"); + assert.equal(check.evidence.includes("HWLAB API:DO1=true 后读 DI1"), true); + assert.equal(check.evidence.includes("HWLAB API:DO1=false 复核 DI1"), true); + assert.equal(check.evidence.some((item) => /我点击发送后才确认执行这次写入请求/u.test(item)), true); +}); + test("live workbench identity passes only when runtime commit or image tag matches current source", () => { assert.equal( classifyLiveDeploymentIdentity(sourceIdentity, { @@ -575,6 +585,33 @@ test("local Code Agent timeout fixture keeps failed UI state, trace evidence, an assert.equal(timeoutCheck.observations.ui.completedMessageVisible, false); }); +test("Code Agent quick prompt fixture fills input, does not autosend writes, and fits mobile", async () => { + const report = await runDevCloudWorkbenchQuickPromptsFixtureSmoke(); + if (report.status === "skip") { + assert.match(report.summary, /Playwright is unavailable/u); + return; + } + + assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); + assert.equal(report.evidenceLevel, "SOURCE"); + assert.equal(report.devLive, false); + assert.equal(report.safety.codeAgentPostSentByQuickPrompt, false); + assert.equal(report.safety.hardwareWriteApis, false); + + const fill = report.checks.find((check) => check.id === "quick-prompts-fill-input"); + const noAutosend = report.checks.find((check) => check.id === "quick-prompts-write-no-autosend"); + const copy = report.checks.find((check) => check.id === "quick-prompts-copy-boundary"); + const layout = report.checks.find((check) => check.id === "quick-prompts-mobile-layout"); + assert.equal(fill?.status, "pass"); + assert.equal(noAutosend?.status, "pass"); + assert.equal(copy?.status, "pass"); + assert.equal(layout?.status, "pass"); + assert.equal(noAutosend.observations.every((item) => item.agentPostCountDelta === 0), true); + assert.equal(noAutosend.observations.every((item) => item.explicitSendRequired === true), true); + assert.equal(copy.observations.every((item) => item.copyBoundaryOk === true), true); + assert.equal(layout.observations.some((item) => item.viewport.width === 390 && item.layoutOk), true); +}); + test("layout smoke verifies desktop and mobile sidebar collapse geometry", async () => { const report = await runDevCloudWorkbenchLayoutSmoke(); if (report.status === "skip") { diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index abfbd258..a331e6ee 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -35,6 +35,33 @@ const codeAgentE2ePrompts = Object.freeze([ } ]); +const codeAgentQuickPromptFixtures = Object.freeze([ + { + id: "pwd", + label: "Skill CLI:pwd", + prompt: "通过 Skill CLI 执行 pwd,列出当前工作目录。", + writable: false + }, + { + id: "skills", + label: "Skill CLI:列出 skill", + prompt: "通过 Skill CLI 列出你能使用的所有 skill。", + writable: false + }, + { + id: "do1-true-di1", + label: "HWLAB API:DO1=true 后读 DI1", + prompt: "通过 HWLAB API / Skill CLI 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", + writable: true + }, + { + id: "do1-false-di1", + label: "HWLAB API:DO1=false 复核 DI1", + prompt: "通过 HWLAB API / Skill CLI 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", + writable: true + } +]); + const readOnlyRpcMethods = Object.freeze([ "system.health", "cloud.adapter.describe", @@ -284,6 +311,7 @@ export async function runDevCloudWorkbenchSmoke(argv = []) { if (args.mode === "m3-status-fixture") return runM3StatusFixtureSmoke(); if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args); if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke(); + if (args.mode === "quick-prompts-fixture") return runDevCloudWorkbenchQuickPromptsFixtureSmoke(); if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke(); return runStaticSmoke(); } @@ -346,6 +374,8 @@ export function parseSmokeArgs(argv) { args.mobile = true; } else if (arg === "--local-agent-fixture") { args.mode = "local-agent-fixture"; + } else if (arg === "--quick-prompts-fixture") { + args.mode = "quick-prompts-fixture"; } else if (arg === "--local-agent-timeout-fixture") { args.mode = "local-agent-timeout-fixture"; } else if (arg === "--url") { @@ -489,6 +519,11 @@ function runStaticSmoke() { evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); + addCheck(checks, blockers, "code-agent-quick-prompts-contract", hasCodeAgentQuickPromptContract(files), "Code Agent quick prompts fill the input with Skill CLI / HWLAB API examples while hardware-write prompts require explicit send confirmation.", { + blocker: "safety_blocker", + evidence: codeAgentQuickPromptFixtures.flatMap((prompt) => [prompt.id, prompt.label, prompt.prompt]) + }); + addCheck(checks, blockers, "code-agent-long-timeout-contract", hasCodeAgentLongTimeoutContract(files.app), "Code Agent chat uses a dedicated long timeout and does not reuse the old 4500ms API timeout.", { blocker: "runtime_blocker", evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"] @@ -1498,6 +1533,7 @@ function baseReport({ "node scripts/dev-cloud-workbench-smoke.mjs --layout", "node scripts/dev-cloud-workbench-smoke.mjs --mobile", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", + "node scripts/dev-cloud-workbench-smoke.mjs --quick-prompts-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --layout --url http://74.48.78.17:16666/", "node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-dom-only.json", @@ -1514,9 +1550,10 @@ function baseReport({ "node scripts/dev-cloud-workbench-smoke.mjs --layout", "node scripts/dev-cloud-workbench-smoke.mjs --mobile", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", + "node scripts/dev-cloud-workbench-smoke.mjs --quick-prompts-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture" ], - evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."], + evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Quick prompt fixture verifies fill-only behavior, explicit-send write semantics, copy boundary, and mobile containment.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."], summary: "Source, layout, mobile, and local fixture workbench smokes are SOURCE-level evidence and do not prove the deployed browser journey." }, dryRun: { @@ -2283,6 +2320,42 @@ function hasCodeAgentChatContract({ html, app }) { ); } +function hasCodeAgentQuickPromptContract({ html, app, styles }) { + const labels = codeAgentQuickPromptFixtures.every((prompt) => + html.includes(`data-agent-quick-prompt="${prompt.id}"`) && + html.includes(prompt.label) && + html.includes(prompt.prompt) + ); + const writeButtonsRequireExplicitSend = codeAgentQuickPromptFixtures + .filter((prompt) => prompt.writable) + .every((prompt) => { + const tag = html.match(new RegExp(`]*data-agent-quick-prompt=["']${escapeRegExp(prompt.id)}["'])[\\s\\S]*?<\\/button>`, "u"))?.[0] ?? ""; + return ( + /data-prompt-action=["']fill["']/u.test(tag) && + /data-requires-explicit-send=["']true["']/u.test(tag) && + /只填充输入框;必须再点击发送才确认写入请求/u.test(tag) && + /我点击发送后才确认执行这次写入请求/u.test(tag) + ); + }); + const promptCopyUsesApiBoundary = codeAgentQuickPromptFixtures.every((prompt) => + /通过 (?:Skill CLI|HWLAB API \/ Skill CLI)/u.test(prompt.prompt) && + !/(?:\bgateway\b|\bbox\b|\bpatch-panel\b|PROD|真实硬件直控)/iu.test(prompt.prompt) + ); + return ( + labels && + writeButtonsRequireExplicitSend && + promptCopyUsesApiBoundary && + /data-agent-quick-prompt/u.test(app) && + /function\s+fillAgentQuickPrompt\s*\(/u.test(app) && + /el\.commandInput\.value = prompt/u.test(app) && + !/data-agent-quick-prompt[\s\S]{0,360}(requestSubmit|submit|click\(\))/u.test(app) && + /agent-quick-prompts/u.test(styles) && + /flex-wrap:\s*wrap/u.test(styles) && + /max-width:\s*100%/u.test(styles) && + /@media\s*\(max-width:\s*520px\)[\s\S]*?\.agent-quick-prompts button\s*\{[\s\S]*?flex-basis:\s*100%/u.test(styles) + ); +} + function hasCodeAgentReadinessVisibility({ html, app, liveStatus = "" }) { const source = `${html}\n${app}`; const userFacingBodies = [ @@ -3602,6 +3675,162 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { }); } +export async function runDevCloudWorkbenchQuickPromptsFixtureSmoke() { + let chromium; + try { + ({ chromium } = await importPlaywright()); + } catch (error) { + const summary = `Code Agent quick prompt fixture smoke skipped because Playwright is unavailable: ${error.message}`; + return { + status: "skip", + task: "DC-DCSN-P0-2026-003", + mode: "quick-prompts-fixture-browser", + evidenceLevel: "SOURCE", + devLive: false, + summary, + checks: [ + { + id: "quick-prompts-browser-dependency", + status: "skip", + summary: playwrightDependencySummary, + evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"] + } + ], + blockers: [ + { + type: "environment_blocker", + scope: "quick-prompts-browser-dependency", + status: "open", + summary + } + ], + safety: staticSafety(), + dependency: { + package: "playwright", + declaredIn: "package.json", + installCommand: playwrightDependencyCommand + } + }; + } + + const server = await startStaticWebServer({ quickPromptsFixture: true }); + let browser; + try { + browser = await chromium.launch({ headless: true }); + const desktop = await inspectQuickPromptsViewport(browser, server.url, { + width: 1366, + height: 768 + }); + const mobile = await inspectQuickPromptsViewport(browser, server.url, { + width: 390, + height: 844 + }); + const promptResults = [...desktop.promptResults, ...mobile.promptResults]; + const geometryResults = [desktop.geometry, mobile.geometry]; + const checks = [ + { + id: "quick-prompts-fill-input", + status: promptResults.every((result) => result.inputMatches && result.focused) ? "pass" : "blocked", + summary: "Each Code Agent quick prompt fills and focuses the input without submitting the form.", + observations: promptResults.map(({ viewport, id, label, inputMatches, focused, inputValue }) => ({ + viewport, + id, + label, + inputMatches, + focused, + inputValue + })) + }, + { + id: "quick-prompts-write-no-autosend", + status: promptResults + .filter((result) => result.writable) + .every((result) => result.agentPostCountDelta === 0 && result.explicitSendRequired) ? "pass" : "blocked", + summary: "HWLAB API write quick prompts are fill-only and do not POST /v1/agent/chat until the user explicitly sends.", + observations: promptResults + .filter((result) => result.writable) + .map(({ viewport, id, label, agentPostCountDelta, explicitSendRequired }) => ({ + viewport, + id, + label, + agentPostCountDelta, + explicitSendRequired + })) + }, + { + id: "quick-prompts-copy-boundary", + status: promptResults.every((result) => result.copyBoundaryOk) ? "pass" : "blocked", + summary: "Quick prompt labels and filled text say HWLAB API / Skill CLI and avoid direct gateway/box/patch-panel or PROD claims.", + observations: promptResults.map(({ viewport, id, label, inputValue, copyBoundaryOk, forbiddenHits }) => ({ + viewport, + id, + label, + inputValue, + copyBoundaryOk, + forbiddenHits + })) + }, + { + id: "quick-prompts-mobile-layout", + status: geometryResults.every((geometry) => geometry.layoutOk) ? "pass" : "blocked", + summary: "Desktop and 390x844 quick prompt strip stays contained, visible, and clear of the input and send controls.", + observations: geometryResults + } + ]; + const blockers = checks + .filter((check) => check.status !== "pass") + .map((check) => ({ + type: check.id === "quick-prompts-write-no-autosend" ? "safety_blocker" : "runtime_blocker", + scope: check.id, + status: "open", + summary: check.summary + })); + return { + status: blockers.length === 0 ? "pass" : "blocked", + task: "DC-DCSN-P0-2026-003", + mode: "quick-prompts-fixture-browser", + url: server.url, + generatedAt: new Date().toISOString(), + evidenceLevel: "SOURCE", + devLive: false, + summary: "Local SOURCE fixture validates Code Agent quick prompt fill behavior, copy boundary, and mobile layout without sending hardware writes.", + checks, + blockers, + safety: { + ...staticSafety(), + localFixtureOnly: true, + codeAgentPostSentByQuickPrompt: promptResults.some((result) => result.agentPostCountDelta > 0), + hardwareWriteApis: false, + statement: "Fixture clicks quick prompt buttons only; write examples require a separate user send click before /v1/agent/chat can receive the prompt." + } + }; + } catch (error) { + return { + status: "blocked", + task: "DC-DCSN-P0-2026-003", + mode: "quick-prompts-fixture-browser", + url: server.url, + generatedAt: new Date().toISOString(), + evidenceLevel: "SOURCE", + devLive: false, + summary: `Code Agent quick prompt fixture smoke failed: ${error.message}`, + checks: [], + blockers: [ + { + type: "runtime_blocker", + scope: "quick-prompts-fixture-browser", + status: "open", + summary: error.message + } + ], + safety: staticSafety() + }; + } finally { + if (browser) await browser.close(); + await server.close(); + } +} + async function runLocalAgentFixtureSmoke({ mode, responseDelayMs = 0, @@ -4160,6 +4389,184 @@ export async function runM3StatusFixtureSmoke() { } } +async function inspectQuickPromptsViewport(browser, url, viewport) { + const context = await browser.newContext({ + viewport, + deviceScaleFactor: 1, + isMobile: viewport.width <= 520 + }); + const page = await context.newPage(); + const agentPosts = []; + page.on("request", (request) => { + const requestUrl = new URL(request.url()); + if (request.method() === "POST" && requestUrl.pathname === "/v1/agent/chat") { + agentPosts.push({ + method: request.method(), + urlPath: requestUrl.pathname, + postData: request.postData() ?? "" + }); + } + }); + try { + await page.goto(url, { waitUntil: "networkidle", timeout: 15000 }); + await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 }); + const promptResults = []; + for (const prompt of codeAgentQuickPromptFixtures) { + const before = agentPosts.length; + await page.locator(`[data-agent-quick-prompt="${prompt.id}"]`).click(); + await page.waitForFunction( + ({ expected }) => document.querySelector("#command-input")?.value === expected, + { expected: prompt.prompt }, + { timeout: 4000 } + ); + const state = await page.evaluate(({ id, expected }) => { + const button = document.querySelector(`[data-agent-quick-prompt="${id}"]`); + const input = document.querySelector("#command-input"); + const text = `${button?.textContent ?? ""}\n${button?.getAttribute("title") ?? ""}\n${button?.dataset.promptText ?? ""}`; + const forbiddenPattern = /\b(?:PROD|gateway|box|patch-panel|真实硬件直控)\b/iu; + const forbiddenHits = [...new Set(text.match(forbiddenPattern) ?? [])]; + return { + label: button?.textContent?.replace(/\s+/gu, " ").trim() ?? "", + title: button?.getAttribute("title") ?? "", + inputValue: input?.value ?? "", + inputMatches: input?.value === expected, + focused: document.activeElement === input, + explicitSendRequired: button?.dataset.requiresExplicitSend === "true", + copyBoundaryOk: + /(?:HWLAB API|Skill CLI)/u.test(text) && + !forbiddenPattern.test(text) && + (button?.dataset.requiresExplicitSend !== "true" || /只填充输入框|点击发送后才确认/u.test(text)), + forbiddenHits + }; + }, { id: prompt.id, expected: prompt.prompt }); + promptResults.push({ + viewport, + id: prompt.id, + writable: prompt.writable, + agentPostCountDelta: agentPosts.length - before, + ...state + }); + } + const geometry = await inspectQuickPromptGeometry(page, viewport); + return { + viewport, + promptResults, + geometry, + agentPosts + }; + } finally { + await context.close(); + } +} + +async function inspectQuickPromptGeometry(page, viewport) { + return page.evaluate((viewport) => { + const boxFor = (selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + selector, + display: style.display, + visibility: style.visibility, + overflowX: style.overflowX, + overflowY: style.overflowY, + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + width: box.width, + height: box.height, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight + }; + }; + const boxesOverlap = (a, b, padding = 0) => Boolean( + a && b && + a.left < b.right - padding && + a.right > b.left + padding && + a.top < b.bottom - padding && + a.bottom > b.top + padding + ); + const strip = boxFor(".agent-quick-prompts"); + const command = boxFor("#command-form"); + const inputShell = boxFor(".input-shell"); + const input = boxFor("#command-input"); + const send = boxFor("#command-send"); + const clear = boxFor("#command-clear"); + const buttons = [...document.querySelectorAll("[data-agent-quick-prompt]")].map((button) => { + const box = button.getBoundingClientRect(); + return { + id: button.dataset.agentQuickPrompt, + text: button.textContent?.replace(/\s+/gu, " ").trim() ?? "", + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + width: box.width, + height: box.height, + scrollWidth: button.scrollWidth, + clientWidth: button.clientWidth, + visible: box.width > 0 && box.height > 0 && getComputedStyle(button).visibility !== "hidden", + contained: button.scrollWidth <= button.clientWidth + 2, + insideCommand: Boolean(command && box.left >= command.left - 1 && box.right <= command.right + 1), + overlapsInput: boxesOverlap( + { left: box.left, top: box.top, right: box.right, bottom: box.bottom }, + inputShell, + -1 + ), + overlapsSend: boxesOverlap( + { left: box.left, top: box.top, right: box.right, bottom: box.bottom }, + send, + -1 + ) + }; + }); + const rootNoHorizontalOverflow = + document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && + document.body.scrollWidth <= document.body.clientWidth + 2; + const commandContained = Boolean(command) && command.scrollWidth <= command.clientWidth + 2; + const stripContained = Boolean(strip) && strip.scrollWidth <= strip.clientWidth + 2; + const stripClearOfInput = + Boolean(strip && inputShell && strip.bottom <= inputShell.top + 1) && + !boxesOverlap(strip, inputShell, -1) && + !boxesOverlap(strip, send, -1) && + !boxesOverlap(strip, clear, -1); + const inputVisible = + Boolean(inputShell && input && send && clear) && + inputShell.width >= Math.min(160, viewport.width - 160) && + input.height >= 30 && + send.width >= 40 && + clear.width >= 40; + return { + viewport, + strip, + command, + inputShell, + input, + send, + clear, + buttons, + rootNoHorizontalOverflow, + commandContained, + stripContained, + stripClearOfInput, + inputVisible, + layoutOk: + rootNoHorizontalOverflow && + commandContained && + stripContained && + stripClearOfInput && + inputVisible && + buttons.length === 4 && + buttons.every((button) => button.visible && button.contained && button.insideCommand && !button.overlapsInput && !button.overlapsSend) + }; + }, viewport); +} + async function startStaticWebServer(options = {}) { const rootDir = path.resolve(options.rootDir ?? webRoot); const server = http.createServer(async (request, response) => { @@ -4167,6 +4574,9 @@ async function startStaticWebServer(options = {}) { if (options.m3StatusFixture && await handleM3StatusFixtureApi({ request, response, url, options })) { return; } + if (options.quickPromptsFixture && await handleQuickPromptsFixtureApi({ request, response, url })) { + return; + } if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) { return; } @@ -4200,6 +4610,78 @@ async function startStaticWebServer(options = {}) { }; } +async function handleQuickPromptsFixtureApi({ request, response, url }) { + if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { + jsonResponse(response, 200, { + serviceId: "hwlab-cloud-api", + status: "degraded", + ready: false, + sourceKind: "SOURCE", + runtime: { + durable: false, + ready: false + }, + agent: { + status: "blocked", + provider: "codex-stdio", + blocker: "fixture-readonly" + }, + m3: { + status: "blocked", + route: "/v1/m3/io" + } + }); + return true; + } + if (request.method === "GET" && url.pathname === "/v1/m3/io") { + jsonResponse(response, 200, { + status: "blocked", + route: "/v1/m3/io", + readiness: { + status: "blocked", + controlReady: false, + sourceKind: "SOURCE" + } + }); + return true; + } + if (request.method === "GET" && url.pathname === "/v1/m3/status") { + jsonResponse(response, 200, { + status: "blocked", + sourceKind: "SOURCE", + resources: [] + }); + 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_quick_prompt_fixture", + result: localRpcFixtureResult(body?.method) + }); + return true; + } + if (request.method === "POST" && url.pathname === "/v1/agent/chat") { + const body = await readJsonBody(request); + jsonResponse(response, 200, { + status: "completed", + sourceKind: "SOURCE", + provider: "source-fixture", + model: "quick-prompt-fixture", + backend: "local-static-fixture", + traceId: body?.traceId ?? "trc_quick_prompt_fixture", + conversationId: body?.conversationId ?? "cnv_quick_prompt_fixture", + messageId: "msg_quick_prompt_fixture", + reply: { + content: "quick prompt fixture response" + } + }); + return true; + } + return false; +} + async function handleM3StatusFixtureApi({ request, response, url, options = {} }) { if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { jsonResponse(response, 200, m3StatusFixtureHealth(options.m3State)); @@ -6786,12 +7268,13 @@ function escapeRegExp(value) { export function printSmokeHelp() { return { status: "usage", - command: "node scripts/dev-cloud-workbench-smoke.mjs --source | --layout [--url http://74.48.78.17:16666/] | --mobile | --local-agent-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-dom-only.json] | --live --confirm-dev-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 --source | --layout [--url http://74.48.78.17:16666/] | --mobile | --local-agent-fixture | --quick-prompts-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-dom-only.json] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]", notes: [ "Default/source mode reads repository files and emits SOURCE-level evidence only; it never calls the live provider and must not claim DEV-LIVE.", "--layout runs desktop and mobile browser geometry/hit-target checks for sidebar collapse/expand; with --url it remains layout-only live evidence and does not claim M3 acceptance.", "--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.", "--local-agent-fixture runs two local browser send/reply paths with a sanitized SOURCE fixture delayed beyond 4500ms and does not claim DEV-LIVE.", + "--quick-prompts-fixture clicks Code Agent quick prompts locally and verifies write prompts fill only, without automatic send or hardware write.", "--local-agent-timeout-fixture runs a local slow SOURCE fixture to verify bounded timeout UI classification and retry preservation without live acceptance.", "--dom-only preserves runtime/web-asset identity preflight, inspects read-only deployed DOM/help wiring, and never posts /v1/agent/chat.", "DEV-LIVE mode requires --live --confirm-dev-live, opens the deployed workbench in a browser, sends the simple Chinese prompt and 列出你能使用的所有skill, and retains only non-sensitive response fields." diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index f83326cb..2276ab6f 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -549,6 +549,20 @@ function initQuickActions() { collapseExplorerAfterMobileAction(); }); } + for (const button of document.querySelectorAll("[data-agent-quick-prompt]")) { + button.addEventListener("click", () => { + fillAgentQuickPrompt(button); + collapseExplorerAfterMobileAction(); + }); + } +} + +function fillAgentQuickPrompt(button) { + const prompt = button.dataset.promptText ?? ""; + if (!prompt) return; + el.commandInput.value = prompt; + el.commandInput.focus(); + el.commandInput.setSelectionRange(prompt.length, prompt.length); } function collapseExplorerAfterMobileAction() { diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index d1658683..7d43e129 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -162,6 +162,38 @@
+
+ + + + +
hwlab check.id === 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 codeAgentTimeoutSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "code-agent-long-timeout-contract"); +const codeAgentQuickPromptsSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "code-agent-quick-prompts-contract"); const mobileWorkbenchSmoke = await runDevCloudWorkbenchMobileSmoke(); const mobileOuterScrollSmokeCheck = mobileWorkbenchSmoke.checks.find((check) => check.id === "mobile-outer-scroll-lock"); const mobileHelpSmokeCheck = mobileWorkbenchSmoke.checks.find((check) => check.id === "mobile-help-markdown-route"); const localAgentFixtureSmoke = await runDevCloudWorkbenchLocalAgentFixtureSmoke(); const localAgentNo4500Check = localAgentFixtureSmoke.checks.find((check) => check.id === "local-agent-fixture-no-4500ms-permanent-failure"); +const quickPromptsFixtureSmoke = await runDevCloudWorkbenchQuickPromptsFixtureSmoke(); +const quickPromptsFillCheck = quickPromptsFixtureSmoke.checks.find((check) => check.id === "quick-prompts-fill-input"); +const quickPromptsNoAutosendCheck = quickPromptsFixtureSmoke.checks.find((check) => check.id === "quick-prompts-write-no-autosend"); +const quickPromptsCopyCheck = quickPromptsFixtureSmoke.checks.find((check) => check.id === "quick-prompts-copy-boundary"); +const quickPromptsLayoutCheck = quickPromptsFixtureSmoke.checks.find((check) => check.id === "quick-prompts-mobile-layout"); const layoutSmokeReportPath = path.resolve(repoRoot, "tmp/dev-cloud-workbench-layout-web-check.json"); const layoutSmoke = await runDevCloudWorkbenchLayoutSmoke({ reportPath: layoutSmokeReportPath }); fs.mkdirSync(path.dirname(layoutSmokeReportPath), { recursive: true }); @@ -108,6 +115,7 @@ assert.equal(outerScrollSmokeCheck?.status, "pass", "desktop workbench shell mus 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(codeAgentTimeoutSmokeCheck?.status, "pass", "Code Agent chat must not reuse the 4500ms light API timeout"); +assert.equal(codeAgentQuickPromptsSmokeCheck?.status, "pass", "Code Agent quick prompts must remain fill-only with explicit send semantics for writes"); 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"); @@ -126,6 +134,13 @@ assert.equal(localAgentFixtureSmoke.status, "pass", JSON.stringify(localAgentFix assert.equal(localAgentFixtureSmoke.evidenceLevel, "SOURCE"); assert.equal(localAgentFixtureSmoke.devLive, false); assert.equal(localAgentNo4500Check?.status, "pass", "local Code Agent fixture must prove the 4500ms browser timeout path is not permanent"); +assert.equal(quickPromptsFixtureSmoke.status, "pass", JSON.stringify(quickPromptsFixtureSmoke.blockers, null, 2)); +assert.equal(quickPromptsFixtureSmoke.evidenceLevel, "SOURCE"); +assert.equal(quickPromptsFixtureSmoke.devLive, false); +assert.equal(quickPromptsFillCheck?.status, "pass", "quick prompt buttons must fill and focus the Code Agent input"); +assert.equal(quickPromptsNoAutosendCheck?.status, "pass", "HWLAB API write quick prompts must not auto-send"); +assert.equal(quickPromptsCopyCheck?.status, "pass", "quick prompt labels must say HWLAB API / Skill CLI without direct hardware-control copy"); +assert.equal(quickPromptsLayoutCheck?.status, "pass", "quick prompt strip must stay contained on desktop and 390x844 mobile"); assert.equal( layoutSmoke.status, "pass", diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index e71df053..d1a7c7e9 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -768,6 +768,46 @@ h3 { border-top: 1px solid var(--line); } +.agent-quick-prompts { + grid-column: 1 / -1; + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; + overflow: hidden; +} + +.agent-quick-prompts button { + flex: 0 1 auto; + max-width: 100%; + min-height: 28px; + padding: 4px 8px; + border: 1px solid var(--line); + background: var(--surface-2); + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + font-weight: 760; + line-height: 1.2; + text-align: left; + overflow-wrap: anywhere; + cursor: pointer; +} + +.agent-quick-prompts button[data-requires-explicit-send="true"] { + border-color: rgba(223, 170, 85, 0.56); + color: var(--text); + background: rgba(52, 47, 29, 0.74); +} + +.agent-quick-prompts button:hover, +.agent-quick-prompts button:focus-visible { + border-color: var(--line-strong); + background: var(--surface-3); + color: var(--text); + outline: 0; +} + .input-shell { min-width: 0; display: grid; @@ -1549,6 +1589,15 @@ tbody tr:last-child td { padding: 8px; } + .agent-quick-prompts { + gap: 5px; + } + + .agent-quick-prompts button { + flex: 1 1 148px; + min-height: 30px; + } + .input-shell { padding: 0 8px; } @@ -1613,6 +1662,10 @@ tbody tr:last-child td { grid-template-columns: minmax(0, 1fr) auto auto; } + .agent-quick-prompts button { + flex-basis: 100%; + } + .prompt-mark { display: none; }