Skip to content
Merged
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
6 changes: 6 additions & 0 deletions apps/server/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
CodexCatalogClientFactory,
CodexCatalogService,
} from "./services/codex-catalog-service";
import { CodexCustomPromptService } from "./services/codex-custom-prompt-service";
import { ProviderCatalogService } from "./services/provider-catalog-service";
import { TerminalService } from "./services/terminal-service";
import { AttachmentService } from "./services/attachment-service";
Expand Down Expand Up @@ -370,6 +371,11 @@ export function setupContainer(mcodeDir: string): typeof container {
{ useClass: CodexCatalogClientFactory },
{ lifecycle: Lifecycle.Singleton },
);
container.register(
CodexCustomPromptService,
{ useClass: CodexCustomPromptService },
{ lifecycle: Lifecycle.Singleton },
);
container.register(
CodexCatalogService,
{ useClass: CodexCatalogService },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,15 @@ function makeProvider(
skillList: (...args: unknown[]) => unknown[] = vi.fn(() => []),
catalogService: {
currentSkills: (cwd?: string) => unknown[];
currentPrompts: () => unknown[];
refreshCustomPrompts: () => Promise<{ prompts: unknown[] }>;
refresh: (cwd?: string) => Promise<{ skills: unknown[] }>;
onSkillsChanged: (handler: () => void) => () => void;
shutdown: () => Promise<void>;
} = {
currentSkills: vi.fn(() => []),
currentPrompts: vi.fn(() => []),
refreshCustomPrompts: vi.fn(async () => ({ prompts: [] })),
refresh: vi.fn(async () => ({ skills: [] })),
onSkillsChanged: vi.fn(() => () => undefined),
shutdown: vi.fn(async () => undefined),
Expand Down Expand Up @@ -145,6 +149,8 @@ describe("CodexProvider first turn on new session", () => {
const list = vi.fn((_cwd, _providerId, skills) => skills as unknown[]);
const provider = makeProvider(list, {
currentSkills,
currentPrompts: vi.fn(() => []),
refreshCustomPrompts: vi.fn(async () => ({ prompts: [] })),
refresh,
onSkillsChanged: vi.fn(() => () => undefined),
shutdown: vi.fn(async () => undefined),
Expand Down Expand Up @@ -393,15 +399,27 @@ describe("CodexProvider first turn on new session", () => {
promptPath,
"---\ndescription: Draft a PR\n---\nDraft a PR for $FILES titled $PR_TITLE. Args: $ARGUMENTS",
);
const provider = makeProvider(vi.fn(() => [{
const prompt = {
name: "prompts:draftpr",
nativeName: "draftpr",
description: "Draft a PR",
kind: "command",
source: "user",
providers: ["codex"],
path: promptPath,
}]));
};
const refreshCustomPrompts = vi.fn(async () => ({ prompts: [prompt] }));
const provider = makeProvider(
vi.fn((_cwd, _providerId, nativeItems) => nativeItems as unknown[]),
{
currentSkills: vi.fn(() => []),
currentPrompts: vi.fn(() => []),
refreshCustomPrompts,
refresh: vi.fn(async () => ({ skills: [] })),
onSkillsChanged: vi.fn(() => () => undefined),
shutdown: vi.fn(async () => undefined),
},
);

await provider.sendTurn({
sessionId: "mcode-prompt-turn",
Expand All @@ -422,6 +440,104 @@ describe("CodexProvider first turn on new session", () => {
type: "text",
text: 'Draft a PR for src/a.ts src/b.ts titled Add files. Args: FILES="src/a.ts src/b.ts" PR_TITLE="Add files"',
}]);
expect(refreshCustomPrompts).toHaveBeenCalledTimes(1);
} finally {
rmSync(promptDir, { recursive: true, force: true });
}
});

it("uses selected catalog identity when a Skill and custom prompt share a name", async () => {
const promptDir = mkdtempSync(join(tmpdir(), "codex-provider-collision-"));
try {
const promptPath = join(promptDir, "release.md");
const skillPath = join(promptDir, "release-skill", "SKILL.md");
writeFileSync(promptPath, "Prompt release $ARGUMENTS");
const prompt = {
name: "prompts:release",
nativeName: "release",
description: "Prompt release",
kind: "command",
source: "user",
providers: ["codex"],
path: promptPath,
};
const skill = {
name: "prompts:release",
nativeName: "prompts:release",
description: "Skill release",
kind: "skill",
source: "user",
providers: ["codex"],
path: skillPath,
};
const provider = makeProvider(
vi.fn((_cwd, _providerId, nativeItems) => [skill, ...(nativeItems as unknown[])]),
{
currentSkills: vi.fn(() => [skill]),
currentPrompts: vi.fn(() => [prompt]),
refreshCustomPrompts: vi.fn(async () => ({ prompts: [prompt] })),
refresh: vi.fn(async () => ({ skills: [skill] })),
onSkillsChanged: vi.fn(() => () => undefined),
shutdown: vi.fn(async () => undefined),
},
);

await provider.sendTurn({
sessionId: "mcode-prompt-collision",
threadId: "prompt-collision",
message: "/prompts:release alpha",
mentions: [{
id: "command:command:prompts:release",
kind: "command",
label: "prompts:release",
namespace: "command",
capabilityIdentity: {
providerId: "codex",
kind: "customPrompt",
nativeId: "release",
},
range: { start: 0, end: 16 },
}],
cwd: process.cwd(),
model: "gpt-5.4",
interactionMode: "build",
providerOptions: {},
permissionMode: "auto",
});
await provider.sendTurn({
sessionId: "mcode-skill-collision",
threadId: "skill-collision",
message: "/prompts:release beta",
mentions: [{
id: "command:skill:prompts:release",
kind: "command",
label: "prompts:release",
namespace: "skill",
capabilityIdentity: {
providerId: "codex",
kind: "skill",
nativeId: skillPath,
},
range: { start: 0, end: 16 },
}],
cwd: process.cwd(),
model: "gpt-5.4",
interactionMode: "build",
providerOptions: {},
permissionMode: "auto",
});

for (let i = 0; i < 20 && sendTurnMock.mock.calls.length < 2; i++) {
await new Promise<void>((resolve) => setImmediate(resolve));
}

expect(sendTurnMock.mock.calls[0][0]).toEqual([
{ type: "text", text: "Prompt release alpha" },
]);
expect(sendTurnMock.mock.calls[1][0]).toEqual([
{ type: "skill", name: "prompts:release", path: skillPath },
{ type: "text", text: "$prompts:release beta" },
]);
} finally {
rmSync(promptDir, { recursive: true, force: true });
}
Expand All @@ -430,15 +546,26 @@ describe("CodexProvider first turn on new session", () => {
it("emits a controlled error when a listed Codex prompt cannot be read", async () => {
const promptDir = mkdtempSync(join(tmpdir(), "missing-codex-prompt-"));
try {
const provider = makeProvider(vi.fn(() => [{
const prompt = {
name: "prompts:draftpr",
nativeName: "draftpr",
description: "Draft a PR",
kind: "command",
source: "user",
providers: ["codex"],
path: join(promptDir, "draftpr.md"),
}]));
};
const provider = makeProvider(
vi.fn((_cwd, _providerId, nativeItems) => nativeItems as unknown[]),
{
currentSkills: vi.fn(() => []),
currentPrompts: vi.fn(() => []),
refreshCustomPrompts: vi.fn(async () => ({ prompts: [prompt] })),
refresh: vi.fn(async () => ({ skills: [] })),
onSkillsChanged: vi.fn(() => () => undefined),
shutdown: vi.fn(async () => undefined),
},
);
const events: AgentEvent[] = [];
provider.on("event", (event: AgentEvent) => events.push(event));

Expand Down Expand Up @@ -502,6 +629,8 @@ describe("CodexProvider first turn on new session", () => {
const refresh = vi.fn(async () => ({ skills: [nativeSkill] }));
const provider = makeProvider(undefined, {
currentSkills: vi.fn(() => []),
currentPrompts: vi.fn(() => []),
refreshCustomPrompts: vi.fn(async () => ({ prompts: [] })),
refresh,
onSkillsChanged: vi.fn(() => () => undefined),
shutdown: vi.fn(async () => undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ describe("CodexProvider permission flow", () => {
{ persistGeneratedImageFromPath: vi.fn() } as never,
{
currentSkills: vi.fn(() => []),
currentPrompts: vi.fn(() => []),
refreshCustomPrompts: vi.fn(async () => ({ prompts: [] })),
refresh: vi.fn(async () => ({ skills: [] })),
onSkillsChanged: vi.fn(() => () => undefined),
shutdown: vi.fn(async () => undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ function makeProvider(): CodexProvider {
{ persistGeneratedImageFromPath: vi.fn() } as never,
{
currentSkills: vi.fn(() => []),
currentPrompts: vi.fn(() => []),
refreshCustomPrompts: vi.fn(async () => ({ prompts: [] })),
refresh: vi.fn(async () => ({ skills: [] })),
onSkillsChanged: vi.fn(() => () => undefined),
shutdown: vi.fn(async () => undefined),
Expand Down
17 changes: 15 additions & 2 deletions apps/server/src/providers/codex/codex-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ interface CachedPromptTemplate {

const promptTemplateCache = new Map<string, CachedPromptTemplate>();

/** Returns true only for catalog items produced by the Codex custom-prompt adapter. */
export function isCodexCustomPromptCatalogItem(
item: SkillInfo,
): item is SkillInfo & { path: string } {
return (
item.kind === "command"
&& item.source === "user"
&& item.providers.includes("codex")
&& Boolean(item.path)
&& Boolean(item.nativeName)
&& item.name === `prompts:${item.nativeName}`
);
}

function stripFrontmatter(content: string): string {
return content.replace(FRONTMATTER_RE, "");
}
Expand Down Expand Up @@ -142,8 +156,7 @@ export function isCodexPromptCommand(
requestedName: string,
): item is SkillInfo & { path: string } {
return (
item.kind === "command" &&
Boolean(item.path) &&
isCodexCustomPromptCatalogItem(item) &&
item.name === requestedName
);
}
Expand Down
90 changes: 70 additions & 20 deletions apps/server/src/providers/codex/codex-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type {
PermissionRequest,
ProviderModelInfo,
ProviderUsageInfo,
ProviderCapabilityIdentity,
QuotaCategory,
SkillInfo,
} from "@mcode/contracts";
Expand Down Expand Up @@ -296,7 +297,7 @@ async function buildCodexInput(
}

const wireMessage = rewriteAgentMentionsAsSubagentUris(message, mentions);
const invocation = await resolveCodexSlashInvocation(wireMessage, skills);
const invocation = await resolveCodexSlashInvocation(wireMessage, skills, mentions);
if (invocation.skillItem) inputs.push(invocation.skillItem);
inputs.push({ type: "text", text: invocation.text });
return inputs;
Expand Down Expand Up @@ -333,36 +334,77 @@ function rewriteAgentMentionsAsSubagentUris(
async function resolveCodexSlashInvocation(
message: string,
skills: readonly SkillInfo[],
mentions: readonly MessageMention[],
): Promise<{ text: string; skillItem?: TurnInputPart }> {
const slash = parseCodexSlashInvocation(message);
if (!slash) return { text: message };

let promptCommand: (SkillInfo & { path: string }) | undefined;
for (const item of skills) {
if (item.name !== slash.requestedName && item.nativeName !== slash.requestedName) continue;

if (item.kind === "skill") {
const nativeName = item.nativeName ?? item.name.split(":").pop() ?? item.name;
const args = slash.args.trimStart();
const text = `$${nativeName}${args ? ` ${args}` : ""}`;
return {
text,
...(item.path ? { skillItem: { type: "skill" as const, name: nativeName, path: item.path } } : {}),
};
}

if (!promptCommand && isCodexPromptCommand(item, slash.requestedName)) {
promptCommand = item;
}
}
const leadingSpace = message.length - message.trimStart().length;
const commandEnd = leadingSpace + slash.requestedName.length + 1;
const selectedMention = mentions.find((mention): mention is Extract<
MessageMention,
{ kind: "command" }
> => (
mention.kind === "command"
&& mention.label === slash.requestedName
&& mention.range.start === leadingSpace
&& mention.range.end === commandEnd
&& mention.capabilityIdentity?.providerId === "codex"
));
const selectedIdentity = selectedMention?.capabilityIdentity;
const candidates = skills.filter((item) => (
item.name === slash.requestedName || item.nativeName === slash.requestedName
));
const selected = selectedIdentity
? candidates.find((item) => matchesCodexCapabilityIdentity(item, selectedIdentity))
: undefined;
const promptCommand = selectedIdentity?.kind === "customPrompt"
? selected && isCodexPromptCommand(selected, slash.requestedName) ? selected : undefined
: selectedIdentity
? undefined
: candidates.find((item) => isCodexPromptCommand(item, slash.requestedName));

if (promptCommand) {
return { text: await expandCodexPromptCommand(promptCommand, slash.args) };
}

const skill = selectedIdentity?.kind === "skill"
? selected?.kind === "skill" ? selected : undefined
: selectedIdentity
? undefined
: candidates.find((item) => item.kind === "skill");
if (skill) {
const nativeName = skill.nativeName ?? skill.name.split(":").pop() ?? skill.name;
const args = slash.args.trimStart();
const text = `$${nativeName}${args ? ` ${args}` : ""}`;
return {
text,
...(skill.path
? { skillItem: { type: "skill" as const, name: nativeName, path: skill.path } }
: {}),
};
}

return { text: message };
}

function matchesCodexCapabilityIdentity(
item: SkillInfo,
identity: ProviderCapabilityIdentity,
): boolean {
if (identity.kind === "skill") {
return item.kind === "skill"
&& (item.path ?? item.nativeName ?? item.name) === identity.nativeId;
}
if (identity.kind === "customPrompt") {
return isCodexPromptCommand(item, item.name)
&& (item.nativeName ?? item.name) === identity.nativeId;
}
return item.kind === "command"
&& !isCodexPromptCommand(item, item.name)
&& (item.nativeName ?? item.name) === identity.nativeId;
}

/** Return the generated image path from an app-server imageGeneration item. */
export function generatedImagePathFromCodexItem(item: CompletedItem | undefined): string | null {
if (!item || item.type !== "imageGeneration") return null;
Expand Down Expand Up @@ -775,7 +817,15 @@ export class CodexProvider extends EventEmitter implements IAgentProvider, IGoal
const codexFastMode = req.providerOptions.fastMode;

const nativeSkills = this.codexCatalogService.currentSkills(cwd);
const skillCatalog = this.skillService.list(cwd, "codex", nativeSkills);
const slashInvocation = parseCodexSlashInvocation(message);
const customPrompts = slashInvocation?.requestedName.startsWith("prompts:")
? (await this.codexCatalogService.refreshCustomPrompts()).prompts
: this.codexCatalogService.currentPrompts();
const skillCatalog = this.skillService.list(
cwd,
"codex",
[...nativeSkills, ...customPrompts],
);
const threadId = sessionId.startsWith("mcode-") ? sessionId.slice(6) : sessionId;
let input: TurnInputPart[];
try {
Expand Down
Loading
Loading