fix: classify code agent timeout failures
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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"
|
||||
|
||||
+143
-16
@@ -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 {
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
Reference in New Issue
Block a user