Skip to content
Open
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
8 changes: 4 additions & 4 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand Down
18 changes: 12 additions & 6 deletions src/CodexCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -41,24 +41,30 @@ export class CodexCommands {
this.onLogout = onLogout;
}

async publish(sessionId: string): Promise<void> {
async publish(sessionState: SessionState): Promise<void> {
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<string, AvailableCommand>();

Expand Down Expand Up @@ -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 ?? "";
Expand Down
19 changes: 17 additions & 2 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand All @@ -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");
});
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
18 changes: 18 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface SpawnedAgentFixture {
restart(): Promise<SpawnedAgentFixture>;
writeSkill(skill: TestSkill, rootDir?: string): void;
setPermissionResponder(responder: PermissionResponder): void;
expectAvailableCommand(sessionId: string, commandName: string, timeoutMs?: number): Promise<void>;
expectPromptText(
sessionId: string,
promptText: string,
Expand Down Expand Up @@ -103,6 +104,7 @@ class RuntimePaths {

class RecordingClient implements acp.Client {
private readonly textBySessionId = new Map<string, string>();
private readonly availableCommandsBySessionId = new Map<string, acp.AvailableCommand[]>();
private readonly permissionRequestsBySessionId = new Map<string, acp.RequestPermissionRequest[]>();
private permissionResponder: PermissionResponder = () => ({
outcome: {outcome: "cancelled"},
Expand All @@ -123,6 +125,11 @@ class RecordingClient implements acp.Client {
}

async sessionUpdate(params: acp.SessionNotification): Promise<void> {
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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -204,6 +215,13 @@ class SpawnedAgentFixtureImpl implements SpawnedAgentFixture {
this.client.setPermissionResponder(responder);
}

async expectAvailableCommand(sessionId: string, commandName: string, timeoutMs = 30_000): Promise<void> {
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,
Expand Down