test: cover Code Agent slow timeout paths
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user