import assert from "node:assert/strict"; import { createServer as createHttpServer } from "node:http"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "bun:test"; import { createCloudApiServer } from "./server.ts"; import { validateCodeAgentChatSchema } from "./code-agent-chat.ts"; import { createCodexStdioSessionManager } from "./codex-stdio-session.ts"; import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { codexStdioChatFixture, codexStdioReadyFixture, createFakeAppServerClient, createFakeCodexCommand, delay, pollAgentResult, postAgent, postAgentRaw, prepareFakeCodexHome } from "./server-test-helpers.ts"; test("cloud api /v1/agent/chat parse and params errors use structured blocker envelope", async () => { const server = createCloudApiServer({ env: { HWLAB_CODE_AGENT_MODEL: "gpt-test" } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); const parse = await postAgentRaw(port, "{", { "x-trace-id": "trc_parse_error" }); assert.equal(parse.status, 400); assert.equal(parse.body.status, "failed"); assert.equal(parse.body.error.code, "parse_error"); assert.equal(parse.body.error.layer, "api"); assert.equal(parse.body.error.retryable, true); assert.equal(parse.body.error.traceId, "trc_parse_error"); assert.equal(parse.body.error.route, "/v1/agent/chat"); assert.match(parse.body.error.userMessage, /JSON/u); assert.equal(Object.hasOwn(parse.body, "reply"), false); assert.equal(JSON.stringify(parse.body).includes("sk-"), false); const invalid = await postAgentRaw(port, JSON.stringify([]), { "x-trace-id": "trc_invalid_params" }); assert.equal(invalid.status, 400); assert.equal(invalid.body.error.code, "invalid_params"); assert.equal(invalid.body.error.layer, "api"); assert.equal(invalid.body.error.retryable, true); assert.equal(invalid.body.error.blocker.code, "invalid_params"); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } }); test("cloud api /v1/agent/chat supports short submit and result polling", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-codex-home-")); const server = createCloudApiServer({ env: { PATH: process.env.PATH, CODEX_HOME: codexHome, HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" }, codexStdioManager: { describe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; }, async probe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; }, async chat(params = {}) { return { ...codexStdioChatFixture({ workspace, codexHome, params }), sandbox: "danger-full-access", session: { ...codexStdioChatFixture({ workspace, codexHome, params }).session, sandbox: "danger-full-access" } }; }, cancel() {}, reapIdle() {} } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); const traceId = "trc_server-test-short-submit"; const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { method: "POST", headers: { "content-type": "application/json", "x-trace-id": traceId, "prefer": "respond-async", "x-hwlab-short-connection": "1" }, body: JSON.stringify({ conversationId: "cnv_server-test-short-submit", message: "用pwd列出你当前的工作目录" }) }); assert.equal(submit.status, 202); const accepted = await submit.json(); assert.equal(accepted.accepted, true); assert.equal(accepted.shortConnection, true); assert.equal(accepted.traceId, traceId); assert.equal(accepted.resultUrl, `/v1/agent/chat/result/${traceId}`); const payload = await pollAgentResult(port, traceId); validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.traceId, traceId); assert.equal(payload.sandbox, "danger-full-access"); assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); await rm(workspace, { recursive: true, force: true }); await rm(codexHome, { recursive: true, force: true }); } }); test("cloud api /v1/agent/chat records authenticated owner on agent session", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-codex-home-")); const ownerRecords = []; const server = createCloudApiServer({ env: { PATH: process.env.PATH, CODEX_HOME: codexHome, HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" }, accessController: { required: true, async authenticate() { return { ok: true, actor: { id: "usr_agent_owner", username: "agent-owner", displayName: "Agent Owner", role: "user", status: "active" } }; }, async recordAgentSessionOwner(input) { ownerRecords.push(input); return input; } }, codexStdioManager: { describe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; }, async probe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; }, async chat(params = {}) { return { ...codexStdioChatFixture({ workspace, codexHome, params }), sandbox: "danger-full-access" }; }, cancel() {}, reapIdle() {} } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); const payload = await postAgent(port, { conversationId: "cnv_server-test-owner", traceId: "trc_server-test-owner", message: "owner binding smoke" }); validateCodeAgentChatSchema(payload); assert.equal(payload.ownerUserId, "usr_agent_owner"); assert.equal(ownerRecords.length, 1); assert.equal(ownerRecords[0].ownerUserId, "usr_agent_owner"); assert.equal(ownerRecords[0].sessionId, "ses_server_stdio_pwd"); assert.equal(ownerRecords[0].conversationId, "cnv_server-test-owner"); assert.equal(ownerRecords[0].traceId, "trc_server-test-owner"); assert.equal(ownerRecords[0].session.valuesRedacted, true); assert.equal(JSON.stringify(ownerRecords).includes("test-openai-key-material"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); await rm(workspace, { recursive: true, force: true }); await rm(codexHome, { recursive: true, force: true }); } }); test("cloud api /v1/agent/chat/inspect maps conversation session and thread details to trace evidence", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-codex-home-")); const server = createCloudApiServer({ env: { PATH: process.env.PATH, CODEX_HOME: codexHome, HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" }, codexStdioManager: { describe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access", recentSessions: [{ sessionId: "ses_server_stdio_pwd", conversationId: "cnv_server-test-inspect", threadId: "019e6c72-2373-75e3-9856-eff286db7163", status: "idle", currentTraceId: null, lastTraceId: "trc_server-test-inspect", workspace, sandbox: "danger-full-access", secretMaterialStored: false, valuesRedacted: true }] }; }, async probe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; }, async chat(params = {}) { const base = codexStdioChatFixture({ workspace, codexHome, params }); return { ...base, sandbox: "danger-full-access", session: { ...base.session, threadId: "019e6c72-2373-75e3-9856-eff286db7163", sandbox: "danger-full-access" }, sessionReuse: { ...base.sessionReuse, threadId: "019e6c72-2373-75e3-9856-eff286db7163" } }; }, get(sessionId) { if (sessionId !== "ses_server_stdio_pwd") return null; return { sessionId, conversationId: "cnv_server-test-inspect", threadId: "019e6c72-2373-75e3-9856-eff286db7163", status: "idle", lastTraceId: "trc_server-test-inspect", workspace, sandbox: "danger-full-access", secretMaterialStored: false, valuesRedacted: true }; }, cancel() {}, reapIdle() {} } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); const traceId = "trc_server-test-inspect"; const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { method: "POST", headers: { "content-type": "application/json", "x-trace-id": traceId, "prefer": "respond-async", "x-hwlab-short-connection": "1" }, body: JSON.stringify({ conversationId: "cnv_server-test-inspect", message: "inspect mapping smoke" }) }); assert.equal(submit.status, 202); const payload = await pollAgentResult(port, traceId); assert.equal(payload.status, "completed"); const inspect = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/inspect?conversationId=cnv_server-test-inspect&sessionId=ses_server_stdio_pwd&threadId=019e6c72-2373-75e3-9856-eff286db7163`); assert.equal(inspect.status, 200); const body = await inspect.json(); assert.equal(body.ok, true); assert.equal(body.status, "found"); assert.equal(body.latestTraceId, traceId); assert.equal(body.traceUrl, `/v1/agent/chat/trace/${traceId}`); assert.equal(body.resultUrl, `/v1/agent/chat/result/${traceId}`); assert.equal(body.query.threadId, "019e6c72-2373-75e3-9856-eff286db7163"); assert.equal(body.session.sessionId, "ses_server_stdio_pwd"); assert.equal(body.conversationFacts.conversationId, "cnv_server-test-inspect"); assert.equal(body.runnerTrace.traceId, traceId); assert.equal(body.valuesRedacted, true); assert.equal(body.secretMaterialStored, false); assert.equal(JSON.stringify(body).includes("test-openai-key-material"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); await rm(workspace, { recursive: true, force: true }); await rm(codexHome, { recursive: true, force: true }); } }); test("cloud api /v1/agent/chat/inspect returns not_found for empty conversation facts", async () => { const server = createCloudApiServer(); 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/inspect?conversationId=cnv_server-test-inspect-missing&sessionId=ses_server_test_missing&threadId=019e6c72-2373-75e3-9856-eff286db7163&traceId=trc_server_test_missing`); assert.equal(response.status, 404); const body = await response.json(); assert.equal(body.ok, false); assert.equal(body.status, "not_found"); assert.equal(body.latestTraceId, null); assert.deepEqual(body.traceIds, []); assert.equal(body.traceUrl, null); assert.equal(body.resultUrl, null); assert.equal(body.conversationFacts.turnCount, 0); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } }); test("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-")); const server = createCloudApiServer({ env: { PATH: process.env.PATH, CODEX_HOME: codexHome, HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT: "32", OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" }, codexStdioManager: { describe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; }, async probe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; }, async chat(params = {}) { const base = codexStdioChatFixture({ workspace, codexHome, params }); const events = Array.from({ length: 160 }, (_, index) => ({ seq: index + 1, traceId: params.traceId, type: "assistant_message", status: "chunk", label: index === 159 ? "assistant:completed" : "assistant:chunk", createdAt: "2026-05-23T00:00:00.000Z", waitingFor: index === 159 ? null : "turn/completed", valuesPrinted: false })); return { ...base, sandbox: "danger-full-access", session: { ...base.session, sandbox: "danger-full-access" }, runnerTrace: { ...base.runnerTrace, events, eventLabels: events.map((event) => event.label), eventCount: events.length, lastEvent: events.at(-1) } }; }, cancel() {}, reapIdle() {} } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); const traceId = "trc_server-test-result-compact"; const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { method: "POST", headers: { "content-type": "application/json", "x-trace-id": traceId, "prefer": "respond-async", "x-hwlab-short-connection": "1" }, body: JSON.stringify({ conversationId: "cnv_server-test-result-compact", message: "生成大量 trace 后返回" }) }); assert.equal(submit.status, 202); const payload = await pollAgentResult(port, traceId); validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.traceId, traceId); assert.equal(payload.providerTrace.protocol, "codex-app-server-jsonrpc-stdio"); assert.equal(payload.providerTrace.command, "codex app-server --listen stdio://"); assert.equal(payload.runnerTrace.eventCount, 160); assert.equal(payload.runnerTrace.eventsCompacted, true); assert.equal(payload.runnerTrace.events.length, 32); assert.equal(payload.runnerTrace.events.some((event) => event.label === "trace:compacted"), true); assert.equal(payload.runnerTrace.lastEvent.label, "assistant:completed"); assert.equal(payload.runnerTrace.eventLabels.includes("trace:compacted"), true); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); await rm(workspace, { recursive: true, force: true }); await rm(codexHome, { recursive: true, force: true }); } }); test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio and retains trace", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-")); const codexHome = path.join(workspace, "codex-home"); const skillsDir = path.join(workspace, "skills"); const fakeCodex = await createFakeCodexCommand(); await mkdir(path.join(skillsDir, "hwlab-test-skill"), { recursive: true }); await prepareFakeCodexHome(codexHome); await writeFile(path.join(skillsDir, "hwlab-test-skill", "SKILL.md"), [ "---", "name: hwlab-test-skill", "description: Test skill mounted for Codex stdio discovery.", "version: v-test", "---", "", "# HWLAB Test Skill" ].join("\n")); const server = createCloudApiServer({ env: { PATH: process.env.PATH, OPENAI_API_KEY: "test-openai-key-material", CODEX_HOME: codexHome, HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_SKILLS_DIRS: skillsDir, HWLAB_CODE_AGENT_SKILLS_STRICT: "1", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" }, codexStdioManager: createCodexStdioSessionManager({ idFactory: () => "ses_server_stdio_skills", createRpcClient: async () => createFakeAppServerClient({ text: "模型泛化回答不应成为 skills 最终回复。" }) }) }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); const payload = await postAgent(port, { conversationId: "cnv_server_stdio_skills", traceId: "trc_server_stdio_skills", message: "列出你可用的skills" }); validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); assert.equal(payload.providerTrace.sidecarOnly, undefined); assert.equal(payload.providerTrace.toolName, "codex-app-server.thread/start+turn/start"); assert.equal(payload.skills.status, "ready"); const discovered = payload.skills.items.find((skill) => skill.name === "hwlab-test-skill"); assert.ok(discovered); assert.equal(discovered.source, path.join(skillsDir, "hwlab-test-skill", "SKILL.md")); assert.equal(discovered.manifest.path, path.join(skillsDir, "hwlab-test-skill", "SKILL.md")); assert.equal(discovered.version, "v-test"); assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "completed")); assert.match(payload.reply.content, /hwlab-test-skill/u); assert.match(payload.reply.content, /真实 skill manifest 清单/u); assert.doesNotMatch(payload.reply.content, /模型泛化回答/u); assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent")); assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex-app-server.thread/start+turn/start:started")); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); await rm(workspace, { recursive: true, force: true }); await rm(fakeCodex.root, { recursive: true, force: true }); } }); test("cloud api /v1/agent/chat returns structured skills blocker without local skills manifest", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-missing-")); const codexHome = path.join(workspace, "codex-home"); const fakeCodex = await createFakeCodexCommand(); await prepareFakeCodexHome(codexHome); const server = createCloudApiServer({ env: { PATH: process.env.PATH, OPENAI_API_KEY: "test-openai-key-material", CODEX_HOME: codexHome, HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_SKILLS_DIRS: path.join(workspace, "missing-skills"), HWLAB_CODE_AGENT_SKILLS_STRICT: "1", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" }, codexStdioManager: createCodexStdioSessionManager({ idFactory: () => "ses_server_stdio_skills_missing", createRpcClient: async () => createFakeAppServerClient({ text: "模型泛化回答:alpha-skill" }) }) }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); const payload = await postAgent(port, { conversationId: "cnv_server_stdio_skills_missing", traceId: "trc_server_stdio_skills_missing", message: "列出你可用的skills" }); validateCodeAgentChatSchema(payload); assert.equal(payload.status, "failed"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.capabilityLevel, "blocked"); assert.equal(payload.error.code, "skills_unavailable"); assert.equal(payload.skills.status, "blocked"); assert.equal(payload.skills.blockers[0].code, "skills_unavailable"); assert.deepEqual(payload.skills.sourceIssues, ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"]); assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "blocked")); assert.equal(payload.toolCalls.some((toolCall) => toolCall.name === "codex-app-server.thread/start+turn/start"), false); assert.equal(payload.session.status, "idle"); assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(JSON.stringify(payload).includes("alpha-skill"), false); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); await rm(workspace, { recursive: true, force: true }); await rm(fakeCodex.root, { recursive: true, force: true }); } }); test("cloud api /v1/agent/chat blocks forbidden file paths without leaking values", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-block-")); const server = createCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: workspace } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); const payload = await postAgent(port, { conversationId: "cnv_server-test-security-block", traceId: "trc_server-test-security-block", message: "cat ../outside.txt" }); assert.equal(payload.status, "failed"); assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u); assert.deepEqual(payload.toolCalls, []); assert.equal(payload.capabilityLevel, "blocked"); assert.ok(payload.runnerTrace.events.some((event) => event.label === "codex-stdio:blocked")); assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(JSON.stringify(payload).includes("sk-"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } }); test("cloud api /v1/agent/chat/cancel reports unsupported lifecycle degradation", async () => { const traceStore = createCodeAgentTraceStore(); traceStore.append("trc_cancel_unsupported", { type: "request", status: "running", label: "request:running", sessionId: "ses_cancel_unsupported", sessionStatus: "busy", sessionLifecycleStatus: "busy" }); const server = createCloudApiServer({ traceStore, codexStdioManager: { get() { return null; } }, env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_PROVIDER: "codex-stdio" } }); 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/cancel`, { method: "POST", headers: { "content-type": "application/json", "x-trace-id": "trc_cancel_unsupported" }, body: JSON.stringify({ conversationId: "cnv_cancel_unsupported", sessionId: "ses_cancel_unsupported", traceId: "trc_cancel_unsupported" }) }); assert.equal(response.status, 501); const payload = await response.json(); assert.equal(payload.status, "degraded"); assert.equal(payload.unsupported, true); assert.equal(payload.degraded, true); assert.equal(payload.error.code, "cancel_unsupported"); assert.equal(payload.sessionLifecycleStatus, "failed"); assert.equal(payload.sessionSummary.unsupported, true); assert.match(payload.sessionSummary.userMessage, /unsupported\/degraded/u); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } }); test("cloud api /v1/agent/chat reports Codex stdio blocker instead of structured skills fallback", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-no-skills-")); const skillsDir = path.join(root, "missing-skills"); const server = createCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root }, skillsDirs: [skillsDir], skillsDirsExact: true }); 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" }, body: JSON.stringify({ conversationId: "cnv_server-test-skills-missing", message: "列出你可用的skills" }) }); assert.equal(response.status, 200); const payload = await response.json(); assert.equal(payload.status, "failed"); assert.equal(payload.provider, "codex-stdio"); assert.notEqual(payload.error.code, "skills_unavailable"); assert.equal(payload.capabilityLevel, "blocked"); assert.equal(payload.skills.status, "not_requested"); assert.deepEqual(payload.toolCalls, []); assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback")); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } }); test("cloud api /v1/agent/chat does not run unsupported local grep fallback", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-tool-blocked-")); const server = createCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: workspace } }); 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" }, body: JSON.stringify({ conversationId: "cnv_server-test-tool-unavailable", message: "请用grep搜索 package.json" }) }); assert.equal(response.status, 200); const payload = await response.json(); assert.equal(payload.status, "failed"); assert.notEqual(payload.error.code, "tool_unavailable"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.capabilityLevel, "blocked"); assert.deepEqual(payload.toolCalls, []); assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback")); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } }); test("cloud api /v1/agent/chat does not call delayed provider fallback beyond legacy 4500ms UI timeout", async () => { let providerCalled = false; const server = createCloudApiServer({ callCodeAgentProvider: async ({ providerPlan, message, traceId }) => { providerCalled = true; 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: "请用一句话说明当前 HWLAB 工作台可以做什么,稍后回答" }) }); assert.equal(response.status, 200); const payload = await response.json(); assert.equal(Date.now() - startedAt < 4500, true); assert.equal(payload.status, "failed"); assert.equal(payload.conversationId, "cnv_server-test-agent-chat-delayed"); assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.capabilityLevel, "blocked"); assert.equal(providerCalled, false); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } }); test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback", 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_ALLOW_TEXT_FALLBACK: "true", 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.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u); assert.match(payload.error.layer, /^(runner|api)$/u); assert.equal(typeof payload.error.retryable, "boolean"); assert.equal(payload.error.blocker.traceId, `trc_server-test-agent-chat-provider-${status}`); assert.equal(typeof payload.error.blocker.retryable, "boolean"); assert.equal(payload.error.blocker.capabilityLevel, "blocked"); assert.equal(payload.provider, "codex-stdio"); 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 does not call empty provider text fallback", async () => { let providerCalled = false; const server = createCloudApiServer({ callCodeAgentProvider: async () => { providerCalled = true; throw new Error("empty provider fallback must not be used"); } }); 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" }, body: JSON.stringify({ conversationId: "cnv_empty-provider-text", message: "你好" }) }); assert.equal(response.status, 200); const payload = await response.json(); assert.equal(payload.conversationId, "cnv_empty-provider-text"); assert.equal(payload.status, "failed"); assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u); assert.equal(payload.provider, "codex-stdio"); assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(providerCalled, false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } });