fix: extend code agent heavy request timeout
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
Reference in New Issue
Block a user