From 37a6a8c410ba7958f66a6414e73c989920c53dfc Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sun, 24 May 2026 08:56:15 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B9=E8=BF=9B=20Code=20Agent=20?= =?UTF-8?q?=E9=95=BF=E8=AF=B7=E6=B1=82=E7=AD=89=E5=BE=85=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/dev-cloud-workbench-smoke.test.mjs | 21 ++++- scripts/src/code-agent-response-contract.mjs | 17 +++- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 83 +++++++++++++++++-- web/hwlab-cloud-web/app.mjs | 69 +++++++++++++-- web/hwlab-cloud-web/scripts/check.mjs | 5 ++ 5 files changed, 180 insertions(+), 15 deletions(-) diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 838065b9..ef3cdd27 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -449,6 +449,25 @@ test("Code Agent browser classifier distinguishes runner busy, session blocked, assert.equal(sessionBlocked.blocker, "session-blocked"); assert.equal(sessionBlocked.category, "session_blocked"); + const codexRuntimeBlocked = classifyCodeAgentBrowserJourney({ + responseSummary: sanitizeAgentChatBody({ + ...base, + error: { + code: "codex_stdio_failed", + message: "Codex app-server turn finished with status=failed" + } + }, { httpStatus: 200 }), + httpOk: true, + httpStatus: 200, + ui: { + agentChatStatus: "Runner 受阻", + completedMessageVisible: false, + failedMessageVisible: true + } + }); + assert.equal(codexRuntimeBlocked.blocker, "runner-blocked"); + assert.equal(codexRuntimeBlocked.category, "runner_blocked"); + const apiError = classifyCodeAgentBrowserJourney({ responseSummary: sanitizeAgentChatBody({ status: "failed", @@ -461,7 +480,7 @@ test("Code Agent browser classifier distinguishes runner busy, session blocked, httpOk: false, httpStatus: 502, ui: { - agentChatStatus: "发送失败", + agentChatStatus: "后端失败", completedMessageVisible: false, failedMessageVisible: true } diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 5e85737c..0efa6fb2 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -4,7 +4,7 @@ const partialRunnerProviders = new Set(["codex-readonly-runner"]); const readonlySessionCapabilityLevels = new Set(["read-only-tools", "read-only-session-tools"]); const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const blockedCodeAgentUiLabels = new Set([ - "发送失败", + "后端失败", "服务受阻", "等待超时", "Provider 不可用", @@ -484,7 +484,20 @@ export function classifyCodeAgentRuntimeBlock(payload, { httpStatus = null } = { }; } - if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "external_network_blocked", "network_tool_unavailable", "network_timeout"].includes(errorCode)) { + if ([ + "runner_unavailable", + "tool_unavailable", + "skills_unavailable", + "security_blocked", + "codex_stdio_blocked", + "codex_stdio_failed", + "codex_stdio_protocol_blocked", + "codex_stdio_empty_response", + "codex_stdio_command_probe_failed", + "external_network_blocked", + "network_tool_unavailable", + "network_timeout" + ].includes(errorCode)) { return { blocked: true, status: "blocked", diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 660e6b26..b3117355 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -329,6 +329,7 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ ]); const blockedCodeAgentUiLabels = Object.freeze([ + "后端失败", "服务受阻", "等待超时", "Provider 不可用", @@ -654,7 +655,7 @@ function runStaticSmoke() { evidence: [ "DEV-LIVE 回复", "SOURCE 回复", - "等待超时/API 错误/发送失败", + "等待超时/API 错误/后端失败", "服务受阻 or legacy BLOCKED 凭证缺口", "message-trace/details/events" ] @@ -4222,6 +4223,31 @@ async function inspectJourneyControls(page) { async function inspectJourneyUi(page) { return page.evaluate(() => { + const summarizeActions = (statusClass) => { + const cards = [...document.querySelectorAll(`.message-card.${statusClass}`)]; + const labels = cards.flatMap((card) => + [...card.querySelectorAll(".message-action")].map((button) => button.textContent?.replace(/\s+/gu, " ").trim() ?? "") + ); + const panels = cards.flatMap((card) => + [...card.querySelectorAll(".message-actions")].map((panel) => { + const box = panel.getBoundingClientRect(); + return { + width: box.width, + left: box.left, + right: box.right, + scrollWidth: panel.scrollWidth, + clientWidth: panel.clientWidth, + contained: box.left >= -1 && box.right <= window.innerWidth + 1 && panel.scrollWidth <= panel.clientWidth + 2 + }; + }) + ); + return { + retryVisible: labels.includes("重试上一条"), + traceVisible: labels.includes("回放 trace"), + cancelVisible: labels.includes("取消当前请求"), + contained: panels.length > 0 && panels.every((panel) => panel.contained) + }; + }; const failedText = [...document.querySelectorAll(".message-card.status-failed")] .map((element) => element.textContent ?? "") .join("\n"); @@ -4250,6 +4276,9 @@ async function inspectJourneyUi(page) { contained: box.left >= -1 && box.right <= window.innerWidth + 1 && card.scrollWidth <= card.clientWidth + 2 }; }); + const runningActions = summarizeActions("status-running"); + const failedActions = summarizeActions("status-failed"); + const timeoutActions = summarizeActions("status-timeout"); return { title: document.title, workspaceHidden: document.querySelector('[data-view="workspace"]')?.hidden ?? null, @@ -4271,6 +4300,16 @@ async function inspectJourneyUi(page) { 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)), + pendingRetryActionVisible: runningActions.retryVisible, + pendingTraceActionVisible: runningActions.traceVisible, + pendingCancelActionVisible: runningActions.cancelVisible, + pendingActionPanelContained: runningActions.contained, + failedRetryActionVisible: failedActions.retryVisible, + failedTraceActionVisible: failedActions.traceVisible, + failedActionPanelContained: failedActions.contained, + timeoutRetryActionVisible: timeoutActions.retryVisible, + timeoutTraceActionVisible: timeoutActions.traceVisible, + timeoutActionPanelContained: timeoutActions.contained, pendingContextRowsContained: pendingRows.length > 0 && pendingRows.every((row) => row.contained), runningCardsContained: runningCardBoxes.length > 0 && runningCardBoxes.every((box) => box.contained), messageCount: document.querySelectorAll(".message-card").length, @@ -4324,6 +4363,16 @@ async function inspectLegacyFailureWindow(page, { beforeMessageCount }) { contained: row.scrollWidth <= row.clientWidth + 2 })) : []; + const pendingActionLabels = latestAgent + ? [...latestAgent.querySelectorAll(".message-action")].map((button) => button.textContent?.replace(/\s+/gu, " ").trim() ?? "") + : []; + const pendingActionPanels = latestAgent + ? [...latestAgent.querySelectorAll(".message-actions")].map((panel) => ({ + scrollWidth: panel.scrollWidth, + clientWidth: panel.clientWidth, + contained: panel.scrollWidth <= panel.clientWidth + 2 + })) + : []; const latestAgentBox = latestAgent?.getBoundingClientRect(); const pendingStillRunning = latestAgent?.classList.contains("status-running") === true || @@ -4342,6 +4391,9 @@ async function inspectLegacyFailureWindow(page, { beforeMessageCount }) { 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)), + pendingRetryActionVisible: pendingActionLabels.includes("重试上一条"), + pendingTraceActionVisible: pendingActionLabels.includes("回放 trace"), + pendingActionsContained: pendingActionPanels.length > 0 && pendingActionPanels.every((panel) => panel.contained), pendingRowsContained: pendingRows.length > 0 && pendingRows.every((row) => row.contained), runningCardContained: latestAgentBox ? latestAgentBox.left >= -1 && @@ -4678,7 +4730,7 @@ async function runLocalAgentFixtureSmoke({ 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() ?? ""), + () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "等待超时", null, { timeout: 8000 } ); @@ -4711,10 +4763,13 @@ async function runLocalAgentFixtureSmoke({ responseSummary = response ? sanitizeAgentChatBody(responseBody, { httpStatus: response.status() }) : null; const pass = expectTimeout ? ( - ["等待超时", "发送失败"].includes(ui.agentChatStatus) && + ui.agentChatStatus === "等待超时" && ui.retryInputPreserved && (ui.failedMessageHasTrace || ui.traceHasTraceId) && (ui.failedMessageHasChineseTimeout || ui.agentChatStatus === "等待超时") && + ui.timeoutRetryActionVisible && + ui.timeoutTraceActionVisible && + ui.timeoutActionPanelContained && !ui.completedMessageVisible ) : agentFixtureMode === "slow-blocker" || agentFixtureMode === "external-network-blocker" @@ -4730,6 +4785,9 @@ async function runLocalAgentFixtureSmoke({ ui.failedMessageVisible && ui.failedMessageHasTrace && ui.retryInputPreserved && + ui.failedRetryActionVisible && + ui.failedTraceActionVisible && + ui.failedActionPanelContained && !ui.completedMessageVisible ) : ( @@ -4746,6 +4804,9 @@ async function runLocalAgentFixtureSmoke({ result.legacyWindow?.pendingConversationVisible === true && result.legacyWindow?.pendingSessionVisible === true && result.legacyWindow?.pendingTraceCopyVisible === true && + result.legacyWindow?.pendingRetryActionVisible === true && + result.legacyWindow?.pendingTraceActionVisible === true && + result.legacyWindow?.pendingActionsContained === true && result.legacyWindow?.pendingRowsContained === true && result.legacyWindow?.runningCardContained === true ) && @@ -4805,6 +4866,9 @@ async function runLocalAgentFixtureSmoke({ result.legacyWindow?.pendingConversationVisible === true && result.legacyWindow?.pendingSessionVisible === true && result.legacyWindow?.pendingTraceCopyVisible === true && + result.legacyWindow?.pendingRetryActionVisible === true && + result.legacyWindow?.pendingTraceActionVisible === true && + result.legacyWindow?.pendingActionsContained === true && result.legacyWindow?.pendingRowsContained === true && result.legacyWindow?.runningCardContained === true ) ? "pass" : "blocked", @@ -4835,7 +4899,7 @@ async function runLocalAgentFixtureSmoke({ status: pass ? "pass" : "blocked", summary: agentFixtureMode === "external-network-blocker" ? "External network blocker for a GitHub prompt displays Runner 受阻 with network trace, keeps the user prompt, and does not restore main evidence/facts noise." - : "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.", + : "Delayed structured blocker arrives after the legacy 4500ms window, displays Runner 受阻 instead of a generic 后端失败 state, keeps trace visible, and leaves the user prompt for continuing the same conversation.", observations: { legacyFailureWindowMs, fixtureDelayMs: responseDelayMs, @@ -4946,10 +5010,16 @@ async function inspectLocalAgentPendingViewport(browser, url, { width, height, r legacyWindow.pendingConversationVisible === true && legacyWindow.pendingSessionVisible === true && legacyWindow.pendingTraceCopyVisible === true && + legacyWindow.pendingRetryActionVisible === true && + legacyWindow.pendingTraceActionVisible === true && + legacyWindow.pendingActionsContained === true && legacyWindow.pendingRowsContained === true && legacyWindow.runningCardContained === true && ui.runningCardsContained === true && - ui.pendingContextRowsContained === true, + ui.pendingContextRowsContained === true && + ui.pendingRetryActionVisible === true && + ui.pendingTraceActionVisible === true && + ui.pendingActionPanelContained === true, legacyWindow, ui: { agentChatStatus: ui.agentChatStatus, @@ -4959,6 +5029,9 @@ async function inspectLocalAgentPendingViewport(browser, url, { width, height, r pendingMessageHasConversation: ui.pendingMessageHasConversation, pendingMessageHasSession: ui.pendingMessageHasSession, pendingTraceCopyVisible: ui.pendingTraceCopyVisible, + pendingRetryActionVisible: ui.pendingRetryActionVisible, + pendingTraceActionVisible: ui.pendingTraceActionVisible, + pendingActionPanelContained: ui.pendingActionPanelContained, pendingContextRowsContained: ui.pendingContextRowsContained, runningCardsContained: ui.runningCardsContained }, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index b817ada9..40222e80 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -792,8 +792,12 @@ async function submitAgentMessage(value, options = {}) { renderAgentChatStatus(failedStatus, state.chatMessages[index]); renderCodeAgentSummary(); } finally { - if (state.currentRequest?.traceId === traceId) state.currentRequest = null; - state.chatPending = false; + if (state.currentRequest?.traceId === traceId) { + state.currentRequest = null; + state.chatPending = false; + } else if (!state.currentRequest) { + state.chatPending = false; + } stopTraceStream(); renderCodeAgentSummary(); renderConversation(); @@ -2575,7 +2579,7 @@ function renderAgentChatStatus(status, result = null) { running: "处理中", completed: "DEV-LIVE 回复", source: "SOURCE 回复", - failed: "发送失败", + failed: "后端失败", timeout: "等待超时", canceled: "已取消", error: "请求错误", @@ -2927,7 +2931,20 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) }; } - if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(code)) { + if ([ + "runner_unavailable", + "tool_unavailable", + "skills_unavailable", + "security_blocked", + "codex_stdio_blocked", + "codex_stdio_failed", + "codex_stdio_protocol_blocked", + "codex_stdio_empty_response", + "codex_stdio_command_probe_failed", + "external_network_blocked", + "network_tool_unavailable", + "network_timeout" + ].includes(code)) { return { category: "runner_blocked", title: "Code Agent Runner 受阻", @@ -2969,6 +2986,8 @@ function titleForBlockerCategory(category, code) { if (value === "canceled" || code === "codex_stdio_canceled") return "Code Agent 已取消"; if (value === "runner_busy") return "Code Agent Runner 忙碌"; if (value === "session_blocked") return "Code Agent Session 受阻"; + if (value === "runner_blocked") return "Code Agent Runner 受阻"; + if (value === "api_error") return "Code Agent API 错误"; if (value === "capability_unavailable") return "Code Agent 能力未开放"; if (value === "retryable") return "Code Agent 可重试"; return "Code Agent 服务受阻"; @@ -3013,8 +3032,11 @@ function messageActionsPanel(message) { const actions = []; if (message.status === "running") { actions.push(actionButton("取消当前请求", "cancel", () => cancelAgentMessage(message.id), "取消当前 in-flight Codex stdio 请求")); + if (message.retryInput) { + actions.push(actionButton("重试上一条", "retry", () => restartRunningAgentMessage(message.id), "先取消当前 trace,再用同一输入重新发送")); + } } - if (canRetryAgentMessage(message)) { + if (canRetryTerminalAgentMessage(message)) { actions.push(actionButton("重试上一条", "retry", () => retryAgentMessage(message.id), "保留 conversation/trace 记录并重新发送上一条输入")); } if (message.traceId) { @@ -3040,16 +3062,38 @@ function actionButton(label, action, onClick, title) { return button; } -function canRetryAgentMessage(message) { +function canRetryTerminalAgentMessage(message) { return Boolean( message?.retryInput && ["failed", "timeout", "error", "canceled"].includes(String(message.status ?? "").toLowerCase()) ); } +async function restartRunningAgentMessage(messageId) { + const message = state.chatMessages.find((item) => item.id === messageId); + if (!message?.retryInput || message.status !== "running") return; + await cancelAgentMessage(messageId); + const canceled = state.chatMessages.find((item) => item.id === messageId); + if (canceled?.status !== "canceled" || state.chatPending) { + setAgentActionStatus(messageId, "取消未确认,当前 trace 仍在等待后端;已保留输入,可等待真实超时或后端结果后再重试。"); + return; + } + await submitAgentMessage(message.retryInput, { + conversationId: canceled.conversationId || state.conversationId || undefined, + sessionId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status) + ? undefined + : canceled.sessionId, + retryOf: canceled.traceId + }); +} + async function retryAgentMessage(messageId) { const message = state.chatMessages.find((item) => item.id === messageId); - if (!message?.retryInput || state.chatPending) return; + if (!message?.retryInput) return; + if (state.chatPending) { + setAgentActionStatus(messageId, "当前仍有请求处理中;请先取消当前请求,或等待真实超时/后端结果后再重试。"); + return; + } await submitAgentMessage(message.retryInput, { conversationId: message.conversationId || state.conversationId || undefined, sessionId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status) @@ -3059,6 +3103,17 @@ async function retryAgentMessage(messageId) { }); } +function setAgentActionStatus(messageId, text) { + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return; + state.chatMessages[index] = { + ...state.chatMessages[index], + traceReplayStatus: text, + updatedAt: new Date().toISOString() + }; + renderConversation(); +} + async function cancelAgentMessage(messageId) { const index = state.chatMessages.findIndex((message) => message.id === messageId); if (index < 0) return; diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index b0867d07..2db1ca61 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -901,6 +901,9 @@ assert.match(app, /sourceKind:\s*"PENDING"/); assert.match(app, /旧 4500ms/); assert.match(app, /function messagePendingContextPanel/); assert.match(app, /message-pending-context/); +assert.match(app, /function\s+restartRunningAgentMessage/); +assert.match(app, /先取消当前 trace,再用同一输入重新发送/); +assert.match(app, /canRetryTerminalAgentMessage/); assert.match(app, /结构化 blocker/); assert.match(app, /Code Agent 请求超过/); assert.match(app, /agentFailurePresentation/); @@ -914,6 +917,8 @@ assert.match(app, /Provider 不可用/); assert.match(app, /Runner 忙碌/); assert.match(app, /Session 受阻/); assert.match(app, /API 错误/); +assert.match(app, /后端失败/); +assert.doesNotMatch(app, /发送失败/); assert.doesNotMatch(html, /M3 Diagnostics Console/); assert.match(styles, /html,\s*\nbody\s*{[^}]*height:\s*100%;[^}]*overflow:\s*hidden;/s); assert.match(styles, /body\s*>\s*\[data-app-shell\]\s*{[^}]*min-height:\s*0;/s);