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 00dc26652c..329e30b488 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 @@ -2233,6 +2233,59 @@ describe("Codex app-server provider", () => { ); }); + test("resolves a Codex skill mentioned after prose into app-server skill input", async () => { + const session = createSession(); + const request = vi.fn(async (method: string) => { + if (method === "skills/list") { + return { + data: [ + { + cwd: "/tmp/codex-question-test", + skills: [ + { + name: "paseo-implement", + description: "Execute an existing Paseo plan.", + path: "/tmp/skills/paseo-implement/SKILL.md", + }, + ], + errors: [], + }, + ], + }; + } + if (method === "thread/loaded/list") { + return { data: ["test-thread"] }; + } + if (method === "turn/start") { + return {}; + } + throw new Error(`Unexpected request: ${method}`); + }); + + session.activeForegroundTurnId = null; + session.client = createStub({ request }); + + await session.startTurn("Review the accepted plan, then use /paseo-implement to complete it."); + + const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start"); + expect(turnStartCall?.[1]).toEqual( + expect.objectContaining({ + input: [ + { + type: "skill", + name: "paseo-implement", + path: "/tmp/skills/paseo-implement/SKILL.md", + }, + { + type: "text", + text: "Review the accepted plan, then use $paseo-implement to complete it.", + text_elements: [], + }, + ], + }), + ); + }); + test("lists project skill commands when app-server receives the project cwd in cwds", async () => { const commands = await listCommandsFromFakeCodex([ { 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 b7fc721f24..2d8030ed09 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 @@ -3169,6 +3169,26 @@ function enabledCodexSkills( return Array.from(skillsByName.values()); } +// Composer autocomplete inserts skills as `/name` anywhere in the prompt, while +// Codex only injects a skill it receives as a skill input item. +const SKILL_MENTION_PATTERN = /(^|\s)\/([^\s/"']+)/g; + +function resolveCodexSkillMentions( + text: string, + skills: ReadonlyArray<{ name: string; path: string }>, +): { skills: CodexSkillPromptBlock[]; text: string } { + const mentioned = new Map(); + const rewritten = text.replace(SKILL_MENTION_PATTERN, (mention, prefix: string, name: string) => { + const skill = skills.find((entry) => entry.name === name); + if (!skill) { + return mention; + } + mentioned.set(skill.name, { type: "skill", name: skill.name, path: skill.path }); + return `${prefix}$${skill.name}`; + }); + return { skills: Array.from(mentioned.values()), text: rewritten }; +} + type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock; type CodexPromptInput = string | CodexPromptContentBlock[]; interface CodexTextElement { @@ -4063,6 +4083,18 @@ export class CodexAppServerAgentSession implements AgentSession { return args ? `$${commandName} ${args}` : `$${commandName}`; } + private async attachMentionedSkills(prompt: AgentPromptInput): Promise { + if (typeof prompt !== "string" || !prompt.includes("/")) { + return prompt; + } + await this.loadSkills(); + const resolved = resolveCodexSkillMentions(prompt, this.cachedSkills ?? []); + if (resolved.skills.length === 0) { + return prompt; + } + return [...resolved.skills, { type: "text", text: resolved.text }]; + } + private async buildTurnStartParams( prompt: CodexPromptInput, options?: AgentRunOptions, @@ -4266,7 +4298,7 @@ export class CodexAppServerAgentSession implements AgentSession { const slashCommand = await this.resolveSlashCommandInvocation(prompt); const effectivePrompt = slashCommand ? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args) - : prompt; + : await this.attachMentionedSkills(prompt); if (this.currentThreadId) { await this.ensureThreadLoaded();