From 903a733b2eeff406a9b9fb2e25806c56dd86d5a9 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 15:24:06 +0000 Subject: [PATCH] test: cover Code Agent slow timeout paths --- internal/cloud/code-agent-chat.mjs | 17 +++- .../code-agent-session-registry.test.mjs | 97 +++++++++++++++++++ internal/cloud/server.mjs | 1 + internal/cloud/server.test.mjs | 73 ++++++++++++++ .../scripts/m3-io-skill-client.test.mjs | 85 ++++++++++++++++ 5 files changed, 269 insertions(+), 4 deletions(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index e2cd8f1b..59beccca 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -154,6 +154,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { workspace: options.workspace, skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, + skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, sessionRegistry: options.sessionRegistry, codexStdioManager: options.codexStdioManager }); @@ -188,6 +189,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { workspace: options.workspace, skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, + skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, sessionRegistry: options.sessionRegistry, codexStdioManager: options.codexStdioManager }); @@ -318,7 +320,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate; if (error.availability !== undefined) { payload.availability = error.availability; - } else if (error.code === "provider_unavailable") { + } else if (["provider_unavailable", "provider_timeout"].includes(error.code)) { payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response"].includes(error.code)) { payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); @@ -779,7 +781,7 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI } } -async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, sessionRegistry, codexStdioManager }) { +async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, skillsDiscoveryDelayMs, sessionRegistry, codexStdioManager }) { const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); const sessionAcquire = registry.acquire({ @@ -1028,7 +1030,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, } if (intent.kind === "skills") { - const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId }); + const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId, delayMs: skillsDiscoveryDelayMs }); if (skills.status === "blocked") { session = registry.fail(session.sessionId, { now, @@ -1830,7 +1832,8 @@ function assertReadOnlyToolCompleted(toolCall, { workspace, skills, runner, runn }); } -async function discoverSkills({ env, skillsDirs, skillsDirsExact, traceId }) { +async function discoverSkills({ env, skillsDirs, skillsDirsExact, traceId, delayMs = 0 }) { + await maybeDelayForTest(delayMs); const checkedDirs = resolveSkillDirs(env, { skillsDirs, skillsDirsExact }); const sourceSummaries = []; const items = []; @@ -2559,6 +2562,12 @@ function runnerCommandEnv(env = process.env) { }; } +async function maybeDelayForTest(value) { + const delayMs = Number.parseInt(value ?? "", 10); + if (!Number.isInteger(delayMs) || delayMs <= 0) return; + await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, 10000))); +} + function boundToolOutput(value, maxLength = READONLY_TOOL_OUTPUT_LIMIT) { const text = String(value ?? ""); if (text.length <= maxLength) return { text, truncated: false }; diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 944f10c6..6f306512 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -563,6 +563,99 @@ test("Code Agent M3 Skill CLI missing API base returns structured config blocker assert.equal(JSON.stringify(payload).includes("://"), false); }); +test("Code Agent M3 Skill CLI preserves slow structured blocker beyond legacy 4500ms window", async () => { + const startedAt = Date.now(); + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_m3_skill_slow_blocker", + traceId: "trc_m3_skill_slow_blocker", + message: "通过 HWLAB API 读取 M3 DI1 并返回结构化 blocker" + }, + { + now: () => "2026-05-23T00:06:30.000Z", + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + OPENAI_API_KEY: "must-not-be-used" + }, + callProvider: async () => { + throw new Error("OpenAI fallback must not be used for M3 IO blockers"); + }, + m3IoSkillRequestJson: async (url, request) => { + assert.equal(new URL(url).pathname, HWLAB_M3_IO_API_ROUTE); + assert.equal(request.timeoutMs, 30000); + await delay(4600); + return { + ok: true, + status: 200, + body: { + serviceId: "hwlab-cloud-api", + contractVersion: "m3-io-control-v1", + status: "blocked", + accepted: false, + action: "di.read", + traceId: "trc_m3_skill_slow_blocker", + operationId: "op_m3_di_read_slow_blocker", + auditId: "aud_m3_di_read_slow_blocker_failed", + evidenceId: "evd_m3_di_read_slow_blocker_failed", + blocker: { + code: "runtime_durable_not_green", + message: "runtime durable evidence is not green", + zh: "runtime durable evidence 尚未为 green" + }, + blockerClassification: { + category: "runtime_durability", + reason: "durable evidence is blocked" + }, + durableStatus: { + status: "degraded", + durable: false, + blocker: "runtime_durable_not_green" + }, + evidenceState: { + status: "blocked", + sourceKind: "BLOCKED", + blocker: "runtime_durable_not_green", + writeStatus: "not_written" + }, + controlPath: { + cloudApi: true, + gatewaySimu: false, + boxSimu: false, + patchPanel: false, + frontendBypass: false + } + } + }; + } + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(Date.now() - startedAt >= 4500, true); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "hwlab-skill-cli"); + assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); + assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); + assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); + assert.equal(payload.blocker.code, "m3_readiness_blocked"); + assert.equal(payload.blocker.layer, "m3-readiness"); + assert.equal(payload.blocker.retryable, false); + assert.equal(payload.toolCalls[0].status, "blocked"); + assert.equal(payload.toolCalls[0].blocker.code, "runtime_durable_not_green"); + assert.equal(payload.toolCalls[0].traceId, "trc_m3_skill_slow_blocker"); + assert.equal(payload.skills.status, "used"); + assert.equal(payload.skills.blockers[0].code, "runtime_durable_not_green"); + assert.equal(payload.runnerTrace.traceId, "trc_m3_skill_slow_blocker"); + assert.equal(payload.runnerTrace.blocker.code, "runtime_durable_not_green"); + assert.equal(payload.providerTrace.fallbackUsed, false); + assert.equal(payload.providerTrace.traceId, "trc_m3_skill_slow_blocker"); + assert.equal(Object.hasOwn(payload, "error"), false); + assert.match(payload.reply.content, /Blocker: runtime_durable_not_green/u); +}); + test("Code Agent M3 Skill CLI HWLAB API unavailable returns retryable structured blocker", async () => { const payload = await handleCodeAgentChat( { @@ -728,6 +821,10 @@ test("Code Agent blocks direct gateway or patch-panel requests instead of using assert.match(direct.error.message, /Skill CLI -> HWLAB API \/v1\/m3\/io/u); }); +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL before loopback fallback", async () => { const payload = await handleCodeAgentChat( { diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 26800cb1..8dd75d0c 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -336,6 +336,7 @@ async function handleCodeAgentChatHttp(request, response, options) { workspace: options.workspace, skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, + skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, sessionRegistry: options.sessionRegistry, m3IoSkillRequestJson: options.m3IoSkillRequestJson, codexStdioManager: options.codexStdioManager diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index fe1e0165..4b3cd033 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1351,6 +1351,73 @@ test("cloud api /v1/agent/chat discovers skills manifest with source and version } }); +test("cloud api /v1/agent/chat keeps slow skills discovery running past legacy 4500ms timeout", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-slow-skills-")); + const workspace = path.join(root, "workspace"); + const skillsDir = path.join(root, "skills"); + await mkdir(workspace, { recursive: true }); + await mkdir(path.join(skillsDir, "slow-skill"), { recursive: true }); + await writeFile(path.join(skillsDir, "slow-skill", "SKILL.md"), [ + "---", + "name: slow-skill", + "description: Slow skill discovery summary.", + "version: 2026.05.23", + "commit: slow12345678", + "---", + "", + "# Slow" + ].join("\n")); + + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: workspace + }, + skillsDirs: [skillsDir], + skillsDirsExact: true, + skillsDiscoveryDelayMs: 4600 + }); + 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-slow-skills" + }, + body: JSON.stringify({ + conversationId: "cnv_server-test-slow-skills", + message: "请列出你可用的所有 skills" + }) + }); + 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.provider, "codex-readonly-runner"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-readonly-runner"); + assert.equal(payload.traceId, "trc_server-test-slow-skills"); + assert.equal(payload.capabilityLevel, "read-only-session-tools"); + assert.equal(payload.sessionMode, "controlled-readonly-session-registry"); + assert.equal(payload.toolCalls[0].name, "skills.discover"); + assert.equal(payload.toolCalls[0].status, "completed"); + assert.equal(payload.skills.status, "ready"); + assert.equal(payload.skills.items[0].name, "slow-skill"); + assert.equal(payload.skills.items[0].traceId, "trc_server-test-slow-skills"); + assert.equal(payload.runner.kind, "hwlab-readonly-runner"); + assert.equal(payload.runnerTrace.traceId, "trc_server-test-slow-skills"); + assert.ok(payload.runnerTrace.events.includes("tool:skills.discover:completed")); + 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 structured skills_unavailable blocker", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-no-skills-")); const skillsDir = path.join(root, "missing-skills"); @@ -1629,6 +1696,8 @@ test("cloud api /v1/agent/chat keeps delayed provider failure structured beyond assert.equal(payload.error.code, "provider_unavailable"); assert.equal(payload.error.providerStatus, 503); assert.match(payload.error.message, /HTTP 503/u); + assert.equal(payload.backend, "hwlab-cloud-api/openai-responses"); + assert.equal(payload.availability.fallback.backend, "hwlab-cloud-api/openai-responses"); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { @@ -1681,6 +1750,10 @@ test("cloud api /v1/agent/chat reports provider timeout as failed without a repl assert.equal(payload.error.retryable, true); assert.match(payload.error.userMessage, /超时/u); assert.match(payload.error.message, /timed out after 50ms/u); + assert.equal(payload.provider, "openai-responses"); + assert.equal(payload.backend, "hwlab-cloud-api/openai-responses"); + assert.equal(payload.availability.endpoint, "POST /v1/agent/chat"); + assert.equal(payload.availability.runner.kind, "hwlab-readonly-runner"); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { diff --git a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs index 1e7d8178..9faeb4a9 100644 --- a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs +++ b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs @@ -226,3 +226,88 @@ test("M3 Skill CLI reports HWLAB API unavailable as retryable structured blocker assert.equal(result.error.route, HWLAB_M3_IO_API_ROUTE); assert.equal(result.error.toolName, "hwlab-agent-runtime.m3-io"); }); + +test("M3 Skill CLI preserves slow structured blocker and timeout budget", async () => { + const startedAt = Date.now(); + const result = await runM3IoSkillCommand( + [ + "m3", + "io", + "--action", + "di.read", + "--api-base-url", + "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + "--trace-id", + "trc_skill_cli_slow_blocker", + "--request-id", + "req_skill_cli_slow_blocker", + "--timeout-ms", + "30000" + ], + { + now: () => "2026-05-23T00:09:00.000Z", + requestJson: async (url, request) => { + assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`); + assert.equal(request.timeoutMs, 30000); + await delay(4600); + return { + ok: true, + status: 200, + body: { + status: "blocked", + accepted: false, + traceId: "trc_skill_cli_slow_blocker", + operationId: "op_skill_cli_slow_blocker", + auditId: "aud_skill_cli_slow_blocker_failed", + evidenceId: "evd_skill_cli_slow_blocker_failed", + blocker: { + code: "runtime_durable_not_green", + message: "runtime durable evidence is not green", + zh: "runtime durable evidence 尚未为 green" + }, + blockerClassification: { + category: "runtime_durability" + }, + evidenceState: { + status: "blocked", + sourceKind: "BLOCKED", + blocker: "runtime_durable_not_green", + writeStatus: "not_written" + }, + durableStatus: { + status: "degraded", + durable: false, + blocker: "runtime_durable_not_green" + }, + controlPath: { + cloudApi: true, + frontendBypass: false + } + } + }; + } + } + ); + + assert.equal(Date.now() - startedAt >= 4500, true); + assert.equal(result.ok, false); + assert.equal(result.status, "blocked"); + assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(result.traceId, "trc_skill_cli_slow_blocker"); + assert.equal(result.requestId, "req_skill_cli_slow_blocker"); + assert.equal(result.operationId, "op_skill_cli_slow_blocker"); + assert.equal(result.blocker.code, "runtime_durable_not_green"); + assert.equal(result.blocker.category, "runtime_durability"); + assert.equal(result.capabilityBlocker.code, "runtime_durable_not_green"); + assert.equal(result.trustBlocker.code, "runtime_durable_not_green"); + assert.equal(result.blockers.some((blocker) => blocker.code === "runtime_durable_not_green"), true); + assert.equal(result.audit.auditId, "aud_skill_cli_slow_blocker_failed"); + assert.equal(result.evidence.evidenceId, "evd_skill_cli_slow_blocker_failed"); + assert.equal(result.durable.blocker, "runtime_durable_not_green"); + assert.equal(result.safety.fallbackUsed, false); + assert.equal(result.safety.directGatewayCalls, false); +}); + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +}