From fb4403caf255009fba5393db35ae458ec199ad38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yal=C3=A7=C4=B1n=20Doksanbir?= Date: Fri, 4 Sep 2026 01:36:13 +0300 Subject: [PATCH 1/3] fix(codex): send skill mentions as typed skill input --- .../Drivers/CodexSkillDispatch.test.ts | 33 ++++++++++ .../provider/Drivers/CodexSkillDispatch.ts | 63 +++++++++++++++++++ .../Layers/CodexSessionRuntime.test.ts | 16 +++++ .../provider/Layers/CodexSessionRuntime.ts | 52 ++++++++++++++- docs/user/composer.md | 3 + 5 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/Drivers/CodexSkillDispatch.test.ts create mode 100644 apps/server/src/provider/Drivers/CodexSkillDispatch.ts diff --git a/apps/server/src/provider/Drivers/CodexSkillDispatch.test.ts b/apps/server/src/provider/Drivers/CodexSkillDispatch.test.ts new file mode 100644 index 000000000000..439602dac1d0 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexSkillDispatch.test.ts @@ -0,0 +1,33 @@ +import { assert, it } from "@effect/vitest"; + +import { hasCodexSkillMention, resolveCodexSkillMentions } from "./CodexSkillDispatch.ts"; + +const skills = [ + { name: "ask-matt", path: "/home/theo/.agents/skills/ask-matt/SKILL.md", enabled: true }, + { name: "release", path: "/home/theo/.agents/skills/release/SKILL.md", enabled: true }, + { name: "retired", path: "/home/theo/.agents/skills/retired/SKILL.md", enabled: false }, +]; + +it("gates the catalog read on a skill token", () => { + assert.isTrue(hasCodexSkillMention("$ask-matt which flow fits?")); + assert.isTrue(hasCodexSkillMention("please run $release")); + assert.isFalse(hasCodexSkillMention("costs $5 and echo $ is fine")); + assert.isFalse(hasCodexSkillMention("email me@$host")); +}); + +it("resolves each mentioned skill once, in first-mention order", () => { + assert.deepEqual( + resolveCodexSkillMentions("$release then $ask-matt and $release again", skills), + [ + { name: "release", path: "/home/theo/.agents/skills/release/SKILL.md" }, + { name: "ask-matt", path: "/home/theo/.agents/skills/ask-matt/SKILL.md" }, + ], + ); +}); + +it("leaves unknown and disabled mentions as prose", () => { + assert.deepEqual(resolveCodexSkillMentions("set $HOME and try $retired", skills), []); + assert.deepEqual(resolveCodexSkillMentions("no tokens here", skills), []); + // A token is whitespace-delimited, as the composer chip is. + assert.deepEqual(resolveCodexSkillMentions("$ask-matt, please", skills), []); +}); diff --git a/apps/server/src/provider/Drivers/CodexSkillDispatch.ts b/apps/server/src/provider/Drivers/CodexSkillDispatch.ts new file mode 100644 index 000000000000..795b5aa4cd7e --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexSkillDispatch.ts @@ -0,0 +1,63 @@ +/** + * CodexSkillDispatch — turns `$skill` mentions in a composer prompt into the + * typed `skill` input the Codex app server loads. + * + * The composer inserts `$name` for every provider. The Codex app server takes + * that as prose, and a skill that only the user may start + * (`allow_implicit_invocation: false`) is absent from the catalog the model + * sees, so the prompt names a skill the model cannot reach. Verified against + * Codex 0.152.1 by pointing it at a recording model endpoint: only a + * `{ type: "skill", name, path }` input item makes the app server inject the + * SKILL.md body into the turn; plain text and `mention` items do not. The item + * goes out beside the text, so the user's words are kept as written. + * + * The same run showed the app server dropping skill items from input that + * steers a turn already running, so the item only lands on a turn Codex starts + * fresh. That limit is Codex's own and nothing here works around it. + * + * @module provider/Drivers/CodexSkillDispatch + */ + +/** + * Same token shape the composer and timeline chips recognise + * (`packages/shared/src/composerInlineTokens.ts`) and that Claude dispatch + * uses, so a rendered chip and a dispatched skill are always the same set. + */ +const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const SKILL_MENTION_TEST = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/; + +export interface CodexSkillInput { + readonly name: string; + readonly path: string; +} + +/** Cheap gate for the catalog read: a prompt with no `$name` token needs none. */ +export function hasCodexSkillMention(prompt: string): boolean { + return SKILL_MENTION_TEST.test(prompt); +} + +/** + * Every discovered, enabled skill the prompt names, once each in first-mention + * order. Mentions that name nothing discovered stay literal: a `$HOME` in prose + * must not become an invocation. + */ +export function resolveCodexSkillMentions( + prompt: string, + skills: ReadonlyArray<{ + readonly name: string; + readonly path: string; + readonly enabled: boolean; + }>, +): ReadonlyArray { + const pathByName = new Map(); + for (const skill of skills) { + if (skill.enabled && !pathByName.has(skill.name)) pathByName.set(skill.name, skill.path); + } + const resolved = new Map(); + for (const match of prompt.matchAll(SKILL_MENTION_PATTERN)) { + const name = match[2] ?? ""; + const path = pathByName.get(name); + if (path !== undefined && !resolved.has(name)) resolved.set(name, path); + } + return Array.from(resolved, ([name, path]) => ({ name, path })); +} diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 6a6cec5b1e61..aeb06dc4cb9c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -134,6 +134,22 @@ describe("buildTurnStartParams", () => { }); }); + it.effect("sends each named skill as a typed item beside the prompt", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "$ask-matt which flow fits?", + skills: [{ name: "ask-matt", path: "/home/theo/.agents/skills/ask-matt/SKILL.md" }], + }); + + NodeAssert.deepStrictEqual(params.input, [ + { type: "text", text: "$ask-matt which flow fits?" }, + { type: "skill", name: "ask-matt", path: "/home/theo/.agents/skills/ask-matt/SKILL.md" }, + ]); + }), + ); + it("includes default collaboration mode and image attachments", () => { const params = Effect.runSync( buildTurnStartParams({ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index d83489763f5c..cf8a81bf1e07 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -38,6 +38,11 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; +import { + hasCodexSkillMention, + resolveCodexSkillMentions, + type CodexSkillInput, +} from "../Drivers/CodexSkillDispatch.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); @@ -127,10 +132,23 @@ const McpElicitationForm = Schema.Struct({ const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); const isMcpElicitationForm = Schema.is(McpElicitationForm); +// The vendored schema predates typed skill invocations: Codex 0.152 accepts +// `{ type: "skill", name, path }` beside `mention`, and only that item makes +// the app server load the SKILL.md body (see CodexSkillDispatch). +const CodexSkillUserInput = Schema.Struct({ + type: Schema.Literal("skill"), + name: Schema.String, + path: Schema.String, +}); +const CodexTurnStartUserInput = Schema.Union([ + EffectCodexSchema.V2TurnStartParams__UserInput, + CodexSkillUserInput, +]); // TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated -// `V2TurnStartParams` schema includes `collaborationMode` directly. +// `V2TurnStartParams` schema includes `collaborationMode` and the skill input directly. const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ + input: Schema.Array(CodexTurnStartUserInput), collaborationMode: Schema.optionalKey(EffectCodexSchema.V2TurnStartParams__CollaborationMode), }), ); @@ -593,6 +611,8 @@ export function buildTurnStartParams(input: { readonly threadId: string; readonly runtimeMode: RuntimeMode; readonly prompt?: string; + /** Skills the prompt names, sent as typed items so the app server loads them. */ + readonly skills?: ReadonlyArray; readonly attachments?: ReadonlyArray<{ readonly type: "image"; readonly url: string; @@ -607,13 +627,16 @@ export function buildTurnStartParams(input: { CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError > { - const turnInput: Array = []; + const turnInput: Array = []; if (input.prompt) { turnInput.push({ type: "text", text: input.prompt, }); } + for (const skill of input.skills ?? []) { + turnInput.push({ type: "skill", name: skill.name, path: skill.path }); + } for (const attachment of input.attachments ?? []) { turnInput.push(attachment); } @@ -2290,6 +2313,29 @@ export const makeCodexSessionRuntime = ( yield* Queue.shutdown(events); }); + // Only a prompt that names a skill pays for the catalog read, and the app + // server answers it from its own cache. A failed read sends the mention as + // text, which is what every turn did before typed skill input. + const resolveSkillsForPrompt = ( + prompt: string | undefined, + ): Effect.Effect> => + prompt !== undefined && hasCodexSkillMention(prompt) + ? client.request("skills/list", { cwds: [options.cwd] }).pipe( + Effect.map((response) => { + const entry = response.data.find((candidate) => candidate.cwd === options.cwd); + return resolveCodexSkillMentions( + prompt, + entry ? entry.skills : response.data.flatMap((candidate) => candidate.skills), + ); + }), + Effect.catch((cause) => + Effect.logWarning("Could not read Codex skills before the turn.", { cause }).pipe( + Effect.as([] as ReadonlyArray), + ), + ), + ) + : Effect.succeed([]); + return { start, getSession: Ref.get(sessionRef), @@ -2308,10 +2354,12 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); + const skills = yield* resolveSkillsForPrompt(input.input); const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, ...(input.input ? { prompt: input.input } : {}), + ...(skills.length > 0 ? { skills } : {}), ...(input.attachments ? { attachments: input.attachments } : {}), ...(normalizedModel ? { model: normalizedModel } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), diff --git a/docs/user/composer.md b/docs/user/composer.md index 1affd1631ca2..ba0b55c76601 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -223,6 +223,9 @@ the form that provider runs, so the text before and after the token is kept. Ski start, and never the agent on its own, work the same way. A skill you switched off in the provider's settings does not appear in either menu. +Codex loads a skill only on a turn it starts fresh. A skill sent while Codex is still working on the +previous message reaches it as ordinary text, so wait for that turn to finish before sending one. + Provider commands such as `/compact` only run when they open the message, so the `/` menu offers them only there. T3 Code's own commands, such as `/model` and `/plan`, and skills stay available on any line. From 180596633a5e63b84439dae70da594c6e918df7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yal=C3=A7=C4=B1n=20Doksanbir?= Date: Fri, 4 Sep 2026 01:46:54 +0300 Subject: [PATCH 2/3] fix(codex): bound the skill catalog read before a turn --- apps/server/src/provider/Layers/CodexSessionRuntime.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index cf8a81bf1e07..9c0f8dc066b1 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -2314,13 +2314,15 @@ export const makeCodexSessionRuntime = ( }); // Only a prompt that names a skill pays for the catalog read, and the app - // server answers it from its own cache. A failed read sends the mention as - // text, which is what every turn did before typed skill input. + // server answers it from its own cache. A failed or slow read sends the + // mention as text, which is what every turn did before typed skill input, + // rather than holding the turn behind a catalog that never answers. const resolveSkillsForPrompt = ( prompt: string | undefined, ): Effect.Effect> => prompt !== undefined && hasCodexSkillMention(prompt) ? client.request("skills/list", { cwds: [options.cwd] }).pipe( + Effect.timeout("5 seconds"), Effect.map((response) => { const entry = response.data.find((candidate) => candidate.cwd === options.cwd); return resolveCodexSkillMentions( From 78743c1e4022046edd1dcf03ec6fd76adb7c073b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yal=C3=A7=C4=B1n=20Doksanbir?= Date: Fri, 4 Sep 2026 02:00:19 +0300 Subject: [PATCH 3/3] docs(codex): say which chip regex the skill dispatch mirrors --- apps/server/src/provider/Drivers/CodexSkillDispatch.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexSkillDispatch.ts b/apps/server/src/provider/Drivers/CodexSkillDispatch.ts index 795b5aa4cd7e..6f0ab7c454df 100644 --- a/apps/server/src/provider/Drivers/CodexSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/CodexSkillDispatch.ts @@ -19,9 +19,13 @@ */ /** - * Same token shape the composer and timeline chips recognise - * (`packages/shared/src/composerInlineTokens.ts`) and that Claude dispatch - * uses, so a rendered chip and a dispatched skill are always the same set. + * Same token shape the timeline chip recognises on a sent message + * (`apps/web/src/components/chat/SkillInlineText.tsx`) and that Claude + * dispatch uses, so a rendered chip and a dispatched skill are always the same + * set. End of text counts as a boundary because the composer trims the prompt + * on send: a skill picked last arrives as `$name` with nothing after it. The + * editor's own regex (`packages/shared/src/composerInlineTokens.ts`) wants + * trailing whitespace only because the picker inserts one while typing. */ const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; const SKILL_MENTION_TEST = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/;