diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index fc8e23ef..9131e1c6 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -1444,7 +1444,7 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI sessionMode: CODEX_STDIO_SESSION_MODE, sessionReuse: session ? sessionReuseEvidence(session) : null, implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - runnerLimitations: ["codex-stdio-blocked"], + runnerLimitations: error.runnerLimitations ?? ["codex-stdio-blocked"], codexStdioFeasibility: availability, longLivedSessionGate: longLivedSessionGate({ provider: CODEX_STDIO_PROVIDER, diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 926ba5fc..28be92e0 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -13,8 +13,7 @@ import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs"; import { - classifyCodexRunnerCapability, - classifyCodeAgentChatReadiness + classifyCodexRunnerCapability } from "../../scripts/src/code-agent-response-contract.mjs"; import { HWLAB_M3_IO_API_BASE_URL_ENV, @@ -286,7 +285,7 @@ test("code agent session registry blocks failed and interrupted sessions without assert.equal(interrupted.session.status, "interrupted"); }); -test("read-only runner returns session registry evidence and blocked long-lived gate", async () => { +test("Codex stdio readiness blocker does not use read-only session fallback", async () => { const registry = createCodeAgentSessionRegistry({ idFactory: () => "ses_chat_registry" }); @@ -300,25 +299,28 @@ test("read-only runner returns session registry evidence and blocked long-lived now: () => "2026-05-23T00:01:00.000Z", sessionRegistry: registry, env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_CODEX_COMMAND: path.join(os.tmpdir(), "hwlab-missing-codex"), + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd() } } ); validateCodeAgentChatSchema(first); - assert.equal(first.status, "completed"); - assert.equal(first.sessionId, "ses_chat_registry"); - assert.equal(first.session.sessionId, "ses_chat_registry"); - assert.equal(first.session.status, "idle"); - assert.equal(first.session.sessionMode, "controlled-readonly-session-registry"); - assert.equal(first.session.workspace, process.cwd()); - assert.equal(first.session.lastTraceId, "trc_chat_registry_pwd"); - assert.equal(first.capabilityLevel, "read-only-session-tools"); - assert.equal(first.session.longLivedSession, true); - assert.equal(first.runner.longLivedSession, true); + assert.equal(first.status, "failed"); + assert.equal(first.provider, "codex-stdio"); + assert.equal(first.backend, "hwlab-cloud-api/codex-app-server-stdio"); + assert.equal(first.error.code, "codex_cli_binary_missing"); + assert.equal(first.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(first.runner.kind === "hwlab-readonly-runner", false); + assert.equal(first.capabilityLevel, "blocked"); + assert.equal(first.skills.status, "not_requested"); + assert.deepEqual(first.toolCalls, []); + assert.equal(Object.hasOwn(first, "reply"), false); assert.equal(first.longLivedSessionGate.status, "blocked"); assert.equal(first.longLivedSessionGate.pass, false); - assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_blocked_readonly_session_available")); + assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing")); const second = await handleCodeAgentChat( { @@ -330,94 +332,45 @@ test("read-only runner returns session registry evidence and blocked long-lived now: () => "2026-05-23T00:01:01.000Z", sessionRegistry: registry, env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_CODEX_COMMAND: path.join(os.tmpdir(), "hwlab-missing-codex"), + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd() } } ); - assert.equal(second.status, "completed"); - assert.equal(second.sessionId, first.sessionId); - assert.equal(second.sessionReuse.reused, true); - assert.equal(second.sessionReuse.turn, 2); - assert.equal(second.session.lastTraceId, "trc_chat_registry_ls"); + assert.equal(second.status, "failed"); + assert.equal(second.error.code, "codex_cli_binary_missing"); + assert.equal(second.provider, "codex-stdio"); + assert.equal(second.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(second.runner.kind === "hwlab-readonly-runner", false); + assert.equal(Object.hasOwn(second, "reply"), false); assert.equal(classifyCodexRunnerCapability(second, { httpStatus: 200 }).capabilityPass, false); }); -test("expired, busy, and failed session states are surfaced as structured blockers", async () => { - const expiredRegistry = createCodeAgentSessionRegistry({ - idleTimeoutMs: 10, - idFactory: () => "ses_expired_chat" +test("stale read-only conversation facts do not mask Codex stdio blockers", async () => { + const registry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_stale_readonly" }); - const first = expiredRegistry.acquire({ - conversationId: "cnv_expired_chat", + const acquired = registry.acquire({ + conversationId: "cnv_stale_readonly", workspace: process.cwd(), sandbox: "read-only", runnerKind: "hwlab-readonly-runner", capabilityLevel: "read-only-session-tools", - traceId: "trc_expired_seed", + traceId: "trc_stale_readonly_seed", now: "2026-05-23T00:02:00.000Z" }); - expiredRegistry.release(first.session.sessionId, { - conversationId: "cnv_expired_chat", - traceId: "trc_expired_seed", + registry.release(acquired.session.sessionId, { + conversationId: "cnv_stale_readonly", + traceId: "trc_stale_readonly_seed", now: "2026-05-23T00:02:00.001Z" }); - expiredRegistry.recordFact("cnv_expired_chat", { - traceId: "trc_expired_seed", + registry.recordFact("cnv_stale_readonly", { + traceId: "trc_stale_readonly_seed", workspace: process.cwd(), - session: first.session, - runner: { kind: "hwlab-readonly-runner" }, - sessionMode: "controlled-readonly-session-registry", - capabilityLevel: "read-only-session-tools", - toolCalls: [{ name: "pwd", status: "completed" }] - }, { now: "2026-05-23T00:02:00.002Z" }); - const expired = await handleCodeAgentChat( - { - conversationId: "cnv_expired_chat", - traceId: "trc_expired_request", - message: "pwd" - }, - { - now: () => "2026-05-23T00:02:01.000Z", - sessionRegistry: expiredRegistry, - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() - } - } - ); - assert.equal(expired.status, "failed"); - assert.equal(expired.error.code, "session_expired"); - assert.equal(expired.error.layer, "session"); - assert.equal(expired.error.retryable, true); - assert.match(expired.error.userMessage, /session 已过期/u); - assert.equal(expired.error.blocker.code, "session_expired"); - assert.equal(expired.error.blocker.traceId, "trc_expired_request"); - assert.equal(expired.session.status, "expired"); - assert.equal(expired.capabilityLevel, "blocked"); - assert.equal(expired.longLivedSessionGate.status, "blocked"); - assert.equal(expired.conversationFacts.conversationId, "cnv_expired_chat"); - assert.equal(expired.conversationFacts.sessionId, "ses_expired_chat"); - assert.equal(expired.conversationFacts.turnCount, 1); - assert.equal(expired.conversationFacts.recentToolCalls[0].name, "pwd"); - assert.equal(expired.error.blocker.conversationFacts.sessionId, "ses_expired_chat"); - - const busyRegistry = createCodeAgentSessionRegistry({ - idFactory: () => "ses_busy_chat" - }); - busyRegistry.acquire({ - conversationId: "cnv_busy_chat", - workspace: process.cwd(), - sandbox: "read-only", - runnerKind: "hwlab-readonly-runner", - capabilityLevel: "read-only-session-tools", - traceId: "trc_busy_seed", - now: "2026-05-23T00:03:00.000Z" - }); - busyRegistry.recordFact("cnv_busy_chat", { - traceId: "trc_busy_seed", - workspace: process.cwd(), - sessionId: "ses_busy_chat", + session: acquired.session, runner: { kind: "hwlab-readonly-runner" }, sessionMode: "controlled-readonly-session-registry", capabilityLevel: "read-only-session-tools", @@ -428,91 +381,45 @@ test("expired, busy, and failed session states are surfaced as structured blocke count: 1, totalCount: 1 } - }, { now: "2026-05-23T00:03:00.001Z" }); - const busy = await handleCodeAgentChat( - { - conversationId: "cnv_busy_chat", - traceId: "trc_busy_request", - message: "pwd" - }, - { - now: () => "2026-05-23T00:03:01.000Z", - sessionRegistry: busyRegistry, - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() - } - } - ); - assert.equal(busy.status, "failed"); - assert.equal(busy.error.code, "session_busy"); - assert.equal(busy.error.layer, "session"); - assert.equal(busy.error.retryable, true); - assert.match(busy.error.userMessage, /正在处理上一轮/u); - assert.equal(busy.error.blocker.code, "session_busy"); - assert.equal(busy.session.status, "busy"); - assert.equal(busy.capabilityLevel, "blocked"); - assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317"); - assert.equal(busy.conversationFacts.conversationId, "cnv_busy_chat"); - assert.equal(busy.conversationFacts.sessionId, "ses_busy_chat"); - assert.equal(busy.conversationFacts.latestSkills.names[0], "alpha-skill"); + }, { now: "2026-05-23T00:02:00.002Z" }); - const failedRegistry = createCodeAgentSessionRegistry({ - idFactory: () => "ses_failed_chat" - }); - const failedSeed = failedRegistry.acquire({ - conversationId: "cnv_failed_chat", - workspace: process.cwd(), - sandbox: "read-only", - runnerKind: "hwlab-readonly-runner", - capabilityLevel: "read-only-session-tools", - traceId: "trc_failed_seed", - now: "2026-05-23T00:04:00.000Z" - }); - failedRegistry.fail(failedSeed.session.sessionId, { - conversationId: "cnv_failed_chat", - traceId: "trc_failed_seed", - now: "2026-05-23T00:04:00.001Z" - }); - failedRegistry.recordFact("cnv_failed_chat", { - traceId: "trc_failed_seed", - workspace: process.cwd(), - session: failedSeed.session, - runner: { kind: "hwlab-readonly-runner" }, - sessionMode: "controlled-readonly-session-registry", - capabilityLevel: "read-only-session-tools", - toolCalls: [{ name: "pwd", status: "completed" }] - }, { now: "2026-05-23T00:04:00.002Z" }); - const failed = await handleCodeAgentChat( + const payload = await handleCodeAgentChat( { - conversationId: "cnv_failed_chat", - traceId: "trc_failed_request", - message: "pwd" + conversationId: "cnv_stale_readonly", + traceId: "trc_stale_readonly_request", + message: "根据上一轮列出你能使用的所有skill" }, { - now: () => "2026-05-23T00:04:01.000Z", - sessionRegistry: failedRegistry, + now: () => "2026-05-23T00:02:01.000Z", + sessionRegistry: registry, env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_CODEX_COMMAND: path.join(os.tmpdir(), "hwlab-missing-codex"), + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd() } } ); - assert.equal(failed.status, "failed"); - assert.equal(failed.error.code, "session_failed"); - assert.equal(failed.error.layer, "session"); - assert.equal(failed.error.retryable, true); - assert.match(failed.error.userMessage, /session 已失败/u); - assert.equal(failed.error.blocker.code, "session_failed"); - assert.equal(failed.session.status, "failed"); - assert.equal(failed.capabilityLevel, "blocked"); - assert.equal(failed.conversationFacts.conversationId, "cnv_failed_chat"); - assert.equal(failed.conversationFacts.sessionId, "ses_failed_chat"); - assert.equal(failed.conversationFacts.recentToolCalls[0].name, "pwd"); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "failed"); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(payload.skills.status, "not_requested"); + assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(JSON.stringify(payload).includes("alpha-skill"), true); + assert.equal(payload.conversationFacts.latestSkills.names[0], "alpha-skill"); + assert.equal(payload.error.blocker.conversationFacts.latestSkills.names[0], "alpha-skill"); + assert.equal(payload.runner.kind === "hwlab-readonly-runner", false); + assert.equal(payload.provider === "openai-responses", false); }); -test("OpenAI fallback and codex one-shot do not pass the long-lived session gate", async () => { - const fallback = await handleCodeAgentChat( +test("OpenAI fallback hook is not used when Codex stdio is unavailable", async () => { + let providerCalled = false; + const payload = await handleCodeAgentChat( { conversationId: "cnv_fallback_gate", traceId: "trc_fallback_gate", @@ -520,89 +427,65 @@ test("OpenAI fallback and codex one-shot do not pass the long-lived session gate }, { now: () => "2026-05-23T00:04:00.000Z", - callProvider: async ({ providerPlan }) => ({ - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - content: "普通文本回复。", - usage: null, - providerTrace: { - source: "test-provider" - } - }) + env: { + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "1", + HWLAB_CODE_AGENT_CODEX_COMMAND: path.join(os.tmpdir(), "hwlab-missing-codex"), + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd() + }, + callProvider: async () => { + providerCalled = true; + return { + provider: "openai-responses", + model: "gpt-test", + backend: "hwlab-cloud-api/openai-responses", + content: "普通文本回复。", + usage: null, + providerTrace: { source: "test-provider" } + }; + } } ); - assert.equal(fallback.status, "completed"); - assert.equal(fallback.runner.kind, "openai-responses-fallback"); - assert.equal(fallback.capabilityLevel, "text-chat-only"); - assert.deepEqual(fallback.runnerLimitations, [ - "openai-responses-fallback", - "text-chat-only", - "not-workspace-tools", - "not-session-runner" - ]); - assert.equal(fallback.runnerLimitations.includes("not-codex-stdio"), false); - assert.equal(fallback.blocker.code, "text_chat_only_fallback"); - assert.equal(fallback.blocker.layer, "provider"); - assert.equal(fallback.blocker.retryable, false); - assert.match(fallback.blocker.userMessage, /文本 fallback/u); - assert.equal(fallback.longLivedSessionGate.status, "blocked"); - assert.ok(fallback.longLivedSessionGate.blockers.some((blocker) => blocker.code === "openai_responses_fallback_not_session")); - assert.equal(classifyCodexRunnerCapability(fallback, { httpStatus: 200 }).capabilityPass, false); - const oneShot = { - ...fallback, - provider: "codex-cli", - backend: "hwlab-cloud-api/codex-cli", - runner: { - kind: "codex-cli-one-shot-ephemeral", - codexStdio: false, - writeCapable: false, - durableSession: false, - longLivedSession: false - }, - sessionMode: "ephemeral-one-shot", - implementationType: "codex-cli-one-shot-ephemeral", - longLivedSessionGate: { - status: "blocked", - pass: false, - blockers: [{ code: "one_shot_runner_not_long_lived", sourceIssue: "pikasTech/HWLAB#317" }] - } - }; - assert.equal(classifyCodexRunnerCapability(oneShot, { httpStatus: 200 }).capabilityPass, false); - assert.equal(classifyCodeAgentChatReadiness(oneShot, { realDevLive: true, httpStatus: 200 }).devLiveReplyPass, true); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "failed"); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(payload.runner.kind === "openai-responses-fallback", false); + assert.equal(payload.provider === "openai-responses", false); + assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(providerCalled, false); + assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false); }); -test("OpenAI provider turns receive bounded prior session facts and record continuity", async () => { +test("OpenAI provider mode still preserves facts but does not fallback without Codex stdio", async () => { const registry = createCodeAgentSessionRegistry({ idFactory: () => "ses_provider_context" }); - const providerPrompts = []; + let providerCalled = false; const env = { - PATH: process.env.PATH, + PATH: "", HWLAB_CODE_AGENT_PROVIDER: "openai", HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "1", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + HWLAB_CODE_AGENT_CODEX_COMMAND: path.join(os.tmpdir(), "hwlab-missing-codex"), + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd() }; - const first = await handleCodeAgentChat( - { - conversationId: "cnv_provider_context", - traceId: "trc_provider_context_pwd", - message: "pwd" - }, - { - now: () => "2026-05-23T00:04:10.000Z", - sessionRegistry: registry, - env - } - ); - assert.equal(first.status, "completed"); - assert.equal(first.provider, "codex-readonly-runner"); - assert.equal(first.toolCalls[0].name, "pwd"); - assert.equal(first.conversationFacts.turnCount, 1); + registry.recordFact("cnv_provider_context", { + traceId: "trc_provider_context_pwd", + workspace: process.cwd(), + runner: { kind: "codex-app-server-stdio-runner" }, + sessionMode: "codex-app-server-stdio-long-lived", + capabilityLevel: "long-lived-codex-stdio-session", + toolCalls: [{ name: "pwd", status: "completed" }] + }, { now: "2026-05-23T00:04:09.000Z" }); - const second = await handleCodeAgentChat( + const payload = await handleCodeAgentChat( { conversationId: "cnv_provider_context", traceId: "trc_provider_context_openai", @@ -612,17 +495,13 @@ test("OpenAI provider turns receive bounded prior session facts and record conti now: () => "2026-05-23T00:04:11.000Z", sessionRegistry: registry, env, - callProvider: async ({ conversationFacts }) => { - const promptFacts = JSON.stringify(conversationFacts); - providerPrompts.push(promptFacts); - assert.equal(conversationFacts.sessionId, first.sessionId); - assert.equal(conversationFacts.workspace, process.cwd()); - assert.equal(conversationFacts.recentToolCalls[0].name, "pwd"); + callProvider: async () => { + providerCalled = true; return { provider: "openai-responses", model: "gpt-test", backend: "hwlab-cloud-api/openai-responses", - content: `上一轮工作目录是 ${conversationFacts.workspace},session=${conversationFacts.sessionId},trace=${conversationFacts.latestTraceId}。`, + content: "普通 fallback 回复不应被调用。", usage: null, providerTrace: { source: "test-provider" } }; @@ -630,15 +509,16 @@ test("OpenAI provider turns receive bounded prior session facts and record conti } ); - assert.equal(second.status, "completed"); - assert.equal(second.provider, "openai-responses"); - assert.equal(second.conversationFacts.turnCount, 2); - assert.equal(second.conversationFacts.sessionId, first.sessionId); - assert.equal(second.conversationFacts.latestProvider, "openai-responses"); - assert.equal(second.conversationFacts.latestBackend, "hwlab-cloud-api/openai-responses"); - assert.match(second.reply.content, /上一轮工作目录/u); - assert.match(providerPrompts[0], /trc_provider_context_pwd/u); - assert.equal(JSON.stringify(second.conversationFacts).includes("sk-"), false); + assert.equal(payload.status, "failed"); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(payload.conversationFacts.turnCount, 1); + assert.equal(payload.conversationFacts.recentToolCalls[0].name, "pwd"); + assert.equal(payload.conversationFacts.latestTraceId, "trc_provider_context_pwd"); + assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(providerCalled, false); + assert.equal(JSON.stringify(payload.conversationFacts).includes("sk-"), false); }); test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", async () => { @@ -2128,6 +2008,162 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with await rm(codexHome, { recursive: true, force: true }); }); +test("Codex stdio skills discovery returns bounded manifest facts instead of generic model text", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-stdio-skills-ready-")); + const workspace = path.join(root, "workspace"); + const skillsDir = path.join(root, "skills"); + const codexHome = await prepareFakeCodexHome(path.join(root, "codex-home")); + const fakeCodex = await createFakeCodexCommand(); + const calls = []; + await mkdir(path.join(workspace), { recursive: true }); + await mkdir(path.join(skillsDir, "alpha"), { recursive: true }); + await writeFile(path.join(skillsDir, "alpha", "SKILL.md"), [ + "---", + "name: alpha-skill", + "description: Alpha manifest summary from SKILL.md.", + "version: v1.2.3", + "commitId: abc123def456", + "---", + "", + "# Alpha" + ].join("\n"), "utf8"); + + try { + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_stdio_skills_ready", + createRpcClient: async () => createFakeAppServerClient({ + calls, + responses: ["模型泛化回答:我可以使用很多 skill。"] + }) + }); + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_skills_ready", + traceId: "trc_stdio_skills_ready", + message: "列出你能使用的所有skill" + }, + { + now: () => "2026-05-24T00:10:00.000Z", + codexStdioManager: manager, + 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://172.26.26.227:17680/v1/responses" + } + } + ); + + 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.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); + assert.equal(payload.skills.status, "ready"); + assert.equal(payload.skills.count, 1); + assert.equal(payload.skills.totalCount, 1); + assert.equal(payload.skills.items[0].name, "alpha-skill"); + assert.equal(payload.skills.items[0].summary, "Alpha manifest summary from SKILL.md."); + assert.equal(payload.skills.items[0].version, "v1.2.3"); + assert.equal(payload.skills.items[0].commitId, "abc123def456"); + assert.equal(payload.skills.items[0].source, path.join(skillsDir, "alpha", "SKILL.md")); + assert.equal(payload.skills.items[0].manifest.path, path.join(skillsDir, "alpha", "SKILL.md")); + assert.match(payload.reply.content, /真实 skill manifest 清单/u); + assert.match(payload.reply.content, /数量:本次返回 1 个;已发现总数 1 个/u); + assert.match(payload.reply.content, /alpha-skill/u); + assert.match(payload.reply.content, /version=v1\.2\.3/u); + assert.match(payload.reply.content, /commitId=abc123def456/u); + assert.doesNotMatch(payload.reply.content, /模型泛化回答/u); + assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "completed")); + assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "codex-app-server.thread/start+turn/start" && toolCall.status === "completed")); + const turnPrompt = calls.find((call) => call.method === "turn/start")?.args?.prompt ?? ""; + assert.match(turnPrompt, /Current skills discovery facts/u); + assert.match(turnPrompt, /alpha-skill/u); + assert.match(turnPrompt, /commitId=abc123def456/u); + assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(fakeCodex.root, { recursive: true, force: true }); + } +}); + +test("Codex stdio skills discovery returns structured blocker when manifests are missing", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-stdio-skills-missing-")); + const workspace = path.join(root, "workspace"); + const codexHome = await prepareFakeCodexHome(path.join(root, "codex-home")); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(workspace, { recursive: true }); + + try { + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_stdio_skills_missing", + createRpcClient: async () => createFakeAppServerClient({ + responses: ["模型泛化回答:我可以使用 alpha-skill 和 beta-skill。"] + }) + }); + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_skills_missing", + traceId: "trc_stdio_skills_missing", + message: "列出你可用的skills" + }, + { + now: () => "2026-05-24T00:10:30.000Z", + codexStdioManager: manager, + 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(root, "missing-skills"), + HWLAB_CODE_AGENT_SKILLS_STRICT: "1", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + } + } + ); + + 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.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(payload.runnerTrace.runnerKind, "codex-app-server-stdio-runner"); + assert.equal(payload.error.code, "skills_unavailable"); + assert.equal(payload.error.retryable, false); + assert.equal(payload.skills.status, "blocked"); + assert.equal(payload.skills.count, 0); + assert.deepEqual(payload.skills.sourceIssues, ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"]); + assert.ok(payload.skills.blockers.some((blocker) => blocker.sourceIssue === "pikasTech/HWLAB#136")); + assert.ok(payload.skills.blockers.some((blocker) => blocker.sourceIssue === "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.notEqual(payload.runner.kind, "openai-responses-fallback"); + assert.notEqual(payload.provider, "openai-responses"); + assert.equal(JSON.stringify(payload).includes("alpha-skill"), false); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "stdio:ready")); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "skills.discover:blocked")); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(fakeCodex.root, { recursive: true, force: true }); + } +}); + test("Codex stdio runner startup failure marks session failed and blocks long-lived gate", async () => { const fakeCodex = await createFakeCodexCommand(); const codexHome = await prepareFakeCodexHome(); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 61c05ad0..d921496e 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -46,6 +46,8 @@ const CODEX_APP_SERVER_WIRE_API = "responses"; const CODEX_APP_SERVER_REQUIRED_METHODS = Object.freeze(["initialize", "thread/start", "thread/resume", "turn/start"]); const CODEX_STDIO_TOOL_OUTPUT_LIMIT = 4000; const CODEX_STDIO_SKILL_LIMIT = 40; +const CODEX_STDIO_SKILL_REPLY_LIMIT = 20; +const CODEX_STDIO_SKILL_SUMMARY_LIMIT = 180; const CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS = 3000; const CODEX_STDIO_COMMAND_PROBE_TRACE_ID = "trc_codex_stdio_command_probe"; const CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID = "cnv_codex_stdio_command_probe"; @@ -428,7 +430,53 @@ export function createCodexStdioSessionManager(options = {}) { now }); for (const event of sidecar.events) traceRecorder.append(event); - const prompt = buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts }); + if (sidecar.intent?.skills && sidecar.skills?.status !== "ready") { + session = releaseSession(session.sessionId, { + now, + traceId, + conversationId, + reused: session.reused, + status: "idle" + }) ?? session; + traceRecorder.append({ + type: "error", + status: "blocked", + label: "skills.discover:blocked", + toolName: "skills.discover", + errorCode: "skills_unavailable", + message: "No readable SKILL.md files were found in configured skills directories; skills discovery is blocked before any generic model answer.", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: "skills-manifest", + terminal: true + }); + throw codexStdioError("skills_unavailable", "Code Agent Skill 清单未挂载或不可读;已通过 Codex stdio runner 建立 trace,但不能编造 skills 列表。", { + availability, + session, + toolCalls: sidecar.toolCalls, + skills: sidecar.skills, + blockers: sidecar.skills.blockers, + runnerTrace: runnerTrace({ + traceRecorder, + traceId, + workspace, + sandbox, + session, + startedAt, + outputTruncated: sidecar.outputTruncated + }), + preserveSessionStatus: true, + retryable: false, + userMessage: "技能发现受阻:运行时没有返回可读取的 SKILL.md 清单;本次不会用泛化文本编造可用 skill。请关联 #136/#237 补齐 skills 挂载或 manifest。", + route: "/v1/agent/chat", + toolName: "skills.discover", + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + runnerLimitations: ["skills-manifest-unavailable", "no-text-fallback-for-skills-discovery"] + }); + } + const prompt = buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts, sidecar }); const toolName = session.threadId ? "codex-app-server.thread/resume+turn/start" : "codex-app-server.thread/start+turn/start"; traceRecorder.append({ type: "prompt", @@ -501,6 +549,24 @@ export function createCodexStdioSessionManager(options = {}) { threadId: turnResult.threadId ?? session.threadId, status: "idle" }) ?? session; + const codexToolCall = { + id: `tool_${randomUUID()}`, + type: "codex-app-server-stdio", + name: toolName, + status: "completed", + cwd: workspace, + command: "codex app-server --listen stdio://", + exitCode: 0, + stdout: [ + turnResult.threadId ? `threadId=${turnResult.threadId}` : "threadId=captured", + turnResult.turnId ? `turnId=${turnResult.turnId}` : null, + `terminalStatus=${turnResult.terminalStatus}` + ].filter(Boolean).join(" "), + stderrSummary: "", + outputTruncated: false, + traceId + }; + const toolCalls = [codexToolCall, ...sidecar.toolCalls]; traceRecorder.append({ type: "tool_call", status: "completed", @@ -551,23 +617,7 @@ export function createCodexStdioSessionManager(options = {}) { implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, codexStdioFeasibility: availability }), - toolCalls: [{ - id: `tool_${randomUUID()}`, - type: "codex-app-server-stdio", - name: toolName, - status: "completed", - cwd: workspace, - command: "codex app-server --listen stdio://", - exitCode: 0, - stdout: [ - turnResult.threadId ? `threadId=${turnResult.threadId}` : "threadId=captured", - turnResult.turnId ? `turnId=${turnResult.turnId}` : null, - `terminalStatus=${turnResult.terminalStatus}` - ].filter(Boolean).join(" "), - stderrSummary: "", - outputTruncated: false, - traceId - }, ...sidecar.toolCalls], + toolCalls, skills: sidecar.skills, runner: runnerDescriptor({ workspace, sandbox, session }), runnerTrace: runnerTrace({ @@ -599,8 +649,11 @@ export function createCodexStdioSessionManager(options = {}) { const timeout = /timed out|timeout|超时/iu.test(String(error.message ?? "")); const currentSession = sessions.get(session.sessionId) ?? null; const canceled = currentSession?.status === "canceled"; + const preserveSessionStatus = error.preserveSessionStatus === true && error.session?.status && error.session.status !== "busy"; session = canceled ? publicSession(currentSession, { conversationId, reused: session.reused }) + : preserveSessionStatus + ? error.session : failSession(session.sessionId, { now, traceId, @@ -3037,9 +3090,18 @@ async function discoverSkillsForStdio({ env = process.env, traceId } = {}) { if (!manifest) continue; items.push({ name: manifest.name, - summary: manifest.description ?? firstMarkdownSummary(manifest.body) ?? "No description provided.", + summary: boundSkillText(manifest.description ?? firstMarkdownSummary(manifest.body) ?? "No description provided."), source: manifestPath, + manifestPath, + manifest: { + type: "SKILL.md", + path: manifestPath, + root: dir, + relativePath: path.relative(dir, manifestPath) || "SKILL.md" + }, sourceRoot: dir, + version: manifest.version ?? null, + commitId: manifest.commitId ?? null, traceId }); } @@ -3055,11 +3117,19 @@ async function discoverSkillsForStdio({ env = process.env, traceId } = {}) { totalCount: 0, checkedDirs, sources, - blockers: [{ - code: "skills_unavailable", - sourceIssue: "pikasTech/HWLAB#136", - summary: "No readable SKILL.md files were found in the configured Codex stdio skills directories." - }], + sourceIssues: ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"], + blockers: [ + { + code: "skills_unavailable", + sourceIssue: "pikasTech/HWLAB#136", + summary: "No readable SKILL.md files were found in the configured Codex stdio skills directories." + }, + { + code: "skills_manifest_missing", + sourceIssue: "pikasTech/HWLAB#237", + summary: "Codex stdio skills discovery requires mounted skill manifests; generic text fallback must not invent skills." + } + ], valuesPrinted: false }; } @@ -3072,6 +3142,7 @@ async function discoverSkillsForStdio({ env = process.env, traceId } = {}) { truncated: unique.length > returned.length, checkedDirs, sources, + sourceIssues: [], blockers: [], valuesPrinted: false }; @@ -3120,7 +3191,13 @@ async function readSkillManifest(manifestPath) { const frontmatter = parseFrontmatter(text); const body = text.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/u, ""); const name = frontmatter.name ?? path.basename(path.dirname(manifestPath)); - return name ? { name, description: frontmatter.description, body } : null; + return name ? { + name, + description: frontmatter.description, + version: firstManifestField(frontmatter, ["version", "skillVersion"]), + commitId: firstManifestField(frontmatter, ["commitId", "commit", "gitCommit", "revision"]), + body + } : null; } catch { return null; } @@ -3145,6 +3222,20 @@ function firstMarkdownSummary(body) { .find((line) => line && !line.startsWith("#") && !line.startsWith("-")) ?? null; } +function firstManifestField(frontmatter, keys) { + for (const key of keys) { + const value = String(frontmatter?.[key] ?? "").trim(); + if (value) return boundSkillText(value, 120); + } + return null; +} + +function boundSkillText(value, limit = CODEX_STDIO_SKILL_SUMMARY_LIMIT) { + const text = redactText(String(value ?? "").replace(/\s+/gu, " ").trim()); + if (text.length <= limit) return text; + return `${text.slice(0, Math.max(0, limit - 3))}...`; +} + function dedupeSkills(items) { const seen = new Set(); const deduped = []; @@ -3166,10 +3257,39 @@ function notRequestedSkills() { }; } -function codexReplyWithSidecar(content) { +function codexReplyWithSidecar(content, sidecar = {}) { + if (sidecar.intent?.skills && sidecar.skills?.status === "ready") { + return skillsDiscoveryReply(sidecar.skills); + } return String(content ?? "").trim(); } +function skillsDiscoveryReply(skills) { + const items = Array.isArray(skills?.items) ? skills.items : []; + const listed = items.slice(0, CODEX_STDIO_SKILL_REPLY_LIMIT); + const sourcePaths = Array.isArray(skills?.sources) + ? skills.sources.map((source) => `${source.status}:${source.path}`).slice(0, 8) + : []; + const lines = [ + "这是当前 Codex stdio runner 读取到的真实 skill manifest 清单;没有使用文本 fallback。", + `数量:本次返回 ${skills.count ?? listed.length} 个;已发现总数 ${skills.totalCount ?? items.length} 个${skills.truncated ? `;列表已截断到 ${skills.count ?? listed.length} 个` : ""}。`, + sourcePaths.length ? `来源目录:${sourcePaths.join(";")}` : null, + "" + ].filter((line) => line !== null); + for (const [index, item] of listed.entries()) { + const metadata = [ + item.version ? `version=${item.version}` : null, + item.commitId ? `commitId=${item.commitId}` : null, + item.manifestPath ? `manifest=${item.manifestPath}` : item.source ? `source=${item.source}` : null + ].filter(Boolean).join(";"); + lines.push(`${index + 1}. ${item.name}:${item.summary}${metadata ? `(${metadata})` : ""}`); + } + if (items.length > listed.length) { + lines.push(`其余 ${items.length - listed.length} 个 skill 未在正文展开,可在 payload.skills.items 查看。`); + } + return lines.join("\n").trim(); +} + function resolveWorkspaceTarget(workspace, target, { mustExist = false } = {}) { const rawTarget = String(target || ".").trim(); if (!rawTarget) return { blocked: true, reason: "missing target path" }; @@ -3238,17 +3358,40 @@ function safeDisplayPath(value) { return redactText(String(value ?? ".")).replace(/\s+/gu, " "); } -function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts }) { +function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts, sidecar }) { return [ `conversationId: ${conversationId}`, `traceId: ${traceId}`, codexConversationFactsPrompt(conversationFacts), + codexSidecarSkillsPrompt(sidecar), "", "User message:", String(message ?? "").trim() ].filter((line) => line !== null && line !== undefined).join("\n"); } +function codexSidecarSkillsPrompt(sidecar) { + if (!sidecar?.intent?.skills) return null; + const skills = sidecar.skills; + if (!skills || typeof skills !== "object") return null; + if (skills.status !== "ready") { + const blockers = Array.isArray(skills.blockers) + ? skills.blockers.map((blocker) => safePromptFact(`${blocker?.code ?? "blocked"}:${blocker?.sourceIssue ?? ""}`)).slice(0, 4).join(",") + : "skills_unavailable"; + return [ + "Current skills discovery facts (authoritative, bounded, redacted):", + `- status=${safePromptFact(skills.status)}; count=0; blockers=${blockers}.`, + "- Do not invent skill names. The API will return a structured skills_unavailable blocker." + ].join("\n"); + } + const items = Array.isArray(skills.items) ? skills.items.slice(0, 12) : []; + return [ + "Current skills discovery facts (authoritative, bounded, redacted):", + `- status=ready; returned=${safePromptFact(skills.count)}; total=${safePromptFact(skills.totalCount)}; truncated=${skills.truncated === true ? "true" : "false"}.`, + ...items.map((item) => `- ${safePromptFact(item.name)} | ${safePromptFact(item.summary)} | version=${safePromptFact(item.version)} | commitId=${safePromptFact(item.commitId)} | manifest=${safePromptFact(item.manifestPath ?? item.source)}`) + ].join("\n"); +} + function codexConversationFactsPrompt(conversationFacts) { if (!conversationFacts || typeof conversationFacts !== "object" || Number(conversationFacts.turnCount ?? 0) <= 0) { return null; diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 8efa3068..bc6a1855 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1703,7 +1703,7 @@ test("cloud api health reports provider, durable DB, and codex stdio ready when const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-health-codex-stdio-")); const codexHome = path.join(workspace, "codex-home"); const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); + await prepareFakeCodexHome(codexHome); const manager = createCodexStdioSessionManager({ createRpcClient: async () => ({ async initialize() { @@ -1809,7 +1809,7 @@ test("cloud api health blocks full Code Agent readiness when Codex stdio command const codexHome = path.join(workspace, "codex-home"); const workspaceFile = path.join(workspace, "workspace-file"); const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); + await prepareFakeCodexHome(codexHome); await writeFile(workspaceFile, "not a directory\n", "utf8"); const manager = createCodexStdioSessionManager({ createRpcClient: async () => ({ @@ -2328,7 +2328,7 @@ test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio an const skillsDir = path.join(workspace, "skills"); const fakeCodex = await createFakeCodexCommand(); await mkdir(path.join(skillsDir, "hwlab-test-skill"), { recursive: true }); - await mkdir(codexHome, { recursive: true }); + await prepareFakeCodexHome(codexHome); await writeFile(path.join(skillsDir, "hwlab-test-skill", "SKILL.md"), [ "---", "name: hwlab-test-skill", @@ -2355,22 +2355,8 @@ test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio an }, codexStdioManager: createCodexStdioSessionManager({ idFactory: () => "ses_server_stdio_skills", - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool(name, args) { - return { - structuredContent: { - threadId: "thread_server_stdio_skills", - content: `真实 Codex stdio skill 回复:${args.prompt.includes("skills") ? "hwlab-test-skill" : "unknown"}` - } - }; - }, - close() {} + createRpcClient: async () => createFakeAppServerClient({ + text: "模型泛化回答不应成为 skills 最终回复。" }) }) }); @@ -2387,16 +2373,22 @@ test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio an validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-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"); + assert.equal(payload.providerTrace.toolName, "codex-app-server.thread/start+turn/start"); assert.equal(payload.skills.status, "ready"); - assert.ok(payload.skills.items.some((skill) => skill.name === "hwlab-test-skill")); + 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:started")); + 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) => { @@ -2667,11 +2659,11 @@ test("cloud api /v1/agent/chat returns network_timeout with preserved runner tra } }); -test("cloud api /v1/agent/chat can answer skills prompt without local skills manifest when Codex stdio replies", async () => { +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 mkdir(codexHome, { recursive: true }); + await prepareFakeCodexHome(codexHome); const server = createCloudApiServer({ env: { PATH: process.env.PATH, @@ -2689,22 +2681,8 @@ test("cloud api /v1/agent/chat can answer skills prompt without local skills man }, codexStdioManager: createCodexStdioSessionManager({ idFactory: () => "ses_server_stdio_skills_missing", - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool() { - return { - structuredContent: { - threadId: "thread_server_stdio_skills_missing", - content: "真实 Codex stdio 回复:当前运行时未提供可展开的本地 skill manifest,但这是模型/工具链的真实回答。" - } - }; - }, - close() {} + createRpcClient: async () => createFakeAppServerClient({ + text: "模型泛化回答:alpha-skill" }) }) }); @@ -2719,15 +2697,19 @@ test("cloud api /v1/agent/chat can answer skills prompt without local skills man }); validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); + assert.equal(payload.status, "failed"); assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); - assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); + 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.match(payload.reply.content, /真实 Codex stdio 回复/u); + 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) => { @@ -4729,6 +4711,43 @@ async function createFakeCodexCommand() { return { root, command }; } +async function prepareFakeCodexHome(codexHome) { + await mkdir(codexHome, { recursive: true }); + await writeFile(path.join(codexHome, "config.toml"), "model = \"gpt-test\"\n", "utf8"); + await writeFile(path.join(codexHome, "auth.json"), "{\"auth\":\"redacted-test-placeholder\"}\n", "utf8"); + return codexHome; +} + +function createFakeAppServerClient({ text = "真实 Codex stdio 回复", delayMs = 0 } = {}) { + let notificationHandler = null; + return { + async initialize() { + return { initialized: true }; + }, + setNotificationHandler(handler) { + notificationHandler = typeof handler === "function" ? handler : null; + }, + async startThread() { + notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_server_test_stdio" } } }); + return { threadId: "thread_server_test_stdio" }; + }, + async resumeThread(args) { + notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } }); + return { threadId: args.threadId }; + }, + async startTurn() { + const turnId = "turn_server_test_stdio"; + notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } }); + if (delayMs > 0) await delay(delayMs); + notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: "item_server_test_stdio", delta: text } }); + notificationHandler?.({ method: "item/completed", params: { item: { id: "item_server_test_stdio", type: "agentMessage", text } } }); + notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } }); + return { turnId }; + }, + close() {} + }; +} + async function postAgentRaw(port, body, headers = {}) { const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { method: "POST", diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 8bfe4f5f..fa596597 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -716,7 +716,10 @@ test("local slow structured blocker fixture preserves trace, pending state, and assert.equal(blockerCheck.observations.ui.retryInputValue, "列出你能使用的所有skill"); assert.equal(blockerCheck.observations.prompt.legacyWindow.permanentFailureAround4500ms, false); assert.equal(blockerCheck.observations.prompt.legacyWindow.pendingChineseVisible, true); + assert.equal(blockerCheck.observations.prompt.response.provider, "codex-stdio"); + assert.equal(blockerCheck.observations.prompt.response.traceId, blockerCheck.observations.prompt.traceId); assert.equal(blockerCheck.observations.prompt.response.error.code, "skills_unavailable"); + assert.equal(blockerCheck.observations.prompt.response.skills.status, "blocked"); assert.equal(blockerCheck.observations.prompt.classification.blocker, "runner-blocked"); });