Skip to content

Commit fe120c7

Browse files
feat: add skill acp handling
1 parent f799cc8 commit fe120c7

3 files changed

Lines changed: 85 additions & 5 deletions

File tree

package-lock.json

Lines changed: 7 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/CodexAcpClient.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {JsonValue} from "./app-server/serde_json/JsonValue";
2020
import {ModelId} from "./ModelId";
2121
import {AgentMode} from "./AgentMode";
2222
import path from "node:path";
23+
import {fileURLToPath} from "node:url";
2324
import {logger} from "./Logger";
2425
import type {
2526
AccountLoginCompletedNotification,
@@ -37,6 +38,11 @@ import type {
3738
import packageJson from "../package.json";
3839
import type {AuthenticationStatusResponse} from "./AcpExtensions";
3940

41+
const FILE_URI_PREFIX = "file://";
42+
// From the Codex Rust implementation, `codex-rs/core-skills/src/loader.rs` defines `SKILLS_FILENAME = "SKILL.md"` and only parses files whose discovered filename exactly matches that value. The app-server README and bundled skill-creator sample also document `SKILL.md` as the required file for a skill. There is
43+
// some UI/mention handling that recognizes paths ending in `SKILL.md`, but the actual loader discovery path is hardcoded around this filename.
44+
const SKILL_FILE_NAME = "SKILL.md";
45+
4046
/**
4147
* API for accessing the Codex App Server using ACP requests.
4248
* Converts ACP requests into corresponding app-server operations.
@@ -584,8 +590,13 @@ function buildPromptItems(prompt: acp.ContentBlock[]): UserInput[] {
584590
const url = block.uri ?? `data:${block.mimeType};base64,${block.data}`;
585591
return {type: "image", url};
586592
}
587-
case "resource_link":
593+
case "resource_link": {
594+
const skillInput = buildSkillInput(block);
595+
if (skillInput !== null) {
596+
return skillInput;
597+
}
588598
return {type: "text", text: formatUriAsLink(block.name, block.uri), text_elements: []};
599+
}
589600
case "resource": {
590601
const resource = block.resource as EmbeddedResourceResource;
591602
if ("text" in resource) {
@@ -605,14 +616,51 @@ function formatUriAsLink(name: string | null | undefined, uri: string): string {
605616
if (name && name.length > 0) {
606617
return `[@${name}](${uri})`;
607618
}
608-
if (uri.startsWith("file://")) {
609-
const path = uri.replace("file://", "");
619+
if (uri.startsWith(FILE_URI_PREFIX)) {
620+
const path = uri.replace(FILE_URI_PREFIX, "");
610621
const fileName = path.split("/").pop() ?? path;
611622
return `[@${fileName}](${uri})`;
612623
}
613624
return uri;
614625
}
615626

627+
function buildSkillInput(block: acp.ResourceLink): UserInput | null {
628+
const skillPath = parseSkillPath(block.uri);
629+
if (skillPath === null) {
630+
return null;
631+
}
632+
633+
const name = readSkillName(block, skillPath);
634+
if (name === null) {
635+
return null;
636+
}
637+
638+
return {type: "skill", name, path: skillPath};
639+
}
640+
641+
function parseSkillPath(uri: string): string | null {
642+
if (!uri.startsWith(FILE_URI_PREFIX)) {
643+
return null;
644+
}
645+
646+
let filePath: string;
647+
try {
648+
filePath = fileURLToPath(uri);
649+
} catch {
650+
return null;
651+
}
652+
653+
return path.basename(filePath) === SKILL_FILE_NAME ? filePath : null;
654+
}
655+
656+
function readSkillName(block: acp.ResourceLink, skillPath: string): string | null {
657+
const rawName = block.name === SKILL_FILE_NAME
658+
? path.basename(path.dirname(skillPath))
659+
: block.name;
660+
const name = rawName.trim().replace(/^\$/, "");
661+
return name.length > 0 ? name : null;
662+
}
663+
616664
interface GatewayConfig {
617665
modelProvider: string;
618666
config: {

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,33 @@ describe('ACP server test', { timeout: 40_000 }, () => {
397397
expect(listSkillsSpy.mock.invocationCallOrder[0]!).toBeLessThan(turnStartSpy.mock.invocationCallOrder[0]!);
398398
});
399399

400+
it('passes provided skill resource links as prompt skill items', async () => {
401+
const mockFixture = createCodexMockTestFixture();
402+
const codexAcpClient = mockFixture.getCodexAcpClient();
403+
const codexAppServerClient = mockFixture.getCodexAppServerClient();
404+
405+
vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []});
406+
const runTurnSpy = vi.spyOn(codexAppServerClient, "runTurn").mockResolvedValue({
407+
threadId: "session-id",
408+
turn: createTurn("turn-id", "completed"),
409+
} as any);
410+
411+
await codexAcpClient.sendPrompt({
412+
sessionId: "session-id",
413+
prompt: [
414+
{type: "text", text: "$extra-skill do the work"},
415+
{type: "resource_link", name: "extra-skill", uri: "file:///skills/extra-skill/SKILL.md"},
416+
],
417+
}, AgentMode.DEFAULT_AGENT_MODE, ModelId.create("gpt-5", "medium"), false, "/workspace");
418+
419+
expect(runTurnSpy).toHaveBeenCalledWith(expect.objectContaining({
420+
input: [
421+
{type: "text", text: "$extra-skill do the work", text_elements: []},
422+
{type: "skill", name: "extra-skill", path: "/skills/extra-skill/SKILL.md"},
423+
],
424+
}));
425+
});
426+
400427
function loadNotifications(){
401428
//TODO collect logs form dev run and then load them from file to speedup
402429
const serverNotifications: ServerNotification[] = [

0 commit comments

Comments
 (0)