fix: extend code agent heavy request timeout

This commit is contained in:
Code Queue Review
2026-05-23 04:34:23 +00:00
parent 71152d4d7f
commit 78675f2de1
10 changed files with 511 additions and 63 deletions
+13 -2
View File
@@ -10,6 +10,10 @@ import { createServer } from "node:http";
const serviceId = "hwlab-edge-proxy";
const upstream = process.env.HWLAB_EDGE_UPSTREAM || "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667";
const proxyTimeoutMs = parseTimeout(process.env.HWLAB_EDGE_PROXY_TIMEOUT_MS, 150000, {
min: 1000,
max: 300000
});
const { host, port } = resolveHostPort({
listenEnv: "HWLAB_EDGE_LISTEN",
hostEnv: "HWLAB_EDGE_HOST",
@@ -20,15 +24,22 @@ const { host, port } = resolveHostPort({
function proxyDetails() {
return {
upstream,
timeoutMs: proxyTimeoutMs,
mode: "dev-edge-proxy"
};
}
function parseTimeout(value, fallback, { min, max }) {
const parsed = Number.parseInt(value || "", 10);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
const server = createServer((request, response) => {
const url = new URL(request.url || "/", "http://hwlab-edge-proxy.local");
if (request.method === "GET" && url.pathname === "/health/live") {
proxyHttpRequest({ request, response, upstream });
proxyHttpRequest({ request, response, upstream, timeoutMs: proxyTimeoutMs });
return;
}
@@ -66,7 +77,7 @@ const server = createServer((request, response) => {
return;
}
proxyHttpRequest({ request, response, upstream });
proxyHttpRequest({ request, response, upstream, timeoutMs: proxyTimeoutMs });
});
listen(server, { serviceId, host, port });
+159
View File
@@ -878,6 +878,161 @@ test("cloud api /v1/agent/chat uses streaming OpenAI Responses and parses SSE te
}
});
test("cloud api /v1/agent/chat accepts provider success delayed beyond legacy 4500ms UI timeout", async () => {
const server = createCloudApiServer({
callCodeAgentProvider: async ({ providerPlan, message, traceId }) => {
await delay(4700);
return {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
content: `延迟真实 provider stub: ${message} / ${traceId}`,
usage: null,
providerTrace: {
source: "delayed-test-provider"
}
};
}
});
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"
},
body: JSON.stringify({
conversationId: "cnv_server-test-agent-chat-delayed",
message: "列出你能使用的所有skill"
})
});
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(Date.now() - startedAt >= 4500, true);
assert.equal(payload.status, "completed");
assert.equal(payload.conversationId, "cnv_server-test-agent-chat-delayed");
assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed");
assert.match(payload.reply.content, /列出你能使用的所有skill/u);
assert.equal(Object.hasOwn(payload, "error"), 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();
setTimeout(() => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ output_text: "too late" }));
}, 250);
});
await new Promise((resolve) => providerServer.listen(0, "127.0.0.1", resolve));
const providerPort = providerServer.address().port;
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:${providerPort}/v1/responses`
},
codeAgentTimeoutMs: 50
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
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-provider-timeout"
},
body: JSON.stringify({
conversationId: "cnv_server-test-agent-chat-provider-timeout",
message: "请等待后回答"
})
});
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.status, "failed");
assert.equal(payload.traceId, "trc_server-test-agent-chat-provider-timeout");
assert.equal(payload.error.code, "provider_unavailable");
assert.match(payload.error.message, /timed out after 50ms/u);
assert.equal(Object.hasOwn(payload, "reply"), false);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
await new Promise((resolve, reject) => {
providerServer.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api /v1/agent/chat reports OpenAI provider 502 and 503 as failed blocked payloads", async () => {
for (const status of [502, 503]) {
const providerServer = createHttpServer((request, response) => {
request.resume();
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify({
error: {
message: `upstream ${status}`
}
}));
});
await new Promise((resolve) => providerServer.listen(0, "127.0.0.1", resolve));
const providerPort = providerServer.address().port;
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:${providerPort}/v1/responses`
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
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-provider-${status}`
},
body: JSON.stringify({
conversationId: `cnv_server-test-agent-chat-provider-${status}`,
message: `provider ${status}`
})
});
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.status, "failed");
assert.equal(payload.traceId, `trc_server-test-agent-chat-provider-${status}`);
assert.equal(payload.error.code, "provider_unavailable");
assert.equal(payload.error.providerStatus, status);
assert.match(payload.error.message, new RegExp(`HTTP ${status}`));
assert.equal(Object.hasOwn(payload, "reply"), false);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
await new Promise((resolve, reject) => {
providerServer.close((error) => (error ? reject(error) : resolve()));
});
}
}
});
test("cloud api /v1/agent/chat reports provider gaps without faking a reply", async () => {
const server = createCloudApiServer({
env: {
@@ -930,6 +1085,10 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as
}
});
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
test("cloud api /v1/agent/chat does not complete on empty provider text", async () => {
const server = createCloudApiServer({
callCodeAgentProvider: async ({ providerPlan }) => ({
+15 -1
View File
@@ -2,6 +2,8 @@ import { createServer, request as httpRequest } from "node:http";
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../protocol/index.mjs";
const DEFAULT_PROXY_TIMEOUT_MS = 150000;
export function parsePort(value, fallback) {
const parsed = Number.parseInt(value ?? "", 10);
if (Number.isInteger(parsed) && parsed > 0 && parsed < 65536) {
@@ -105,8 +107,11 @@ export function listen(server, { serviceId, host, port }) {
}
}
export function proxyHttpRequest({ request, response, upstream }) {
export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFAULT_PROXY_TIMEOUT_MS }) {
const target = new URL(request.url || "/", upstream);
const timeout = setTimeout(() => {
proxy.destroy(new Error(`upstream timed out after ${timeoutMs}ms`));
}, timeoutMs);
const proxy = httpRequest(
target,
{
@@ -118,11 +123,20 @@ export function proxyHttpRequest({ request, response, upstream }) {
},
(upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
upstreamResponse.on("end", () => clearTimeout(timeout));
upstreamResponse.on("close", () => clearTimeout(timeout));
upstreamResponse.pipe(response);
}
);
let settled = false;
proxy.on("error", (error) => {
clearTimeout(timeout);
if (settled || response.headersSent) {
response.destroy(error);
return;
}
settled = true;
sendJson(response, 502, {
error: "upstream_unavailable",
upstream,
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import { proxyHttpRequest } from "./http.mjs";
test("dev entrypoint proxy allows slow first response beyond legacy 4500ms", async () => {
const upstream = createServer((request, response) => {
request.resume();
setTimeout(() => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
status: "completed",
traceId: request.headers["x-trace-id"] ?? null
}));
}, 4700);
});
await listen(upstream);
const proxy = createServer((request, response) => {
proxyHttpRequest({
request,
response,
upstream: serverUrl(upstream),
timeoutMs: 6000
});
});
await listen(proxy);
try {
const startedAt = Date.now();
const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-trace-id": "trc_dev-entrypoint-proxy-slow-success"
},
body: JSON.stringify({ message: "列出你能使用的所有skill" })
});
assert.equal(response.status, 200);
assert.equal(Date.now() - startedAt >= 4500, true);
const payload = await response.json();
assert.equal(payload.status, "completed");
assert.equal(payload.traceId, "trc_dev-entrypoint-proxy-slow-success");
} finally {
await close(proxy);
await close(upstream);
}
});
test("dev entrypoint proxy returns 502 when hard timeout expires", async () => {
const upstream = createServer((request, response) => {
request.resume();
setTimeout(() => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ status: "completed" }));
}, 250);
});
await listen(upstream);
const proxy = createServer((request, response) => {
proxyHttpRequest({
request,
response,
upstream: serverUrl(upstream),
timeoutMs: 50
});
});
await listen(proxy);
try {
const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: "slow" })
});
assert.equal(response.status, 502);
const payload = await response.json();
assert.equal(payload.error, "upstream_unavailable");
assert.match(payload.message, /timed out after 50ms/u);
} finally {
await close(proxy);
await close(upstream);
}
});
function listen(server) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
}
function close(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
function serverUrl(server) {
const address = server.address();
return `http://127.0.0.1:${address.port}`;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -1
View File
@@ -311,6 +311,10 @@ const runtimeKind = process.env.HWLAB_ARTIFACT_KIND || "health-placeholder";
const entrypoint = process.env.HWLAB_SERVICE_ENTRYPOINT || "";
const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "8080", 10);
const cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || "";
const cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 150000, {
min: 1000,
max: 300000
});
const readOnlyRpcMethods = new Set([
"system.health",
"cloud.adapter.describe",
@@ -366,8 +370,17 @@ function copyHeaders(headers) {
return result;
}
function parseTimeout(value, fallback, { min, max }) {
const parsed = Number.parseInt(value || "", 10);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function requestUpstream(target, request, body) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
upstream.destroy(new Error("cloud api proxy timed out after " + cloudApiProxyTimeoutMs + "ms"));
}, cloudApiProxyTimeoutMs);
const upstream = http.request(
target,
{
@@ -382,6 +395,7 @@ function requestUpstream(target, request, body) {
const chunks = [];
upstreamResponse.on("data", (chunk) => chunks.push(chunk));
upstreamResponse.on("end", () => {
clearTimeout(timeout);
resolve({
statusCode: upstreamResponse.statusCode || 502,
headers: upstreamResponse.headers,
@@ -390,7 +404,10 @@ function requestUpstream(target, request, body) {
});
}
);
upstream.on("error", reject);
upstream.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
if (body) upstream.write(body);
upstream.end();
});
@@ -5,6 +5,7 @@ import {
classifyCodeAgentBrowserJourney,
classifyLiveDeploymentIdentity,
classifyLiveWebAssetIdentity,
runDevCloudWorkbenchTimeoutFixtureSmoke,
sanitizeAgentChatBody
} from "./src/dev-cloud-workbench-smoke-lib.mjs";
import {
@@ -321,3 +322,27 @@ test("Code Agent browser classifier blocks completed payloads without backend ev
assert.equal(classification.status, "blocked");
assert.equal(classification.blocker, "untrusted-completion");
});
test("local Code Agent timeout fixture keeps failed UI state, trace evidence, and retry input", async () => {
const report = await runDevCloudWorkbenchTimeoutFixtureSmoke({
responseDelayMs: 120,
timeoutConfigMs: 50
});
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 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.failedMessageVisible, true);
assert.equal(timeoutCheck.observations.ui.failedMessageHasTrace, true);
assert.equal(timeoutCheck.observations.ui.failedMessageHasChineseTimeout, true);
assert.equal(timeoutCheck.observations.ui.retryInputPreserved, true);
assert.equal(timeoutCheck.observations.ui.completedMessageVisible, false);
});
+132 -50
View File
@@ -176,9 +176,19 @@ export function runDevCloudWorkbenchStaticSmoke() {
return runStaticSmoke();
}
export async function runDevCloudWorkbenchTimeoutFixtureSmoke(options = {}) {
return runLocalAgentFixtureSmoke({
mode: "local-agent-timeout-fixture-browser",
responseDelayMs: options.responseDelayMs ?? 120,
timeoutConfigMs: options.timeoutConfigMs ?? 50,
expectTimeout: true
});
}
export async function runDevCloudWorkbenchSmoke(argv = []) {
const args = parseSmokeArgs(argv);
if (args.mode === "live") return runLiveSmoke(args);
if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke();
if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke();
if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke();
return runStaticSmoke();
@@ -197,6 +207,8 @@ export function parseSmokeArgs(argv) {
args.mobile = true;
} else if (arg === "--local-agent-fixture") {
args.mode = "local-agent-fixture";
} else if (arg === "--local-agent-timeout-fixture") {
args.mode = "local-agent-timeout-fixture";
} else if (arg === "--url") {
index += 1;
if (!argv[index]) throw new Error("--url requires a value");
@@ -1269,6 +1281,12 @@ function hasCodeAgentConversationUxStates({ app, styles }) {
/function\s+messageEvidencePanel\s*\(/u.test(app) &&
/function\s+messageEvidenceSummary\s*\(/u.test(app) &&
/function\s+boundedEvidenceField\s*\(/u.test(app) &&
/DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) &&
/DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*150000/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) &&
/Code Agent SOURCE 回复/u.test(app) &&
/SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/u.test(app) &&
/sourceKind:\s*completion\.sourceKind/u.test(submitBody) &&
@@ -1949,26 +1967,35 @@ async function inspectJourneyControls(page) {
}
async function inspectJourneyUi(page) {
return page.evaluate(() => ({
title: document.title,
workspaceHidden: document.querySelector('[data-view="workspace"]')?.hidden ?? null,
gateHidden: document.querySelector('[data-view="gate"]')?.hidden ?? null,
helpHidden: document.querySelector('[data-view="help"]')?.hidden ?? null,
agentChatStatus: document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null,
inputCleared: document.querySelector("#command-input")?.value === "",
completedMessageVisible: Boolean(document.querySelector(".message-card.status-completed")),
sourceMessageVisible: Boolean(document.querySelector(".message-card.status-source")),
failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")),
messageCount: document.querySelectorAll(".message-card").length,
completedMessageHasNonSensitiveMeta: Boolean(
[...document.querySelectorAll(".message-card.status-completed .message-meta")]
.some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? ""))
),
sourceMessageHasNonSensitiveMeta: Boolean(
[...document.querySelectorAll(".message-card.status-source .message-meta")]
.some((element) => /SOURCE|source-fixture|trc_/u.test(element.textContent ?? ""))
)
}));
return page.evaluate(() => {
const failedText = [...document.querySelectorAll(".message-card.status-failed")]
.map((element) => element.textContent ?? "")
.join("\n");
const commandValue = document.querySelector("#command-input")?.value ?? "";
return {
title: document.title,
workspaceHidden: document.querySelector('[data-view="workspace"]')?.hidden ?? null,
gateHidden: document.querySelector('[data-view="gate"]')?.hidden ?? null,
helpHidden: document.querySelector('[data-view="help"]')?.hidden ?? null,
agentChatStatus: document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null,
inputCleared: commandValue === "",
retryInputPreserved: commandValue === "请用一句话说明当前 HWLAB 工作台可以做什么。",
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),
messageCount: document.querySelectorAll(".message-card").length,
completedMessageHasNonSensitiveMeta: Boolean(
[...document.querySelectorAll(".message-card.status-completed .message-meta")]
.some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? ""))
),
sourceMessageHasNonSensitiveMeta: Boolean(
[...document.querySelectorAll(".message-card.status-source .message-meta")]
.some((element) => /SOURCE|source-fixture|trc_/u.test(element.textContent ?? ""))
)
};
});
}
async function inspectDefaultApiStatus(page) {
@@ -2012,6 +2039,19 @@ function blockedAgentChatResponse(code) {
}
export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() {
return runLocalAgentFixtureSmoke({
mode: "local-agent-fixture-browser",
responseDelayMs: 0,
expectTimeout: false
});
}
async function runLocalAgentFixtureSmoke({
mode,
responseDelayMs = 0,
timeoutConfigMs = null,
expectTimeout = false
} = {}) {
let chromium;
try {
({ chromium } = await import("playwright"));
@@ -2019,7 +2059,7 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() {
return {
status: "skip",
task: "DC-DCSN-P0-2026-003",
mode: "local-agent-fixture-browser",
mode,
evidenceLevel: "SOURCE",
devLive: false,
summary: `Local Code Agent browser fixture smoke skipped because Playwright is unavailable: ${error.message}`,
@@ -2029,11 +2069,22 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() {
};
}
const server = await startStaticWebServer({ agentFixture: true });
const server = await startStaticWebServer({ agentFixture: true, agentDelayMs: responseDelayMs });
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
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);
}
const agentResponses = [];
page.on("response", async (response) => {
if (!response.url().includes("/v1/agent/chat")) return;
@@ -2061,30 +2112,50 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() {
const controls = await inspectJourneyControls(page);
const apiStatus = await inspectDefaultApiStatus(page);
await page.locator("#command-input").fill("请用一句话说明当前 HWLAB 工作台可以做什么。");
const responsePromise = page.waitForResponse(
const responsePromise = expectTimeout ? null : page.waitForResponse(
(response) => response.url().includes("/v1/agent/chat") && response.request().method() === "POST",
{ timeout: 15000 }
);
await page.locator("#command-send").click();
const response = await responsePromise;
const responseBody = await response.json().catch(() => null);
await page.waitForFunction(
() => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复",
null,
{ timeout: 8000 }
);
let response = null;
let responseBody = null;
if (expectTimeout) {
await page.waitForFunction(
() => document.querySelector("#agent-chat-status")?.textContent?.trim() === "发送失败",
null,
{ timeout: 8000 }
);
} else {
response = await responsePromise;
responseBody = await response.json().catch(() => null);
await page.waitForFunction(
() => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复",
null,
{ timeout: 8000 }
);
}
const ui = await inspectJourneyUi(page);
const responseSummary = sanitizeAgentChatBody(responseBody, { httpStatus: response.status() });
const pass =
response.ok() &&
responseSummary?.status === "completed" &&
responseSummary?.sourceKind === "SOURCE" &&
responseSummary?.hasReply === true &&
ui.agentChatStatus === "SOURCE 回复" &&
ui.sourceMessageVisible &&
ui.sourceMessageHasNonSensitiveMeta &&
!ui.completedMessageVisible &&
!ui.failedMessageVisible;
const responseSummary = response ? sanitizeAgentChatBody(responseBody, { httpStatus: response.status() }) : null;
const pass = expectTimeout
? (
ui.agentChatStatus === "发送失败" &&
ui.failedMessageVisible &&
ui.retryInputPreserved &&
ui.failedMessageHasTrace &&
ui.failedMessageHasChineseTimeout &&
!ui.completedMessageVisible
)
: (
response.ok() &&
responseSummary?.status === "completed" &&
responseSummary?.sourceKind === "SOURCE" &&
responseSummary?.hasReply === true &&
ui.agentChatStatus === "SOURCE 回复" &&
ui.sourceMessageVisible &&
ui.sourceMessageHasNonSensitiveMeta &&
!ui.completedMessageVisible &&
!ui.failedMessageVisible
);
const checks = [
{
id: "local-agent-fixture-controls",
@@ -2099,9 +2170,11 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() {
observations: apiStatus
},
{
id: "local-agent-fixture-replied-state",
id: expectTimeout ? "local-agent-timeout-fixture-failed-state" : "local-agent-fixture-replied-state",
status: pass ? "pass" : "blocked",
summary: "Local SOURCE fixture sends input and reaches a user-visible replied state without marking the fixture as DEV-LIVE.",
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."
: "Local SOURCE fixture sends input and reaches a user-visible replied state without marking the fixture as DEV-LIVE.",
observations: {
response: responseSummary,
ui,
@@ -2120,12 +2193,14 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() {
return {
status: blockers.length === 0 ? "pass" : "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "local-agent-fixture-browser",
mode,
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: "Local browser smoke uses a sanitized SOURCE fixture for /v1/agent/chat; it proves the UI conversation path only and does not claim DEV-LIVE.",
summary: expectTimeout
? "Local browser smoke uses a sanitized slow SOURCE fixture for /v1/agent/chat; it proves timeout UI classification only and does not claim DEV-LIVE."
: "Local browser smoke uses a sanitized SOURCE fixture for /v1/agent/chat; it proves the UI conversation path only and does not claim DEV-LIVE.",
checks,
blockers,
safety: {
@@ -2139,7 +2214,7 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() {
return {
status: "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "local-agent-fixture-browser",
mode,
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
@@ -2149,7 +2224,7 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() {
blockers: [
{
type: "observability_blocker",
scope: "local-agent-fixture-browser",
scope: mode,
status: "open",
summary: error.message
}
@@ -2336,7 +2411,7 @@ export async function runDevCloudWorkbenchMobileSmoke() {
async function startStaticWebServer(options = {}) {
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url })) {
if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) {
return;
}
let pathname = decodeURIComponent(url.pathname);
@@ -2369,7 +2444,7 @@ async function startStaticWebServer(options = {}) {
};
}
async function handleLocalAgentFixtureApi({ request, response, url }) {
async function handleLocalAgentFixtureApi({ request, response, url, options = {} }) {
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) {
jsonResponse(response, 200, {
serviceId: "hwlab-cloud-api",
@@ -2418,6 +2493,7 @@ async function handleLocalAgentFixtureApi({ request, response, url }) {
}
if (request.method === "POST" && url.pathname === "/v1/agent/chat") {
const body = await readJsonBody(request);
await delay(options.agentDelayMs ?? 0);
const timestamp = "2026-05-22T00:00:00.000Z";
const conversationId = stringOrFallback(body?.conversationId, "cnv_source_fixture_browser");
jsonResponse(response, 200, {
@@ -2453,6 +2529,11 @@ async function handleLocalAgentFixtureApi({ request, response, url }) {
return false;
}
function delay(ms) {
const normalized = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : 0;
return new Promise((resolve) => setTimeout(resolve, normalized));
}
function localSourceFixtureAvailability() {
return {
endpoint: "POST /v1/agent/chat",
@@ -2781,11 +2862,12 @@ function escapeRegExp(value) {
export function printSmokeHelp() {
return {
status: "usage",
command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --mobile | --local-agent-fixture | --live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]",
command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --mobile | --local-agent-fixture | --local-agent-timeout-fixture | --live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]",
notes: [
"Static mode reads repository files and emits SOURCE-level evidence only.",
"--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.",
"--local-agent-fixture runs a local browser send/reply path with a sanitized SOURCE fixture and does not claim DEV-LIVE.",
"--local-agent-timeout-fixture runs a local slow SOURCE fixture to verify bounded timeout UI classification and retry preservation without live acceptance.",
"Live mode opens the deployed workbench in a browser, verifies core controls, sends one Code Agent message, and retains only non-sensitive response fields."
]
};
+34 -8
View File
@@ -2,7 +2,10 @@ import { gateSummary } from "./gate-summary.mjs";
import { runtime } from "./runtime.mjs";
import { marked } from "./third_party/marked/marked.esm.js";
const API_TIMEOUT_MS = 4500;
const DEFAULT_API_TIMEOUT_MS = 4500;
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 150000;
const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 });
const CODE_AGENT_TIMEOUT_MS = resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min: 50, max: 300000 });
const rpcReadMethods = Object.freeze([
"system.health",
"cloud.adapter.describe",
@@ -140,6 +143,13 @@ function query(selector) {
return element;
}
function resolveTimeoutMs(name, fallback, { min, max }) {
const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {};
const configured = Number(config?.[name]);
if (!Number.isFinite(configured)) return fallback;
return Math.min(Math.max(Math.trunc(configured), min), max);
}
function initRoutes() {
window.addEventListener("hashchange", () => showView(routeFromLocation()));
for (const button of document.querySelectorAll("[data-route]")) {
@@ -282,7 +292,7 @@ function initCommandBar() {
id: nextProtocolId("msg"),
role: "agent",
title: "Code Agent 处理中",
text: "正在调用真实 Code Agent / Codex 后端,请稍候。",
text: `正在调用真实 Code Agent / Codex 后端,复杂问题可能需要 1-2 分钟;超时上限 ${Math.round(CODE_AGENT_TIMEOUT_MS / 1000)} 秒,失败时会保留输入供重试。`,
status: "running",
traceId,
createdAt: new Date().toISOString()
@@ -344,7 +354,9 @@ function initCommandBar() {
traceId: error.traceId || traceId,
updatedAt: new Date().toISOString(),
error: {
message: error.message
code: error.code || "request_failed",
message: error.message,
timeoutMs: error.timeoutMs
}
};
el.commandInput.value = value;
@@ -413,6 +425,8 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
"Content-Type": "application/json",
"X-Trace-Id": traceId
},
timeoutMs: CODE_AGENT_TIMEOUT_MS,
timeoutName: "Code Agent",
body: JSON.stringify({
message,
conversationId,
@@ -425,8 +439,12 @@ 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.error || "Code Agent 请求失败");
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;
throw error;
}
return {
@@ -460,14 +478,19 @@ async function loadLiveSurface() {
}
async function fetchJson(path, options = {}) {
const {
timeoutMs = API_TIMEOUT_MS,
timeoutName = path,
...fetchOptions
} = options;
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(path, {
...options,
...fetchOptions,
headers: {
Accept: "application/json",
...(options.headers ?? {})
...(fetchOptions.headers ?? {})
},
signal: controller.signal
});
@@ -496,10 +519,13 @@ async function fetchJson(path, options = {}) {
}
return { ok: true, path, status: response.status, data };
} catch (error) {
const timedOut = error.name === "AbortError";
return {
ok: false,
path,
error: error.name === "AbortError" ? `${path} 请求超时 ${API_TIMEOUT_MS}ms` : `请求失败:${error.message}`
timeout: timedOut,
timeoutMs,
error: timedOut ? `${timeoutName} 请求超时 ${timeoutMs}ms` : `请求失败:${error.message}`
};
} finally {
window.clearTimeout(timer);
+11
View File
@@ -405,6 +405,13 @@ assert.match(app, /messageId/);
assert.match(app, /createdAt/);
assert.match(app, /updatedAt/);
assert.match(app, /error\?\.message/);
assert.match(app, /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/);
assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*150000/);
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, /Code Agent 请求超过/);
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);
@@ -429,6 +436,10 @@ assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/);
assert.match(app, /callRpc\("audit\.event\.query"/);
assert.match(app, /callRpc\("evidence\.record\.query"/);
assert.match(artifactPublisher, /HWLAB_API_BASE_URL/);
assert.match(artifactPublisher, /HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS/);
assert.match(artifactPublisher, /cloudApiProxyTimeoutMs/);
assert.match(artifactPublisher, /setTimeout\(\(\) => \{/);
assert.match(artifactPublisher, /cloud api proxy timed out after/);
assert.match(artifactPublisher, /readOnlyRpcMethods/);
assert.match(artifactPublisher, /requestUpstream/);
assert.match(artifactPublisher, /from "node:http"/);