diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index f5d628a0..bba989f5 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -406,7 +406,7 @@ export class CodexAcpServer { this.publishMcpStartupStatusAsync(sessionId); } - this.publishAvailableCommandsAsync(sessionId); + this.publishAvailableCommandsAsync(sessionState); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -796,8 +796,8 @@ export class CodexAcpServer { return !isJetBrains2026_1Client(this.clientInfo); } - private publishAvailableCommandsAsync(sessionId: string) { - void this.availableCommands.publish(sessionId); + private publishAvailableCommandsAsync(sessionState: SessionState) { + void this.availableCommands.publish(sessionState); } private findCurrentModel(models: Model[], currentModelId: string): Model | undefined { @@ -909,7 +909,7 @@ export class CodexAcpServer { this.publishMcpStartupStatusAsync(sessionId); } - await this.availableCommands.publish(sessionId); + await this.availableCommands.publish(sessionState); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 457d911e..c67cb4d1 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -2,7 +2,7 @@ import type * as acp from "@agentclientprotocol/sdk"; import type {AvailableCommand} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection} from "./ACPSessionConnection"; import type {CodexAcpClient} from "./CodexAcpClient"; -import type {RateLimitSnapshot, ReviewTarget, SkillsListEntry, TurnCompletedNotification} from "./app-server/v2"; +import type {RateLimitSnapshot, ReviewTarget, SkillsListEntry, SkillsListParams, TurnCompletedNotification} from "./app-server/v2"; import type {SessionState} from "./CodexAcpServer"; import type {RateLimitsMap} from "./RateLimitsMap"; import type {TokenCount} from "./TokenCount"; @@ -41,24 +41,30 @@ export class CodexCommands { this.onLogout = onLogout; } - async publish(sessionId: string): Promise { + async publish(sessionState: SessionState): Promise { try { - const skillsResponse = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills()); + const skillsResponse = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills(this.createSkillsListParams(sessionState))); const availableCommands = this.buildAvailableCommands(skillsResponse?.data ?? []); if (availableCommands.length === 0) { return; } - const session = new ACPSessionConnection(this.connection, sessionId); + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); await session.update({ sessionUpdate: "available_commands_update", availableCommands }); } catch (err) { - logger.error(`Failed to publish available commands for session ${sessionId}`, err); + logger.error(`Failed to publish available commands for session ${sessionState.sessionId}`, err); } } + private createSkillsListParams(sessionState: SessionState): SkillsListParams { + return { + cwds: [sessionState.cwd, ...sessionState.additionalDirectories], + }; + } + private buildAvailableCommands(skillsEntries: SkillsListEntry[]): AvailableCommand[] { const commands = new Map(); @@ -212,7 +218,7 @@ export class CodexCommands { return { handled: true }; } case "skills": { - const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills()); + const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills(this.createSkillsListParams(sessionState))); const skills = (response?.data ?? []).flatMap(entry => entry.skills); const lines = skills.map(skill => { const description = skill.shortDescription ?? skill.description ?? ""; diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index dd68685c..826bcc7e 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1250,7 +1250,14 @@ describe('ACP server test', { timeout: 40_000 }, () => { vi.spyOn(mockFixture.getCodexAcpClient(), "listSkills").mockResolvedValue({ data: [] }); // @ts-expect-error - exercising private helper - await codexAcpAgent.availableCommands.publish("session-id"); + await codexAcpAgent.availableCommands.publish(createTestSessionState({ + sessionId: "session-id", + cwd: "/workspace", + })); + + expect(mockFixture.getCodexAcpClient().listSkills).toHaveBeenCalledWith({ + cwds: ["/workspace"], + }); await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/available-commands-build-in.json"); }); @@ -1275,7 +1282,15 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); // @ts-expect-error - exercising private helper - await codexAcpAgent.availableCommands.publish("session-id"); + await codexAcpAgent.availableCommands.publish(createTestSessionState({ + sessionId: "session-id", + cwd: "/workspace", + additionalDirectories: ["/workspace/extra"], + })); + + expect(mockFixture.getCodexAcpClient().listSkills).toHaveBeenCalledWith({ + cwds: ["/workspace", "/workspace/extra"], + }); await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/available-commands-skills.json"); }); diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts index 84b8ee6e..9d5284c5 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts @@ -128,4 +128,21 @@ describeE2E("E2E tests", () => { expect(text).toContain("- session-root-skill: Session root skill"); }); }); + + it("lists repo skills from the session cwd", async () => { + fixture = await createAuthenticatedFixture(); + fixture.writeSkill({ + name: "workspace-skill", + description: "Workspace skill", + body: "This skill exists only in the session workspace.", + }, path.join(fixture.workspaceDir, ".agents", "skills")); + + const session = await fixture.createSession(); + + await fixture.expectAvailableCommand(session.sessionId, "$workspace-skill"); + await fixture.expectPromptText(session.sessionId, "/skills", (text) => { + expect(text).toContain("Available skills:"); + expect(text).toContain("- workspace-skill: Workspace skill"); + }); + }); }); diff --git a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts index 583f9501..3ed17c3c 100644 --- a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts +++ b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts @@ -25,6 +25,7 @@ export interface SpawnedAgentFixture { restart(): Promise; writeSkill(skill: TestSkill, rootDir?: string): void; setPermissionResponder(responder: PermissionResponder): void; + expectAvailableCommand(sessionId: string, commandName: string, timeoutMs?: number): Promise; expectPromptText( sessionId: string, promptText: string, @@ -103,6 +104,7 @@ class RuntimePaths { class RecordingClient implements acp.Client { private readonly textBySessionId = new Map(); + private readonly availableCommandsBySessionId = new Map(); private readonly permissionRequestsBySessionId = new Map(); private permissionResponder: PermissionResponder = () => ({ outcome: {outcome: "cancelled"}, @@ -123,6 +125,11 @@ class RecordingClient implements acp.Client { } async sessionUpdate(params: acp.SessionNotification): Promise { + if (params.update.sessionUpdate === "available_commands_update") { + this.availableCommandsBySessionId.set(params.sessionId, params.update.availableCommands); + return; + } + if (params.update.sessionUpdate !== "agent_message_chunk" || params.update.content.type !== "text") { return; } @@ -135,6 +142,10 @@ class RecordingClient implements acp.Client { return this.textBySessionId.get(sessionId) ?? ""; } + readAvailableCommands(sessionId: string): acp.AvailableCommand[] { + return this.availableCommandsBySessionId.get(sessionId) ?? []; + } + readPermissionRequests( sessionId: string, toolCallKind: acp.ToolKind, @@ -204,6 +215,13 @@ class SpawnedAgentFixtureImpl implements SpawnedAgentFixture { this.client.setPermissionResponder(responder); } + async expectAvailableCommand(sessionId: string, commandName: string, timeoutMs = 30_000): Promise { + await vi.waitFor(() => { + const commandNames = this.client.readAvailableCommands(sessionId).map(command => command.name); + expect(commandNames).toContain(commandName); + }, {timeout: timeoutMs}); + } + async expectPromptText( sessionId: string, promptText: string,