diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 3308607db..834d93f5d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -11,6 +11,7 @@ import type { AgentLaunchContext, AgentSession, AgentSessionConfig, + AgentSlashCommand, AgentStreamEvent, } from "../agent-sdk-types.js"; import { @@ -199,6 +200,63 @@ function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown return params?.config; } +async function listCommandsFromFakeCodex(skills: unknown[]): Promise { + const tempDir = await mkdtemp(path.join(tmpdir(), "codex-command-list-")); + const fakeCodexPath = path.join(tempDir, "fake-codex.cjs"); + writeFileSync( + fakeCodexPath, + ` +let buffer = ""; + +function resultFor(method) { + if (method === "initialize") return {}; + if (method === "collaborationMode/list") return { data: [] }; + if (method === "skills/list") { + return { + data: [ + { + cwd: "/tmp/codex-question-test", + skills: ${JSON.stringify(skills)}, + errors: [], + }, + ], + }; + } + throw new Error("Unexpected Codex request: " + method); +} + +process.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + for (;;) { + const newlineIndex = buffer.indexOf("\\n"); + if (newlineIndex === -1) break; + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!line) continue; + const message = JSON.parse(line); + if (typeof message.id !== "number") continue; + try { + process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n"); + } catch (error) { + process.stdout.write(JSON.stringify({ id: message.id, error: { message: error.message } }) + "\\n"); + } + } +}); +`, + ); + + const client = new CodexAppServerAgentClient(createTestLogger(), { + command: { mode: "replace", argv: [process.execPath, fakeCodexPath] }, + }); + const session = await client.createSession(createConfig()); + try { + return await session.listCommands(); + } finally { + await session.close(); + rmSync(tempDir, { recursive: true, force: true }); + } +} + describe("Codex app-server provider", () => { test("getAvailableModes includes auto-review when the Codex version supports it", async () => { const session = createSession({}, { autoReviewEnabled: true }); @@ -824,6 +882,29 @@ describe("Codex app-server provider", () => { ); }); + test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => { + const commands = await listCommandsFromFakeCodex([ + { + name: "paseo", + description: "Shared orchestration skill.", + path: "/Users/test/.agents/skills/paseo/SKILL.md", + }, + { + name: "paseo", + description: "Shared orchestration skill.", + path: "/Users/test/.codex/skills/paseo/SKILL.md", + }, + ]); + + expect(commands.filter((command) => command.name === "paseo")).toEqual([ + { + name: "paseo", + description: "Shared orchestration skill.", + argumentHint: "", + }, + ]); + }); + test("maps image prompt blocks to Codex localImage input", async () => { const input = await codexAppServerTurnInputFromPrompt( [ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index e5637c7c0..ecb4c7e0a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -2852,7 +2852,7 @@ class CodexAppServerAgentSession implements AgentSession { }), ); const entries = Array.isArray(response?.data) ? response.data : []; - const skills: Array<{ name: string; description: string; path: string }> = []; + const skillsByName = new Map(); for (const entry of entries) { const entryRecord = toObjectRecord(entry); const list = Array.isArray(entryRecord?.skills) ? entryRecord.skills : []; @@ -2860,14 +2860,16 @@ class CodexAppServerAgentSession implements AgentSession { const skillRecord = toObjectRecord(skill); if (typeof skillRecord?.name !== "string" || typeof skillRecord?.path !== "string") continue; - skills.push({ - name: skillRecord.name, - description: resolveSkillDescription(skillRecord), - path: skillRecord.path, - }); + if (!skillsByName.has(skillRecord.name)) { + skillsByName.set(skillRecord.name, { + name: skillRecord.name, + description: resolveSkillDescription(skillRecord), + path: skillRecord.path, + }); + } } } - this.cachedSkills = skills; + this.cachedSkills = Array.from(skillsByName.values()); } catch (error) { this.logger.trace( {