Merge pull request #459 from pikasTech/fix/code-agent-skills-discovery
修复 Code Agent skills 真实发现路径
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user