From ff99b30118f1d52b71d333610c5957e25e50cb7a Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 11:12:31 +0000 Subject: [PATCH] fix: classify code agent timeout failures --- internal/cloud/server.test.mjs | 52 ++++++ internal/dev-entrypoint/http.mjs | 11 +- internal/dev-entrypoint/http.test.mjs | 11 +- scripts/dev-artifact-publish.mjs | 10 +- scripts/dev-cloud-workbench-smoke.test.mjs | 88 +++++++++- scripts/src/code-agent-response-contract.mjs | 101 ++++++++++- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 19 ++- scripts/validate-dev-gate-report.mjs | 12 +- web/hwlab-cloud-web/app.mjs | 159 ++++++++++++++++-- web/hwlab-cloud-web/scripts/check.mjs | 15 +- 10 files changed, 449 insertions(+), 29 deletions(-) diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 27568c1a..e2fe28fc 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1397,6 +1397,58 @@ test("cloud api /v1/agent/chat accepts provider success delayed beyond legacy 45 } }); +test("cloud api /v1/agent/chat keeps delayed provider failure structured beyond legacy 4500ms UI timeout", async () => { + const server = createCloudApiServer({ + env: { + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1/provider-fixture" + }, + callCodeAgentProvider: async ({ providerPlan }) => { + await delay(4600); + const error = new Error("OpenAI Responses returned HTTP 503: slow upstream rejected"); + error.code = "provider_unavailable"; + error.provider = providerPlan.provider; + error.model = providerPlan.model; + error.backend = providerPlan.backend; + error.providerStatus = 503; + throw error; + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const startedAt = Date.now(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": "trc_server-test-agent-chat-delayed-failure" + }, + body: JSON.stringify({ + conversationId: "cnv_server-test-agent-chat-delayed-failure", + message: "请等待后返回 provider 失败分类" + }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(Date.now() - startedAt >= 4500, true); + assert.equal(payload.status, "failed"); + assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed-failure"); + assert.equal(payload.provider, "openai-responses"); + assert.equal(payload.error.code, "provider_unavailable"); + assert.equal(payload.error.providerStatus, 503); + assert.match(payload.error.message, /HTTP 503/u); + assert.equal(Object.hasOwn(payload, "reply"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api /v1/agent/chat reports provider timeout as failed without a reply", async () => { const providerServer = createHttpServer((request, response) => { request.resume(); diff --git a/internal/dev-entrypoint/http.mjs b/internal/dev-entrypoint/http.mjs index 611b58fa..8e6a31ce 100644 --- a/internal/dev-entrypoint/http.mjs +++ b/internal/dev-entrypoint/http.mjs @@ -109,7 +109,9 @@ export function listen(server, { serviceId, host, port }) { export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFAULT_PROXY_TIMEOUT_MS }) { const target = new URL(request.url || "/", upstream); + let timedOut = false; const timeout = setTimeout(() => { + timedOut = true; proxy.destroy(new Error(`upstream timed out after ${timeoutMs}ms`)); }, timeoutMs); const proxy = httpRequest( @@ -138,8 +140,15 @@ export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFA } settled = true; sendJson(response, 502, { - error: "upstream_unavailable", + status: "failed", + error: { + code: timedOut ? "proxy_timeout" : "upstream_unavailable", + category: timedOut ? "timeout" : "proxy", + message: error.message, + timeoutMs: timedOut ? timeoutMs : null + }, upstream, + traceId: request.headers["x-trace-id"] ?? null, message: error.message }); }); diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index b74ae89e..66e8ce3f 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -71,12 +71,19 @@ test("dev entrypoint proxy returns 502 when hard timeout expires", async () => { try { const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + "x-trace-id": "trc_dev-entrypoint-proxy-timeout" + }, body: JSON.stringify({ message: "slow" }) }); assert.equal(response.status, 502); const payload = await response.json(); - assert.equal(payload.error, "upstream_unavailable"); + assert.equal(payload.status, "failed"); + assert.equal(payload.error.code, "proxy_timeout"); + assert.equal(payload.error.category, "timeout"); + assert.equal(payload.error.timeoutMs, 50); + assert.equal(payload.traceId, "trc_dev-entrypoint-proxy-timeout"); assert.match(payload.message, /timed out after 50ms/u); } finally { await close(proxy); diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 634b61bb..484e5285 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -461,10 +461,18 @@ async function proxyCloudApi(request, response, url) { }); response.end(upstream.body); } catch (error) { + const timedOut = /timed out after \d+ms/iu.test(error.message); sendJson(response, 502, { - error: "cloud_api_proxy_failed", + status: "failed", + error: { + code: timedOut ? "cloud_api_proxy_timeout" : "cloud_api_proxy_failed", + category: timedOut ? "timeout" : "proxy", + message: error.message, + timeoutMs: timedOut ? cloudApiProxyTimeoutMs : null + }, serviceId, target: target.href.replace(/\/\/[^/@]*@/u, "//***@"), + traceId: request.headers["x-trace-id"] || null, reason: error.message }); } diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 72bef74c..87a7e88b 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -98,6 +98,24 @@ test("Code Agent browser failure classifier emits Chinese timeout/provider/brows assert.match(provider.chineseSummary, /Provider 故障/u); assert.match(provider.chineseSummary, /trc_provider/u); + const runnerBusy = classifyCodeAgentBrowserFailure(new Error("session_busy: runner already busy"), { traceId: "trc_busy" }); + assert.equal(runnerBusy.category, "runner_busy"); + assert.equal(runnerBusy.blocker, "runner-busy"); + assert.match(runnerBusy.chineseSummary, /Runner 忙碌/u); + assert.match(runnerBusy.chineseSummary, /trc_busy/u); + + const sessionBlocked = classifyCodeAgentBrowserFailure(new Error("session_expired: session blocked"), { traceId: "trc_session" }); + assert.equal(sessionBlocked.category, "session_blocked"); + assert.equal(sessionBlocked.blocker, "session-blocked"); + assert.match(sessionBlocked.chineseSummary, /Session 受阻/u); + assert.match(sessionBlocked.chineseSummary, /trc_session/u); + + const apiError = classifyCodeAgentBrowserFailure(new Error("API HTTP 500 request_failed"), { traceId: "trc_api" }); + assert.equal(apiError.category, "api_error"); + assert.equal(apiError.blocker, "api-error"); + assert.match(apiError.chineseSummary, /API 错误/u); + assert.match(apiError.chineseSummary, /trc_api/u); + const browser = classifyCodeAgentBrowserFailure(new Error("locator #command-input not found"), { traceId: "trc_browser" }); assert.equal(browser.category, "browser"); assert.match(browser.chineseSummary, /浏览器故障/u); @@ -367,6 +385,74 @@ test("Code Agent browser classifier treats sanitized 服务受阻 UI as blocked assert.equal(classification.blocker, "provider-upstream"); }); +test("Code Agent browser classifier distinguishes runner busy, session blocked, and API error payloads", () => { + const base = { + status: "failed", + provider: "codex-readonly-runner", + model: "read-only-tools", + backend: "hwlab-cloud-api/codex-readonly-runner", + traceId: "trc_runtime_block" + }; + + const runnerBusy = classifyCodeAgentBrowserJourney({ + responseSummary: sanitizeAgentChatBody({ + ...base, + error: { + code: "session_busy", + message: "Code Agent runner session is already busy" + } + }, { httpStatus: 200 }), + httpOk: true, + httpStatus: 200, + ui: { + agentChatStatus: "服务受阻", + completedMessageVisible: false, + failedMessageVisible: true + } + }); + assert.equal(runnerBusy.blocker, "runner-busy"); + assert.equal(runnerBusy.category, "runner_busy"); + + const sessionBlocked = classifyCodeAgentBrowserJourney({ + responseSummary: sanitizeAgentChatBody({ + ...base, + error: { + code: "session_expired", + message: "Code Agent runner session expired" + } + }, { httpStatus: 200 }), + httpOk: true, + httpStatus: 200, + ui: { + agentChatStatus: "服务受阻", + completedMessageVisible: false, + failedMessageVisible: true + } + }); + assert.equal(sessionBlocked.blocker, "session-blocked"); + assert.equal(sessionBlocked.category, "session_blocked"); + + const apiError = classifyCodeAgentBrowserJourney({ + responseSummary: sanitizeAgentChatBody({ + status: "failed", + traceId: "trc_proxy_timeout", + error: { + code: "cloud_api_proxy_failed", + message: "cloud web proxy failed" + } + }, { httpStatus: 502 }), + httpOk: false, + httpStatus: 502, + ui: { + agentChatStatus: "发送失败", + completedMessageVisible: false, + failedMessageVisible: true + } + }); + assert.equal(apiError.blocker, "api-error"); + assert.equal(apiError.category, "api_error"); +}); + test("Code Agent readiness classifier blocks completed payloads over any non-2xx HTTP status", () => { const completedPayload = { conversationId: "cnv_completed_non_2xx", @@ -476,7 +562,7 @@ test("local Code Agent timeout fixture keeps failed UI state, trace evidence, an assert.equal(report.devLive, false); const timeoutCheck = report.checks.find((check) => check.id === "local-agent-timeout-fixture-failed-state"); assert.equal(timeoutCheck?.status, "pass"); - assert.equal(timeoutCheck.observations.ui.agentChatStatus, "发送失败"); + assert.equal(timeoutCheck.observations.ui.agentChatStatus, "等待超时"); assert.equal(timeoutCheck.observations.ui.failedMessageVisible, true); assert.equal(timeoutCheck.observations.ui.failedMessageHasTrace, true); assert.equal(timeoutCheck.observations.ui.failedMessageHasChineseTimeout, true); diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 16f910c1..4a4ac0e2 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -2,7 +2,17 @@ const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-re const trustedRunnerProviders = 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(["发送失败", "服务受阻", "BLOCKED 凭证缺口"]); +const blockedCodeAgentUiLabels = new Set([ + "发送失败", + "服务受阻", + "等待超时", + "Provider 不可用", + "Runner 忙碌", + "Session 受阻", + "Runner 受阻", + "API 错误", + "BLOCKED 凭证缺口" +]); const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu; export function classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStatus = null } = {}) { @@ -345,6 +355,7 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId export function classifyCodeAgentBrowserJourney({ responseSummary, httpOk = false, httpStatus = null, ui = null } = {}) { const providerBlock = classifyCodeAgentProviderBlock(responseSummary, { httpStatus }); + const runtimeBlock = classifyCodeAgentRuntimeBlock(responseSummary, { httpStatus }); const uiCompleted = ui?.agentChatStatus === "DEV-LIVE 回复" || ui?.agentChatStatus === "已回复" || ui?.completedMessageVisible === true; const uiFailed = ui?.failedMessageVisible === true || blockedCodeAgentUiLabels.has(ui?.agentChatStatus); const evidence = inspectSanitizedCompletionEvidence(responseSummary); @@ -363,6 +374,10 @@ export function classifyCodeAgentBrowserJourney({ responseSummary, httpOk = fals }; } + if (runtimeBlock.blocked) { + return runtimeBlock; + } + if (providerBlock.blocked) { return { status: "blocked", @@ -387,6 +402,63 @@ export function classifyCodeAgentBrowserJourney({ responseSummary, httpOk = fals }; } +export function classifyCodeAgentRuntimeBlock(payload, { httpStatus = null } = {}) { + const errorCode = stringOrNull(payload?.error?.code); + const payloadStatus = stringOrNull(payload?.status); + + if (errorCode === "session_busy") { + return { + blocked: true, + status: "blocked", + blocker: "runner-busy", + category: "runner_busy", + reason: "Code Agent read-only runner session is already busy; the UI must keep the input retryable." + }; + } + + if (["session_expired", "session_reuse_conflict"].includes(errorCode)) { + return { + blocked: true, + status: "blocked", + blocker: "session-blocked", + category: "session_blocked", + reason: `Code Agent session is blocked by ${errorCode}; fallback must not be reported as a Codex session.` + }; + } + + if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(errorCode)) { + return { + blocked: true, + status: "blocked", + blocker: "runner-blocked", + category: "runner_blocked", + reason: `Code Agent read-only runner returned ${errorCode}; this is blocked runner evidence, not a completed fallback.` + }; + } + + if (["invalid_params", "parse_error", "request_failed", "cloud_api_proxy_failed", "upstream_unavailable"].includes(errorCode)) { + return { + blocked: true, + status: "blocked", + blocker: "api-error", + category: "api_error", + reason: `Code Agent API returned ${errorCode}; the request is retryable and must remain distinguishable from provider failure.` + }; + } + + if (payloadStatus === "failed" && errorCode && errorCode !== "provider_unavailable") { + return { + blocked: true, + status: "blocked", + blocker: "api-error", + category: "api_error", + reason: `Code Agent returned failed status with ${errorCode}.` + }; + } + + return { blocked: false }; +} + export function classifyCodeAgentBrowserFailure(error, { traceId = null } = {}) { const message = error instanceof Error ? error.message : String(error ?? ""); const observedTraceId = traceId ?? error?.traceId ?? null; @@ -399,6 +471,24 @@ export function classifyCodeAgentBrowserFailure(error, { traceId = null } = {}) reason: message }; } + if (/session[_ -]?busy|runner busy|already busy/iu.test(message)) { + return { + status: "blocked", + blocker: "runner-busy", + category: "runner_busy", + chineseSummary: `Runner 忙碌:Code Agent 正在处理上一轮请求,可稍后重试${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`, + reason: message + }; + } + if (/session[_ -]?(expired|blocked|reuse_conflict)|conversation.*session/iu.test(message)) { + return { + status: "blocked", + blocker: "session-blocked", + category: "session_blocked", + chineseSummary: `Session 受阻:Code Agent 会话无法复用或已过期${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`, + reason: message + }; + } if (/provider|upstream|openai|codex|502|503|504|429|provider_unavailable/iu.test(message)) { return { status: "blocked", @@ -408,6 +498,15 @@ export function classifyCodeAgentBrowserFailure(error, { traceId = null } = {}) reason: message }; } + if (/api|http \d{3}|invalid_params|parse_error|request_failed|cloud_api_proxy_failed/iu.test(message)) { + return { + status: "blocked", + blocker: "api-error", + category: "api_error", + chineseSummary: `API 错误:Code Agent 接口返回失败,输入应保留可重试${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`, + reason: message + }; + } return { status: "blocked", blocker: "browser-runtime", diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 30e5e445..b8ddf1b4 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -223,7 +223,16 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "message-evidence-chip" ]); -const blockedCodeAgentUiLabels = Object.freeze(["服务受阻", "BLOCKED 凭证缺口"]); +const blockedCodeAgentUiLabels = Object.freeze([ + "服务受阻", + "等待超时", + "Provider 不可用", + "Runner 忙碌", + "Session 受阻", + "Runner 受阻", + "API 错误", + "BLOCKED 凭证缺口" +]); const forbiddenWritePatterns = Object.freeze([ /callRpc\(\s*["']hardware\./u, @@ -507,7 +516,7 @@ function runStaticSmoke() { evidence: [ "DEV-LIVE 回复", "SOURCE 回复", - "发送失败", + "等待超时/API 错误/发送失败", "服务受阻 or legacy BLOCKED 凭证缺口", "message-evidence/details/chips" ] @@ -2047,7 +2056,7 @@ function trustedRecordGroups({ html, app, styles }) { /state\.liveSurface/u.test(app) && /renderRecords\(state\.liveSurface\)/u.test(app) && /traceId: error\.traceId \|\| traceId/u.test(app) && - /error\.traceId = traceId/u.test(app) && + /error\.traceId = (?:traceId|response\.data\?\.traceId \|\| traceId)/u.test(app) && requiredTrustedRecordTerms.every((term) => source.includes(term)) && /\.record-group\s*\{/u.test(styles) && /\.record-group-title\s*\{/u.test(styles) && @@ -3623,7 +3632,7 @@ async function runLocalAgentFixtureSmoke({ await page.locator("#command-input").fill(codeAgentE2ePrompts[0].text); await page.locator("#command-send").click(); await page.waitForFunction( - () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "发送失败", + () => ["等待超时", "发送失败"].includes(document.querySelector("#agent-chat-status")?.textContent?.trim() ?? ""), null, { timeout: 8000 } ); @@ -3641,7 +3650,7 @@ async function runLocalAgentFixtureSmoke({ responseSummary = response ? sanitizeAgentChatBody(responseBody, { httpStatus: response.status() }) : null; const pass = expectTimeout ? ( - ui.agentChatStatus === "发送失败" && + ["等待超时", "发送失败"].includes(ui.agentChatStatus) && ui.failedMessageVisible && ui.retryInputPreserved && ui.failedMessageHasTrace && diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 3b3702df..81737a81 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -44,7 +44,17 @@ const evidenceLevels = new Set(["SOURCE", "LOCAL", "DRY-RUN", "DEV-LIVE", "BLOCK const devCloudWorkbenchRuntimeIdentityMaxSkewMs = 15 * 60 * 1000; const syntheticAcceptedCodeAgentModels = new Set(["gpt-5"]); const deploymentPreflightCodeAgentErrorCode = "deployment_identity_preflight"; -const blockedCodeAgentUiLabels = new Set(["发送失败", "服务受阻", "BLOCKED 凭证缺口"]); +const blockedCodeAgentUiLabels = new Set([ + "发送失败", + "服务受阻", + "等待超时", + "Provider 不可用", + "Runner 忙碌", + "Session 受阻", + "Runner 受阻", + "API 错误", + "BLOCKED 凭证缺口" +]); const requiredValidationCommands = [ "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index a13519d6..09d471a0 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -558,21 +558,23 @@ function initCommandBar() { renderAgentChatStatus(status, result); } catch (error) { const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); + const presentation = agentFailurePresentation(error, { traceId }); state.chatMessages[index] = { ...pendingMessage, - title: "发送失败", - text: `发送失败:${error.message}`, + title: presentation.title, + text: presentation.text, status: "failed", traceId: error.traceId || traceId, updatedAt: new Date().toISOString(), error: { code: error.code || "request_failed", + category: presentation.category, message: error.message, timeoutMs: error.timeoutMs } }; el.commandInput.value = value; - renderAgentChatStatus("failed"); + renderAgentChatStatus("failed", state.chatMessages[index]); } finally { state.chatPending = false; renderConversation(); @@ -658,12 +660,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI if (isBlockedAgentResponse(response.data, response.status)) { return normalizeBlockedAgentResult(response.data, response.status, traceId, response.error); } - const error = new Error(response.timeout - ? `Code Agent 请求超过 ${response.timeoutMs}ms 未完成;已保留输入,可稍后重试。` - : response.error || "Code Agent 请求失败"); - error.traceId = traceId; - error.code = response.timeout ? "client_timeout" : "request_failed"; - error.timeoutMs = response.timeoutMs; + const error = agentErrorFromHttpResponse(response, traceId); throw error; } return { @@ -2067,7 +2064,7 @@ function renderAgentChatStatus(status, result = null) { failed: "发送失败", blocked: "服务受阻" }; - el.agentChatStatus.textContent = labels[status] ?? statusLabel(status); + 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.commandInput.disabled = status === "running"; el.commandSend.disabled = status === "running"; @@ -2079,6 +2076,21 @@ function renderAgentChatStatus(status, result = null) { } } +function agentStatusLabel(status, result, labels) { + if (status !== "failed") return labels[status] ?? statusLabel(status); + const presentation = agentFailurePresentation(result?.error, { result }); + return ( + { + timeout: "等待超时", + provider: "Provider 不可用", + runner_busy: "Runner 忙碌", + session_blocked: "Session 受阻", + runner_blocked: "Runner 受阻", + api_error: "API 错误" + }[presentation.category] ?? labels.failed + ); +} + function sourceTitle(result) { return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; } @@ -2108,11 +2120,15 @@ function classifyCodeAgentCompletion(result) { status: "failed", replied: false, sourceKind: "BLOCKED", - title: result?.status === "completed" ? "Code Agent 完成证据不足" : "Code Agent 返回失败" + title: result?.status === "completed" ? "Code Agent 完成证据不足" : agentFailurePresentation(result?.error, { result }).title }; } function failureMessage(result) { + const presentation = agentFailurePresentation(result?.error, { result }); + if (presentation.category !== "unknown") { + return presentation.text; + } if (result?.error?.code === "provider_unavailable" || result?.availability?.status === "blocked") { const availability = result.error?.providerStatus || result.availability?.status !== "blocked" ? codeAgentAvailabilityFromResult(result) @@ -2127,6 +2143,109 @@ function failureMessage(result) { return `Code Agent 调用失败:${result.error?.message ?? "未知错误"}。${suffix}请稍后重试或联系维护者补齐后端 provider。`; } +function agentErrorFromHttpResponse(response, traceId) { + const payloadError = response.data?.error; + const code = response.timeout + ? "client_timeout" + : normalizeErrorCode( + (payloadError && typeof payloadError === "object" ? payloadError.code : null) ?? + (typeof payloadError === "string" ? payloadError : null) ?? + response.data?.code ?? + "request_failed" + ); + const error = new Error(response.timeout + ? `Code Agent 请求超过 ${response.timeoutMs}ms 未完成;已保留输入,可稍后重试。` + : response.error || "Code Agent 请求失败"); + error.traceId = response.data?.traceId || traceId; + error.code = code; + error.timeoutMs = response.timeoutMs ?? (payloadError && typeof payloadError === "object" ? payloadError.timeoutMs : undefined); + error.providerStatus = payloadError && typeof payloadError === "object" ? payloadError.providerStatus : response.status; + error.category = payloadError && typeof payloadError === "object" ? payloadError.category : undefined; + return error; +} + +function agentFailurePresentation(error, { result = null, traceId = null } = {}) { + const code = normalizeErrorCode(error?.code); + const message = String(error?.message ?? result?.error?.message ?? "").trim(); + const observedTraceId = result?.traceId || error?.traceId || traceId || null; + const traceSuffix = observedTraceId ? ` trace=${observedTraceId}` : ""; + const timeoutMs = error?.timeoutMs ?? result?.error?.timeoutMs ?? timeoutMsFromText(message); + const providerStatus = error?.providerStatus ?? result?.error?.providerStatus; + + if (isTimeoutFailure(code, message)) { + return { + category: "timeout", + title: "Code Agent 等待超时", + text: `Code Agent 请求${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}未完成;输入已保留,可稍后重试或缩小问题范围。${traceSuffix}` + }; + } + + if (code === "session_busy") { + return { + category: "runner_busy", + title: "Code Agent Runner 忙碌", + text: `受控只读 runner 正在处理上一轮请求;输入已保留,稍后重试即可。${traceSuffix}` + }; + } + + if (["session_expired", "session_reuse_conflict"].includes(code)) { + return { + category: "session_blocked", + title: "Code Agent Session 受阻", + text: `当前 session 已过期或与 conversation 绑定不一致;输入已保留,可重新发送建立新的受控只读 session。${traceSuffix}` + }; + } + + if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(code)) { + return { + category: "runner_blocked", + title: "Code Agent Runner 受阻", + text: `受控只读 runner 返回 ${code};本次不会 fallback 冒充 Codex session。${message ? `原因:${message}。` : ""}${traceSuffix}` + }; + } + + if (["invalid_params", "parse_error", "request_failed", "cloud_api_proxy_failed", "upstream_unavailable"].includes(code)) { + return { + category: "api_error", + title: "Code Agent API 错误", + text: `Code Agent API 请求失败;输入已保留,可稍后重试。${message ? `原因:${message}。` : ""}${traceSuffix}` + }; + } + + if (code === "provider_unavailable" || providerStatus) { + return { + category: "provider", + title: "Code Agent Provider 不可用", + text: providerStatus + ? `Code Agent provider 返回 HTTP ${providerStatus};本次未生成回复,输入已保留,可稍后重试。${traceSuffix}` + : `Code Agent provider 暂不可用;本次未生成回复,输入已保留,可稍后重试。${traceSuffix}` + }; + } + + return { + category: "unknown", + title: "Code Agent 返回失败", + text: `Code Agent 调用失败:${message || "未知错误"}。请稍后重试或联系维护者补齐后端 provider。${traceSuffix}` + }; +} + +function normalizeErrorCode(code) { + const normalized = String(code ?? "").trim(); + return normalized || "code_agent_failed"; +} + +function isTimeoutFailure(code, message) { + return ["client_timeout", "proxy_timeout", "cloud_api_proxy_timeout"].includes(code) || + /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu.test(message); +} + +function timeoutMsFromText(message) { + const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过)\s*(\d+)\s*ms/iu); + if (!match) return null; + const value = Number.parseInt(match[1], 10); + return Number.isInteger(value) && value > 0 ? value : null; +} + function messageCard(message) { const article = document.createElement("article"); article.className = `message-card message-${toneClass(message.role)} status-${toneClass(message.status)}`; @@ -2326,16 +2445,24 @@ function runtimeSummaryFrom(live) { function messageFromPayload(payload) { if (!payload || typeof payload !== "object") return "非 JSON 或空响应"; - return payload.error?.message || payload.error?.code || payload.message || JSON.stringify(payload).slice(0, 160); + if (payload.error && typeof payload.error === "object") { + return payload.error.message || payload.error.code || payload.reason || payload.message || JSON.stringify(payload.error).slice(0, 160); + } + if (typeof payload.error === "string") { + return payload.message ? `${payload.error}:${payload.message}` : payload.error; + } + return payload.code || payload.message || payload.reason || JSON.stringify(payload).slice(0, 160); } function normalizeBlockedAgentResult(payload, httpStatus, traceId, fallbackMessage) { const { reply, ...safePayload } = payload; - const providerStatus = payload.error?.providerStatus ?? httpStatus; + const payloadError = payload.error && typeof payload.error === "object" ? payload.error : {}; + const payloadErrorCode = payloadError.code ?? (typeof payload.error === "string" ? payload.error : null); + const providerStatus = payloadError.providerStatus ?? httpStatus; const error = { - ...(payload.error && typeof payload.error === "object" ? payload.error : {}), - code: payload.error?.code ?? "provider_unavailable", - message: payload.error?.message ?? fallbackMessage ?? `Code Agent provider HTTP ${httpStatus}`, + ...payloadError, + code: payloadErrorCode ?? "provider_unavailable", + message: payloadError.message ?? fallbackMessage ?? `Code Agent provider HTTP ${httpStatus}`, providerStatus }; return { diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index f12aa23b..6dd858b7 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -251,7 +251,7 @@ assert.match(app, /function liveFailureCard/); assert.match(app, /state\.liveSurface/); assert.match(app, /renderRecords\(state\.liveSurface\)/); assert.match(app, /traceId: error\.traceId \|\| traceId/); -assert.match(app, /error\.traceId = traceId/); +assert.match(app, /error\.traceId = (?:traceId|response\.data\?\.traceId \|\| traceId)/); assert.match(app, /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/); assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*150000/); assert.match(app, /async function sendAgentMessage[\s\S]*?timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/); @@ -629,6 +629,17 @@ assert.match(app, /timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/); assert.match(app, /HWLAB_CLOUD_WEB_CONFIG/); assert.match(app, /失败时会保留输入供重试/); assert.match(app, /Code Agent 请求超过/); +assert.match(app, /agentFailurePresentation/); +assert.match(app, /agentStatusLabel/); +assert.match(app, /Code Agent 等待超时/); +assert.match(app, /Code Agent Runner 忙碌/); +assert.match(app, /Code Agent Session 受阻/); +assert.match(app, /Code Agent API 错误/); +assert.match(app, /等待超时/); +assert.match(app, /Provider 不可用/); +assert.match(app, /Runner 忙碌/); +assert.match(app, /Session 受阻/); +assert.match(app, /API 错误/); 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); @@ -671,6 +682,8 @@ assert.match(artifactPublisher, /"audit\.event\.query"/); assert.match(artifactPublisher, /"evidence\.record\.query"/); assert.match(artifactPublisher, /readonly_rpc_required/); assert.match(artifactPublisher, /cloud_api_proxy_failed/); +assert.match(artifactPublisher, /cloud_api_proxy_timeout/); +assert.match(artifactPublisher, /traceId:\s*request\.headers\["x-trace-id"\]/); assert.match(artifactPublisher, /url\.pathname\.replace\(\/\\\/\+\$\/,\s*""\) \|\| "\/"/); assert.match(artifactPublisher, /routePath === "\/gate"/); assert.match(artifactPublisher, /routePath === "\/diagnostics\/gate"/);