Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/server/src/provider/Drivers/CodexSkillDispatch.test.ts
Original file line number Diff line number Diff line change
@@ -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), []);
});
67 changes: 67 additions & 0 deletions apps/server/src/provider/Drivers/CodexSkillDispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* 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 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;
Comment thread
ylcn91 marked this conversation as resolved.
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<CodexSkillInput> {
const pathByName = new Map<string, string>();
for (const skill of skills) {
if (skill.enabled && !pathByName.has(skill.name)) pathByName.set(skill.name, skill.path);
}
const resolved = new Map<string, string>();
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 }));
}
16 changes: 16 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
54 changes: 52 additions & 2 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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),
}),
);
Expand Down Expand Up @@ -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<CodexSkillInput>;
readonly attachments?: ReadonlyArray<{
readonly type: "image";
readonly url: string;
Expand All @@ -607,13 +627,16 @@ export function buildTurnStartParams(input: {
CodexTurnStartParamsWithCollaborationMode,
CodexErrors.CodexAppServerProtocolParseError
> {
const turnInput: Array<EffectCodexSchema.V2TurnStartParams__UserInput> = [];
const turnInput: Array<typeof CodexTurnStartUserInput.Type> = [];
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);
}
Expand Down Expand Up @@ -2290,6 +2313,31 @@ 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 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<ReadonlyArray<CodexSkillInput>> =>
prompt !== undefined && hasCodexSkillMention(prompt)
? client.request("skills/list", { cwds: [options.cwd] }).pipe(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Effect.timeout("5 seconds"),
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<CodexSkillInput>),
),
),
)
: Effect.succeed([]);

return {
start,
getSession: Ref.get(sessionRef),
Expand All @@ -2308,10 +2356,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 } : {}),
Expand Down
3 changes: 3 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading