fix(codex): invoke a skill picked from autocomplete after prose

Composer autocomplete inserts a Codex skill as /name anywhere in the
prompt, but only a prompt starting with /name was sent to Codex as a
skill input item. A skill placed after other text reached Codex as plain
text and was never injected.

Resolve /name mentions of enabled skills in the prompt into skill input
items and $name text, the same shape the leading form already sends.

Closes #5008
This commit is contained in:
paseo-bot[bot]
2026-09-26 17:31:15 +02:00
parent c906c2f4a4
commit af27e28054
2 changed files with 86 additions and 1 deletions
@@ -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<CodexClientLike>({ 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([
{
@@ -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<string, CodexSkillPromptBlock>();
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<CodexPromptInput> {
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();