Merge pull request #459 from pikasTech/fix/code-agent-skills-discovery

修复 Code Agent skills 真实发现路径
This commit is contained in:
Lyon
2026-05-24 19:38:45 +08:00
committed by GitHub
5 changed files with 522 additions and 321 deletions
+1 -1
View File
@@ -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,
@@ -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();
+170 -27
View File
@@ -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;
+64 -45
View File
@@ -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",
@@ -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");
});