From 542d6de6dc8e4bcf6d43ba52ba6953dbaf252db2 Mon Sep 17 00:00:00 2001 From: unidesk-code-queue-runner Date: Sun, 24 May 2026 01:04:52 +0000 Subject: [PATCH] fix: improve Code Agent long request UX --- scripts/dev-cloud-workbench-smoke.test.mjs | 27 ++ scripts/src/dev-cloud-workbench-smoke-lib.mjs | 376 +++++++++++++++++- web/hwlab-cloud-web/app.mjs | 78 +++- .../code-agent-status.test.mjs | 21 + web/hwlab-cloud-web/scripts/check.mjs | 18 +- web/hwlab-cloud-web/styles.css | 53 +++ 6 files changed, 545 insertions(+), 28 deletions(-) diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index cbbe1a1c..a5b5e440 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -9,6 +9,7 @@ import { parseSmokeArgs, runDevCloudWorkbenchLayoutSmoke, runDevCloudWorkbenchQuickPromptsFixtureSmoke, + runDevCloudWorkbenchSlowBlockerFixtureSmoke, runDevCloudWorkbenchStaticSmoke, runDevCloudWorkbenchTimeoutFixtureSmoke, sanitizeAgentChatBody @@ -671,6 +672,32 @@ test("local Code Agent timeout fixture keeps failed UI state, trace evidence, an assert.equal(timeoutCheck.observations.ui.completedMessageVisible, false); }); +test("local slow structured blocker fixture preserves trace, pending state, and retry input", async () => { + const report = await runDevCloudWorkbenchSlowBlockerFixtureSmoke({ + responseDelayMs: 5200 + }); + + 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); + const blockerCheck = report.checks.find((check) => check.id === "local-agent-fixture-slow-structured-blocker"); + assert.equal(blockerCheck?.status, "pass"); + assert.equal(blockerCheck.observations.ui.agentChatStatus, "Runner 受阻"); + assert.equal(blockerCheck.observations.ui.failedMessageVisible, true); + assert.equal(blockerCheck.observations.ui.failedMessageHasTrace, true); + assert.equal(blockerCheck.observations.ui.retryInputPreserved, true); + assert.equal(blockerCheck.observations.ui.retryInputValue, "列出你能使用的所有skill"); + assert.equal(blockerCheck.observations.prompt.legacyWindow.permanentFailureAround4500ms, false); + assert.equal(blockerCheck.observations.prompt.legacyWindow.pendingChineseVisible, true); + assert.equal(blockerCheck.observations.prompt.response.error.code, "skills_unavailable"); + assert.equal(blockerCheck.observations.prompt.classification.blocker, "runner-blocked"); +}); + test("Code Agent quick prompt fixture fills input, does not autosend writes, and fits mobile", async () => { const report = await runDevCloudWorkbenchQuickPromptsFixtureSmoke(); 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 b40f8102..3d3297dd 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -2505,7 +2505,7 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { /recordField\("workspace",\s*message\.workspace\)/u.test(messageEvidenceBody) && /recordField\("sandbox",\s*message\.sandbox\)/u.test(messageEvidenceBody) && /recordField\("capability",\s*message\.capabilityLevel\)/u.test(messageEvidenceBody) && - /recordField\("session",\s*sessionSummary\(message\.session\)\)/u.test(messageEvidenceBody) && + /recordField\("session",\s*sessionSummary\(message\.session\)\s*\?\?\s*message\.sessionId\)/u.test(messageEvidenceBody) && /recordField\("sessionMode",\s*message\.sessionMode\)/u.test(messageEvidenceBody) && /recordField\("sessionReuse",\s*sessionReuseSummary\(message\.sessionReuse\)\)/u.test(messageEvidenceBody) && /recordField\("longLivedGate",\s*longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/u.test(messageEvidenceBody) && @@ -2613,6 +2613,7 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /function\s+isSourceFixtureCompletedChatMessage\s*\(/u.test(app) && /function\s+messageEvidencePanel\s*\(/u.test(app) && /function\s+messageAttributionPanel\s*\(/u.test(app) && + /function\s+messagePendingContextPanel\s*\(/u.test(app) && /function\s+messageEvidenceSummary\s*\(/u.test(app) && /function\s+boundedEvidenceField\s*\(/u.test(app) && /codeAgentAttributionFromMessage/u.test(app) && @@ -2622,7 +2623,10 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*180000/u.test(app) && /timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/u.test(app) && /HWLAB_CLOUD_WEB_CONFIG/u.test(app) && - /失败时会保留输入供重试/u.test(app) && + /正在处理这次 Code Agent 请求/u.test(app) && + /旧 4500ms/u.test(app) && + /结构化 blocker/u.test(app) && + /sourceKind:\s*"PENDING"/u.test(app) && /Code Agent 请求超过/u.test(app) && /Code Agent SOURCE 回复/u.test(app) && /SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/u.test(app) && @@ -2631,6 +2635,7 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /status-source/u.test(styles) && /message-user/u.test(styles) && /message-agent/u.test(styles) && + /message-pending-context\s*\{/u.test(styles) && /message-m3-evidence\s*\{/u.test(styles) && /message-attribution\s*\{/u.test(styles) && /message-attribution-chip/u.test(styles) && @@ -2639,7 +2644,7 @@ function hasCodeAgentConversationUxStates({ app, 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) && + /status === "completed" \? "dev-live" : status === "failed" \|\| status === "blocked" \? "blocked" : status === "running" \? "pending" : "source"/u.test(app) && !/sourceKind:\s*"SOURCE"[\s\S]{0,160}status:\s*"completed"/u.test(app) ); } @@ -4178,7 +4183,31 @@ async function inspectJourneyUi(page) { const failedText = [...document.querySelectorAll(".message-card.status-failed")] .map((element) => element.textContent ?? "") .join("\n"); + const pendingText = [...document.querySelectorAll(".message-card.status-running")] + .map((element) => element.textContent ?? "") + .join("\n"); const commandValue = document.querySelector("#command-input")?.value ?? ""; + const pendingRows = [...document.querySelectorAll(".message-card.status-running .message-pending-row")].map((row) => { + const box = row.getBoundingClientRect(); + return { + text: row.textContent?.replace(/\s+/gu, " ").trim() ?? "", + width: box.width, + scrollWidth: row.scrollWidth, + clientWidth: row.clientWidth, + contained: row.scrollWidth <= row.clientWidth + 2 + }; + }); + const runningCardBoxes = [...document.querySelectorAll(".message-card.status-running")].map((card) => { + const box = card.getBoundingClientRect(); + return { + width: box.width, + left: box.left, + right: box.right, + scrollWidth: card.scrollWidth, + clientWidth: card.clientWidth, + contained: box.left >= -1 && box.right <= window.innerWidth + 1 && card.scrollWidth <= card.clientWidth + 2 + }; + }); return { title: document.title, workspaceHidden: document.querySelector('[data-view="workspace"]')?.hidden ?? null, @@ -4186,12 +4215,22 @@ async function inspectJourneyUi(page) { helpHidden: document.querySelector('[data-view="help"]')?.hidden ?? null, agentChatStatus: document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null, inputCleared: commandValue === "", - retryInputPreserved: commandValue === "请用一句话说明当前 HWLAB 工作台可以做什么。", + retryInputPreserved: commandValue.trim().length > 0, + retryInputValue: commandValue, completedMessageVisible: Boolean(document.querySelector(".message-card.status-completed")), sourceMessageVisible: Boolean(document.querySelector(".message-card.status-source")), failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")), failedMessageHasTrace: /trace=trc_/u.test(failedText), failedMessageHasChineseTimeout: /请求超过|请求超时|保留输入|重试/u.test(failedText), + runningMessageVisible: Boolean(document.querySelector(".message-card.status-running")), + pendingMessageText: pendingText.replace(/\s+/gu, " ").trim(), + pendingMessageChinese: /正在处理|后端处理中|不会把旧 4500ms|结构化 blocker|真实超时/u.test(pendingText), + pendingMessageHasTrace: /traceId\s*trc_|trace=trc_|trc_/u.test(pendingText), + pendingMessageHasConversation: /conversation\s*cnv_|conversation=cnv_|cnv_/u.test(pendingText), + pendingMessageHasSession: /session\s*(等待后端分配|cnv_|ses_)/u.test(pendingText), + pendingTraceCopyVisible: pendingRows.some((row) => /traceId/u.test(row.text) && /复制/u.test(row.text)), + pendingContextRowsContained: pendingRows.length > 0 && pendingRows.every((row) => row.contained), + runningCardsContained: runningCardBoxes.length > 0 && runningCardBoxes.every((box) => box.contained), messageCount: document.querySelectorAll(".message-card").length, completedMessageHasNonSensitiveMeta: Boolean( [...document.querySelectorAll(".message-card.status-completed .message-meta")] @@ -4234,6 +4273,18 @@ async function inspectLegacyFailureWindow(page, { beforeMessageCount }) { const failed = newMessages.filter((element) => element.classList.contains("status-failed")); const latestAgent = [...newMessages].reverse().find((element) => element.classList.contains("message-agent")); const agentChatStatus = document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null; + const pendingText = latestAgent?.classList.contains("status-running") + ? latestAgent.textContent?.replace(/\s+/gu, " ").trim() ?? "" + : ""; + const pendingRows = latestAgent + ? [...latestAgent.querySelectorAll(".message-pending-row")].map((row) => ({ + text: row.textContent?.replace(/\s+/gu, " ").trim() ?? "", + scrollWidth: row.scrollWidth, + clientWidth: row.clientWidth, + contained: row.scrollWidth <= row.clientWidth + 2 + })) + : []; + const latestAgentBox = latestAgent?.getBoundingClientRect(); const pendingStillRunning = latestAgent?.classList.contains("status-running") === true || agentChatStatus === "处理中" || @@ -4246,6 +4297,17 @@ async function inspectLegacyFailureWindow(page, { beforeMessageCount }) { agentChatStatus, failedMessageVisible: failed.length > 0, runningMessageVisible: Boolean(latestAgent?.classList.contains("status-running")), + pendingChineseVisible: /正在处理|后端处理中|不会把旧 4500ms|结构化 blocker|真实超时/u.test(pendingText), + pendingTraceVisible: /traceId\s*trc_|trace=trc_|trc_/u.test(pendingText), + pendingConversationVisible: /conversation\s*cnv_|conversation=cnv_|cnv_/u.test(pendingText), + pendingSessionVisible: /session\s*(等待后端分配|cnv_|ses_)/u.test(pendingText), + pendingTraceCopyVisible: pendingRows.some((row) => /traceId/u.test(row.text) && /复制/u.test(row.text)), + pendingRowsContained: pendingRows.length > 0 && pendingRows.every((row) => row.contained), + runningCardContained: latestAgentBox + ? latestAgentBox.left >= -1 && + latestAgentBox.right <= window.innerWidth + 1 && + latestAgent.scrollWidth <= latestAgent.clientWidth + 2 + : false, permanentFailureAround4500ms: failed.length > 0 && !pendingStillRunning }; }, { beforeMessageCount, legacyFailureWindowMs }); @@ -4306,6 +4368,15 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { }); } +export async function runDevCloudWorkbenchSlowBlockerFixtureSmoke(options = {}) { + return runLocalAgentFixtureSmoke({ + mode: "local-agent-slow-blocker-fixture-browser", + responseDelayMs: options.responseDelayMs ?? localAgentFixtureDelayMs, + agentFixtureMode: "slow-blocker", + expectTimeout: false + }); +} + export async function runDevCloudWorkbenchQuickPromptsFixtureSmoke() { let chromium; try { @@ -4466,6 +4537,7 @@ async function runLocalAgentFixtureSmoke({ mode, responseDelayMs = 0, timeoutConfigMs = null, + agentFixtureMode = "success", expectTimeout = false } = {}) { let chromium; @@ -4505,7 +4577,7 @@ async function runLocalAgentFixtureSmoke({ }; } - const server = await startStaticWebServer({ agentFixture: true, agentDelayMs: responseDelayMs }); + const server = await startStaticWebServer({ agentFixture: true, agentDelayMs: responseDelayMs, agentFixtureMode }); let browser; try { browser = await chromium.launch({ headless: true }); @@ -4555,22 +4627,36 @@ async function runLocalAgentFixtureSmoke({ if (expectTimeout) { await page.locator("#command-input").fill(codeAgentE2ePrompts[0].text); await page.locator("#command-send").click(); + await page.waitForTimeout(Math.min(legacyFailureWindowMs + 350, Math.max(80, timeoutConfigMs ?? 0) + 80)); await page.waitForFunction( () => ["等待超时", "发送失败"].includes(document.querySelector("#agent-chat-status")?.textContent?.trim() ?? ""), null, { timeout: 8000 } ); } else { - for (const prompt of codeAgentE2ePrompts) { + const prompts = agentFixtureMode === "slow-blocker" + ? [codeAgentE2ePrompts[1]] + : codeAgentE2ePrompts; + for (const prompt of prompts) { promptResults.push(await runCodeAgentPromptJourney(page, prompt, { sourceFixture: true })); - await page.waitForFunction( - () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复", - null, - { timeout: 8000 } - ); + if (agentFixtureMode === "success") { + await page.waitForFunction( + () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复", + null, + { timeout: 8000 } + ); + } } } const ui = await inspectJourneyUi(page); + const mobilePending = !expectTimeout && agentFixtureMode === "success" + ? await inspectLocalAgentPendingViewport(browser, server.url, { + width: 390, + height: 844, + responseDelayMs, + timeoutConfigMs + }) + : null; responseSummary = response ? sanitizeAgentChatBody(responseBody, { httpStatus: response.status() }) : null; const pass = expectTimeout ? ( @@ -4581,14 +4667,35 @@ async function runLocalAgentFixtureSmoke({ ui.failedMessageHasChineseTimeout && !ui.completedMessageVisible ) - : ( + : agentFixtureMode === "slow-blocker" + ? ( + promptResults.length === 1 && + promptResults[0]?.status === "blocked" && + promptResults[0]?.classification?.blocker === "runner-blocked" && + promptResults[0]?.legacyWindow?.permanentFailureAround4500ms === false && + promptResults[0]?.legacyWindow?.pendingChineseVisible === true && + ui.agentChatStatus === "Runner 受阻" && + ui.failedMessageVisible && + ui.failedMessageHasTrace && + ui.retryInputPreserved && + !ui.completedMessageVisible + ) + : ( promptResults.every((result) => result.status === "pass" && result.response?.status === "completed" && result.response?.sourceKind === "SOURCE" && result.response?.hasReply === true && result.classification?.sourceOnly === true && - result.legacyWindow?.permanentFailureAround4500ms === false + result.legacyWindow?.permanentFailureAround4500ms === false && + result.legacyWindow?.runningMessageVisible === true && + result.legacyWindow?.pendingChineseVisible === true && + result.legacyWindow?.pendingTraceVisible === true && + result.legacyWindow?.pendingConversationVisible === true && + result.legacyWindow?.pendingSessionVisible === true && + result.legacyWindow?.pendingTraceCopyVisible === true && + result.legacyWindow?.pendingRowsContained === true && + result.legacyWindow?.runningCardContained === true ) && ui.agentChatStatus === "SOURCE 回复" && ui.sourceMessageVisible && @@ -4598,6 +4705,7 @@ async function runLocalAgentFixtureSmoke({ ui.attributionMissingFieldsStructured && ui.attributionFallbackNotRunnerControl && ui.conversationFactsVisible && + mobilePending?.pass === true && !ui.completedMessageVisible && !ui.failedMessageVisible ); @@ -4619,6 +4727,8 @@ async function runLocalAgentFixtureSmoke({ status: pass ? "pass" : "blocked", summary: expectTimeout ? "Local SOURCE fixture proves a bounded Code Agent timeout stays failed/BLOCKED in the UI, preserves trace evidence, and keeps the user's input for retry." + : agentFixtureMode === "slow-blocker" + ? "Local SOURCE fixture returns a delayed structured blocker and the conversation shows the Chinese reason with trace evidence while preserving the input." : "Local SOURCE fixture sends input and reaches a user-visible replied state without marking the fixture as DEV-LIVE.", observations: expectTimeout ? { response: responseSummary, @@ -4627,15 +4737,26 @@ async function runLocalAgentFixtureSmoke({ } : { prompts: promptResults, ui, + mobilePending, networkEvents: agentResponses } } ]; - if (!expectTimeout) { + if (!expectTimeout && agentFixtureMode === "success") { checks.push({ id: "local-agent-fixture-no-4500ms-permanent-failure", - status: promptResults.every((result) => result.legacyWindow?.permanentFailureAround4500ms === false) ? "pass" : "blocked", - summary: "Local SOURCE fixture delays each Code Agent response beyond 4500ms and the frontend keeps the request pending instead of marking permanent failure.", + status: promptResults.every((result) => + result.legacyWindow?.permanentFailureAround4500ms === false && + result.legacyWindow?.runningMessageVisible === true && + result.legacyWindow?.pendingChineseVisible === true && + result.legacyWindow?.pendingTraceVisible === true && + result.legacyWindow?.pendingConversationVisible === true && + result.legacyWindow?.pendingSessionVisible === true && + result.legacyWindow?.pendingTraceCopyVisible === true && + result.legacyWindow?.pendingRowsContained === true && + result.legacyWindow?.runningCardContained === true + ) ? "pass" : "blocked", + summary: "Local SOURCE fixture delays each Code Agent response beyond 4500ms and the conversation shows Chinese pending state with trace/session evidence instead of permanent failure.", observations: { legacyFailureWindowMs, fixtureDelayMs: responseDelayMs, @@ -4644,7 +4765,26 @@ async function runLocalAgentFixtureSmoke({ status: result.status, legacyWindow: result.legacyWindow, traceId: result.traceId - })) + })), + mobilePending + } + }); + checks.push({ + id: "local-agent-fixture-mobile-pending-layout", + status: mobilePending?.pass === true ? "pass" : "blocked", + summary: "390x844 mobile view shows Chinese Code Agent pending status, trace/session context, and no pending-card text overflow before the delayed response arrives.", + observations: mobilePending + }); + } else if (!expectTimeout && agentFixtureMode === "slow-blocker") { + checks.push({ + id: "local-agent-fixture-slow-structured-blocker", + status: pass ? "pass" : "blocked", + summary: "Delayed structured blocker arrives after the legacy 4500ms window, displays Runner 受阻 instead of 发送失败, keeps trace visible, and leaves the user prompt for continuing the same conversation.", + observations: { + legacyFailureWindowMs, + fixtureDelayMs: responseDelayMs, + prompt: promptResults[0] ?? null, + ui } }); } @@ -4672,6 +4812,7 @@ async function runLocalAgentFixtureSmoke({ safety: { ...staticSafety(), localFixtureOnly: true, + agentFixtureMode, fixtureTrustBoundary: "SOURCE fixture replies render as SOURCE 回复 and never as DEV-LIVE 回复.", legacyFailureWindowMs, fixtureDelayMs: responseDelayMs, @@ -4706,6 +4847,73 @@ async function runLocalAgentFixtureSmoke({ } } +async function inspectLocalAgentPendingViewport(browser, url, { width, height, responseDelayMs, timeoutConfigMs }) { + const context = await browser.newContext({ + viewport: { width, height }, + deviceScaleFactor: 1, + isMobile: width <= 480 + }); + const page = await context.newPage(); + try { + if (timeoutConfigMs !== null) { + await page.addInitScript((timeoutMs) => { + globalThis.HWLAB_CLOUD_WEB_CONFIG = { + ...(globalThis.HWLAB_CLOUD_WEB_CONFIG ?? {}), + timeouts: { + ...(globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? {}), + codeAgentTimeoutMs: timeoutMs + } + }; + }, timeoutConfigMs); + } + await page.goto(url, { waitUntil: "networkidle", timeout: 15000 }); + await loginWithDefaultCredentials(page); + await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 }); + const beforeMessageCount = await page.locator(".message-card").count(); + await page.locator("#command-input").fill(codeAgentE2ePrompts[1].text); + const responsePromise = page.waitForResponse( + (response) => response.url().includes("/v1/agent/chat") && response.request().method() === "POST", + { timeout: responseDelayMs + 10000 } + ).catch(() => null); + await page.locator("#command-send").click(); + await page.waitForTimeout(legacyFailureWindowMs + 350); + const legacyWindow = await inspectLegacyFailureWindow(page, { beforeMessageCount }); + const ui = await inspectJourneyUi(page); + const response = await responsePromise; + return { + viewport: { width, height }, + pass: + legacyWindow.permanentFailureAround4500ms === false && + legacyWindow.runningMessageVisible === true && + legacyWindow.pendingChineseVisible === true && + legacyWindow.pendingTraceVisible === true && + legacyWindow.pendingConversationVisible === true && + legacyWindow.pendingSessionVisible === true && + legacyWindow.pendingTraceCopyVisible === true && + legacyWindow.pendingRowsContained === true && + legacyWindow.runningCardContained === true && + ui.runningCardsContained === true && + ui.pendingContextRowsContained === true, + legacyWindow, + ui: { + agentChatStatus: ui.agentChatStatus, + runningMessageVisible: ui.runningMessageVisible, + pendingMessageChinese: ui.pendingMessageChinese, + pendingMessageHasTrace: ui.pendingMessageHasTrace, + pendingMessageHasConversation: ui.pendingMessageHasConversation, + pendingMessageHasSession: ui.pendingMessageHasSession, + pendingTraceCopyVisible: ui.pendingTraceCopyVisible, + pendingContextRowsContained: ui.pendingContextRowsContained, + runningCardsContained: ui.runningCardsContained + }, + responseObserved: Boolean(response) + }; + } finally { + await page.close().catch(() => null); + await context.close(); + } +} + export async function runDevCloudWorkbenchMobileSmoke() { let chromium; try { @@ -5880,6 +6088,140 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} const normalizedMessage = String(body?.message ?? ""); const isSkillListPrompt = /列出你能使用的所有skill/u.test(normalizedMessage); const messageId = isSkillListPrompt ? "msg_source_fixture_browser_skills" : "msg_source_fixture_browser_simple"; + if (options.agentFixtureMode === "slow-blocker") { + jsonResponse(response, 200, { + conversationId, + sessionId: conversationId, + messageId, + status: "failed", + sourceKind: "SOURCE", + evidenceLevel: "SOURCE", + createdAt: timestamp, + updatedAt: timestamp, + traceId, + provider: "codex-readonly-runner", + model: "read-only-tools", + backend: "local-source-fixture/slow-structured-blocker", + projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId), + workspace: "/workspace/hwlab", + sandbox: "read-only", + runner: { + kind: "hwlab-readonly-runner", + writeCapable: false, + codexStdio: false, + longLivedSession: true, + durableSession: false + }, + capabilityLevel: "blocked", + sessionMode: "controlled-readonly-session-registry", + session: { + sessionId: conversationId, + conversationId, + status: "idle", + turn: 1, + lastTraceId: traceId + }, + sessionReuse: { + conversationId, + sessionId: conversationId, + mapped: true, + reused: true, + turn: 1, + status: "idle" + }, + error: { + code: "skills_unavailable", + category: "runner_blocked", + layer: "skill-cli", + message: "SOURCE slow structured blocker: no readable SKILL.md files were found.", + userMessage: "技能发现暂时受阻:后端没有返回可读取的 SKILL.md 清单;输入已保留,可继续同一会话稍后重试。", + retryable: true, + blocker: { + code: "skills_unavailable", + category: "runner_blocked", + layer: "skill-cli", + userMessage: "技能发现暂时受阻:后端没有返回可读取的 SKILL.md 清单;输入已保留,可继续同一会话稍后重试。", + retryable: true + } + }, + blocker: { + code: "skills_unavailable", + category: "runner_blocked", + layer: "skill-cli", + userMessage: "技能发现暂时受阻:后端没有返回可读取的 SKILL.md 清单;输入已保留,可继续同一会话稍后重试。", + retryable: true + }, + blockers: [ + { + code: "skills_unavailable", + category: "runner_blocked", + layer: "skill-cli", + retryable: true + } + ], + toolCalls: [ + { + name: "skills.discover", + status: "blocked", + exitCode: 2, + traceId, + stderrSummary: "skills_unavailable", + blocker: { + code: "skills_unavailable", + category: "runner_blocked", + layer: "skill-cli" + } + } + ], + skills: { + status: "blocked", + items: [], + count: 0, + totalCount: 0, + blockers: [ + { + code: "skills_unavailable", + category: "runner_blocked", + layer: "skill-cli" + } + ] + }, + runnerTrace: { + traceId, + runnerKind: "hwlab-readonly-runner", + sessionMode: "controlled-readonly-session-registry", + status: "blocked", + events: ["tool:skills.discover:blocked"] + }, + conversationFacts: { + conversationId, + sessionId: conversationId, + sessionStatus: "idle", + sessionMode: "controlled-readonly-session-registry", + turnCount: 1, + latestTraceId: traceId, + traceIds: [traceId], + latestSkills: { + status: "blocked", + count: 0, + totalCount: 0, + names: [], + blockers: ["skills_unavailable"] + }, + recentToolCalls: [{ name: "skills.discover", status: "blocked" }], + facts: [], + valuesRedacted: true, + secretMaterialStored: false + }, + providerTrace: { + source: "SOURCE-local-browser-fixture", + sourceKind: "SOURCE", + responseId: "rsp_source_fixture_slow_blocker", + redacted: true + } + }); + return true; + } const fixtureSkills = isSkillListPrompt ? { status: "ready", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index b657d103..86ed0779 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -610,6 +610,8 @@ function initCommandBar() { const value = el.commandInput.value.trim(); if (!value || state.chatPending) return; const traceId = nextProtocolId("trc"); + const activeConversationId = state.conversationId ?? nextProtocolId("cnv"); + state.conversationId = activeConversationId; const userMessage = { id: nextProtocolId("msg"), role: "user", @@ -617,15 +619,19 @@ function initCommandBar() { text: value, status: "sent", traceId, + conversationId: activeConversationId, createdAt: new Date().toISOString() }; const pendingMessage = { id: nextProtocolId("msg"), role: "agent", title: "Code Agent 处理中", - text: `正在调用真实 Code Agent / Codex 后端,复杂问题可能需要 1-2 分钟;超时上限 ${Math.round(CODE_AGENT_TIMEOUT_MS / 1000)} 秒,失败时会保留输入供重试。`, + text: `正在处理这次 Code Agent 请求;复杂问题可能需要 1-2 分钟。页面会保留 trace/session 并等待后端返回成功、结构化 blocker 或真实超时;不会把旧 4500ms 轻量探测窗口当作最终失败。`, status: "running", traceId, + conversationId: activeConversationId, + sessionId: state.sessionId, + sourceKind: "PENDING", createdAt: new Date().toISOString() }; state.chatMessages.push(userMessage, pendingMessage); @@ -638,7 +644,7 @@ function initCommandBar() { renderRecords(state.liveSurface); try { - const result = await sendAgentMessage(value, state.conversationId, traceId); + const result = await sendAgentMessage(value, activeConversationId, traceId); state.conversationId = result.conversationId || result.sessionId || state.conversationId; state.sessionId = result.sessionId || state.sessionId || state.conversationId; const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); @@ -658,6 +664,7 @@ function initCommandBar() { status, traceId: result.traceId, conversationId: result.conversationId || result.sessionId || state.conversationId, + sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId, messageId: result.messageId, provider: result.provider, model: result.model, @@ -1972,13 +1979,14 @@ function codeAgentRecordCards() { infoCard({ title: `${roleLabel(message.role)} ${statusLabel(message.status)}`, detail: [ - sourceKindLabel(message.status === "failed" ? "blocked" : "source"), - recordField("conversation", state.conversationId), + sourceKindLabel(message.status === "running" ? "pending" : message.status === "failed" ? "blocked" : "source"), + recordField("conversation", message.conversationId ?? state.conversationId), recordField("trace", message.traceId), + recordField("session", message.sessionId ?? sessionSummary(message.session)), recordField("message", message.messageId), message.error?.message ? `失败原因=${safeFailureReason(message.error.message)}` : null ].filter(Boolean).join(" / "), - tone: message.status === "failed" ? "blocked" : "source" + tone: message.status === "running" ? "pending" : message.status === "failed" ? "blocked" : "source" }) ); } @@ -2312,7 +2320,7 @@ function renderAgentChatStatus(status, result = null) { blocked: "服务受阻" }; el.agentChatStatus.textContent = agentStatusLabel(status, result, labels); - el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" || status === "blocked" ? "blocked" : "source")}`; + el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" || status === "blocked" ? "blocked" : status === "running" ? "pending" : "source")}`; el.commandInput.disabled = status === "running"; el.commandSend.disabled = status === "running"; el.commandSend.textContent = status === "running" ? "发送中" : "发送"; @@ -2644,6 +2652,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 pendingContext = messagePendingContextPanel(message); + if (pendingContext) article.append(pendingContext); const attribution = messageAttributionPanel(message); if (attribution) article.append(attribution); const capabilityFacts = codeAgentCapabilityFactsPanel(message); @@ -2656,6 +2666,7 @@ function messageCard(message) { } function messageAttributionPanel(message) { + if (message.status === "running") return null; const attribution = codeAgentAttributionFromMessage(message); if (!attribution) return null; const section = document.createElement("section"); @@ -2684,6 +2695,7 @@ function messageAttributionPanel(message) { } function codeAgentCapabilityFactsPanel(message) { + if (message.status === "running") return null; const facts = codeAgentFactsFromMessage(message); if (!facts) return null; const section = document.createElement("section"); @@ -2717,6 +2729,36 @@ function codeAgentCapabilityFactsPanel(message) { return section; } +function messagePendingContextPanel(message) { + if (message.status !== "running") return null; + const fields = [ + ["状态", "后端处理中"], + ["traceId", message.traceId], + ["conversation", message.conversationId], + ["session", message.sessionId ?? "等待后端分配"], + ["timeout", `${CODE_AGENT_TIMEOUT_MS}ms`] + ]; + const section = document.createElement("section"); + section.className = "message-pending-context tone-border-pending"; + const header = document.createElement("div"); + header.className = "message-pending-head"; + header.append(badge("处理中", "pending"), textSpan("请求仍在同一会话上下文中等待后端结果。", "message-pending-summary")); + const list = document.createElement("div"); + list.className = "message-pending-grid"; + for (const [label, value] of fields) { + if (value === undefined || value === null || value === "") continue; + const item = document.createElement("div"); + item.className = "message-pending-row"; + item.append(textSpan(label, "message-pending-key"), textSpan(value, "message-pending-value mono")); + if (label === "traceId" || label === "conversation" || label === "session") { + item.append(copyButton(value, label)); + } + list.append(item); + } + section.append(header, list); + return section; +} + function codeAgentFactsHeader(facts) { const header = document.createElement("div"); header.className = "code-agent-facts-head"; @@ -2766,7 +2808,7 @@ function messageEvidencePanel(message) { if (fields.length === 0) return null; const details = document.createElement("details"); details.className = "message-evidence"; - details.open = message.status === "completed" || message.status === "source"; + details.open = message.status === "running" || message.status === "completed" || message.status === "source" || message.status === "failed"; const summary = document.createElement("summary"); summary.className = "message-meta"; summary.textContent = messageEvidenceSummary(message, fields); @@ -2778,11 +2820,29 @@ function messageEvidencePanel(message) { } function messageEvidenceSummary(message, fields) { - const sourceKind = message.sourceKind ?? (message.status === "completed" ? "DEV-LIVE" : message.status === "failed" ? "BLOCKED" : "SOURCE"); + const sourceKind = message.sourceKind ?? ( + message.status === "running" + ? "PENDING" + : message.status === "completed" + ? "DEV-LIVE" + : message.status === "failed" + ? "BLOCKED" + : "SOURCE" + ); return `证据 ${sourceKind} / ${fields.slice(0, 3).join(" / ")}`; } function messageEvidenceFields(message) { + if (message.status === "running") { + return [ + recordField("source", message.sourceKind ?? "PENDING"), + recordField("status", "running"), + recordField("conversation", message.conversationId), + recordField("trace", message.traceId), + recordField("session", message.sessionId ?? "pending"), + recordField("timeout", `${CODE_AGENT_TIMEOUT_MS}ms`) + ].filter(Boolean); + } const attribution = codeAgentAttributionFromMessage(message); return [ ...(attribution ? attribution.fields.map((field) => field.chip) : []), @@ -2794,7 +2854,7 @@ function messageEvidenceFields(message) { recordField("workspace", message.workspace), recordField("sandbox", message.sandbox), recordField("capability", message.capabilityLevel), - recordField("session", sessionSummary(message.session)), + recordField("session", sessionSummary(message.session) ?? message.sessionId), recordField("sessionMode", message.sessionMode), recordField("sessionReuse", sessionReuseSummary(message.sessionReuse)), recordField("implementation", message.implementationType), diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.mjs index 874e3fb4..26ba0c24 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.mjs @@ -206,6 +206,27 @@ test("maps stateless one-shot runner separately from long-lived sessions", () => assert.equal(summary.lastTraceId, "trc_one_shot"); }); +test("summarizes active Code Agent running request as pending with trace and session context", () => { + const summary = classifyCodeAgentStatusSummary({ + latestMessage: { + role: "agent", + status: "running", + sourceKind: "PENDING", + traceId: "trc_pending_heavy_skills", + conversationId: "cnv_pending_heavy_skills", + sessionId: "cnv_pending_heavy_skills" + } + }); + + assert.equal(summary.kind, "unverified"); + assert.equal(summary.label, "探测中"); + assert.equal(summary.tone, "pending"); + assert.equal(summary.codeAgentStatus, "running"); + assert.equal(summary.sessionId, "cnv_pending_heavy_skills"); + assert.equal(summary.lastTraceId, "trc_pending_heavy_skills"); + assert.equal(summary.readinessBlockers.length, 0); +}); + test("maps HWLAB Skill CLI API control to distinct green state when ready", () => { const summary = classifyCodeAgentStatusSummary({ latestMessage: { diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 73d83c0c..ac7f039e 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -9,6 +9,7 @@ import { runDevCloudWorkbenchMobileSmoke, runDevCloudWorkbenchAuthFixtureSmoke, runDevCloudWorkbenchQuickPromptsFixtureSmoke, + runDevCloudWorkbenchSlowBlockerFixtureSmoke, runDevCloudWorkbenchStaticSmoke } from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs"; import { gateSummary } from "../gate-summary.mjs"; @@ -152,6 +153,9 @@ const mobileOuterScrollSmokeCheck = mobileWorkbenchSmoke.checks.find((check) => 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 localAgentMobilePendingCheck = localAgentFixtureSmoke.checks.find((check) => check.id === "local-agent-fixture-mobile-pending-layout"); +const slowBlockerFixtureSmoke = await runDevCloudWorkbenchSlowBlockerFixtureSmoke(); +const slowBlockerCheck = slowBlockerFixtureSmoke.checks.find((check) => check.id === "local-agent-fixture-slow-structured-blocker"); 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"); @@ -218,6 +222,11 @@ 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(localAgentMobilePendingCheck?.status, "pass", "local Code Agent fixture must keep mobile pending state visible with trace/session context"); +assert.equal(slowBlockerFixtureSmoke.status, "pass", JSON.stringify(slowBlockerFixtureSmoke.blockers, null, 2)); +assert.equal(slowBlockerFixtureSmoke.evidenceLevel, "SOURCE"); +assert.equal(slowBlockerFixtureSmoke.devLive, false); +assert.equal(slowBlockerCheck?.status, "pass", "slow structured blocker fixture must show Chinese reason, trace, retry input, and no 4500ms permanent failure"); assert.equal(quickPromptsFixtureSmoke.status, "pass", JSON.stringify(quickPromptsFixtureSmoke.blockers, null, 2)); assert.equal(quickPromptsFixtureSmoke.evidenceLevel, "SOURCE"); assert.equal(quickPromptsFixtureSmoke.devLive, false); @@ -760,7 +769,7 @@ 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\("session", sessionSummary\(message\.session\)\s*\?\?\s*message\.sessionId\)/); assert.match(app, /recordField\("sessionMode", message\.sessionMode\)/); assert.match(app, /recordField\("sessionReuse", sessionReuseSummary\(message\.sessionReuse\)\)/); assert.match(app, /recordField\("implementation", message\.implementationType\)/); @@ -843,6 +852,7 @@ 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-pending-context\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*{/); @@ -891,7 +901,11 @@ assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*180000/); assert.match(app, /CODE_AGENT_TIMEOUT_MS/); assert.match(app, /timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/); assert.match(app, /HWLAB_CLOUD_WEB_CONFIG/); -assert.match(app, /失败时会保留输入供重试/); +assert.match(app, /sourceKind:\s*"PENDING"/); +assert.match(app, /旧 4500ms/); +assert.match(app, /function messagePendingContextPanel/); +assert.match(app, /message-pending-context/); +assert.match(app, /结构化 blocker/); assert.match(app, /Code Agent 请求超过/); assert.match(app, /agentFailurePresentation/); assert.match(app, /agentStatusLabel/); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 109b2bdc..0ec3cff1 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -887,6 +887,7 @@ h3 { min-width: 0; } +.message-pending-context, .message-attribution, .code-agent-facts, .message-m3-evidence { @@ -898,6 +899,10 @@ h3 { border: 1px solid var(--line); } +.message-pending-context { + background: rgba(55, 45, 24, 0.54); +} + .message-attribution { background: rgba(20, 24, 23, 0.76); border-left-width: 3px; @@ -912,6 +917,7 @@ h3 { background: rgba(15, 17, 16, 0.44); } +.message-pending-head, .message-attribution-head, .code-agent-facts-head { min-width: 0; @@ -921,6 +927,13 @@ h3 { align-items: start; } +.message-pending-head { + display: flex; + flex-wrap: wrap; + align-items: center; +} + +.message-pending-summary, .message-attribution-summary, .code-agent-facts-summary { min-width: 0; @@ -929,6 +942,40 @@ h3 { overflow-wrap: anywhere; } +.message-pending-grid { + min-width: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; +} + +.message-pending-row { + min-width: 0; + display: grid; + grid-template-columns: minmax(70px, auto) minmax(0, 1fr) auto; + gap: 5px; + align-items: center; + padding: 5px 6px; + background: rgba(24, 27, 24, 0.68); + border: 1px solid var(--line-soft); +} + +.message-pending-key, +.message-pending-value { + min-width: 0; + font-size: 10px; + overflow-wrap: anywhere; +} + +.message-pending-key { + color: var(--dim); + font-family: var(--mono); +} + +.message-pending-value { + color: var(--text); +} + .message-m3-head { min-width: 0; display: flex; @@ -2041,12 +2088,18 @@ tbody tr:last-child td { grid-template-columns: 1fr; } + .message-pending-context, .message-attribution, .code-agent-facts, .message-m3-evidence { grid-column: 1; } + .message-pending-grid, + .message-pending-row { + grid-template-columns: 1fr; + } + .message-m3-rows { grid-template-columns: 1fr; }