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
2 changes: 1 addition & 1 deletion src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ export class CodexAcpServer implements acp.Agent {
approvalHandler,
elicitationHandler);

if (await this.availableCommands.tryHandle(params.prompt, sessionState)) {
if (await this.availableCommands.tryHandleCommand(params.prompt, sessionState)) {
logger.log("Prompt handled by a command");
return {
stopReason: "end_turn",
Expand Down
42 changes: 15 additions & 27 deletions src/CodexCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,6 @@ export class CodexCommands {
}
}

async tryHandle(prompt: acp.ContentBlock[], sessionState: SessionState): Promise<boolean> {
const command = this.parseCommand(prompt);
if (command) {
return this.handleCommand(command, sessionState);
}
return false;
}

private buildAvailableCommands(skillsEntries: SkillsListEntry[]): AvailableCommand[] {
const commands = new Map<string, AvailableCommand>();

Expand Down Expand Up @@ -99,30 +91,26 @@ export class CodexCommands {
];
}

private parseCommand(prompt: acp.ContentBlock[]): ParsedCommand | null {
if (prompt.length !== 1) return null;
const [single] = prompt;
if (!single) return null;
private getCommandName(prompt: acp.ContentBlock[]): string | null {
const firstBlock = prompt[0];
if (!firstBlock || firstBlock.type != "text") return null;

if (single.type !== "text") return null;
const trimmed = single.text.trim();
if (!trimmed.startsWith("/")) return null;
const text = firstBlock.text.trim();
if (!text.startsWith("/")) return null;

const commandText = trimmed.slice(1).trim();
const commandText = text.slice(1).trim();
if (commandText.length === 0) return null;

const [name, ...rest] = commandText.split(/\s+/);
const input = rest.join(" ").trim();
return {
name: name!!.toLowerCase(),
input: input.length > 0 ? input : null
};
const [name] = commandText.split(/\s+/);
return name?.toLowerCase() ?? null;
}

async handleCommand(command: ParsedCommand, sessionState: SessionState): Promise<boolean> {
const sessionId = sessionState.sessionId;
async tryHandleCommand(prompt: acp.ContentBlock[], sessionState: SessionState): Promise<boolean> {
const commandName = this.getCommandName(prompt)
if (commandName === null) return false;

switch (command.name) {
const sessionId = sessionState.sessionId;
switch (commandName) {
case "status": {
const session = new ACPSessionConnection(this.connection, sessionId);
const message = this.buildStatusMessage(sessionState);
Expand Down Expand Up @@ -180,7 +168,7 @@ export class CodexCommands {
return true;
}
default:
await this.sendUnknownCommandMessage(command.name, sessionId);
await this.sendUnknownCommandMessage(commandName, sessionId);
return true;
}
}
Expand Down Expand Up @@ -354,4 +342,4 @@ export class CodexCommands {
}
}

type ParsedCommand = { name: string; input: string | null };
type ParsedCommand = { name: string; };
101 changes: 62 additions & 39 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
// noinspection ES6RedundantAwait

import {describe, expect, it, vi, beforeEach} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import type {CodexAuthRequest} from "../../CodexAuthMethod";
import type * as acp from "@agentclientprotocol/sdk";
import {createTestFixture, createCodexMockTestFixture, createTestSessionState, type TestFixture} from "../acp-test-utils";
import {
createCodexMockTestFixture,
createTestFixture,
createTestSessionState,
type TestFixture
} from "../acp-test-utils";
import type {ServerNotification} from "../../app-server";
import type {SessionState} from "../../CodexAcpServer";
import {AgentMode} from "../../AgentMode";
import type {ListMcpServerStatusResponse, Model, SkillsListResponse, TurnStartParams} from "../../app-server/v2";
import type {Model, TurnStartParams} from "../../app-server/v2";
import type {RateLimitsMap} from "../../RateLimitsMap";
import {ModelId} from "../../ModelId";

Expand Down Expand Up @@ -736,28 +741,25 @@ describe('ACP server test', { timeout: 40_000 }, () => {
});

it('handles logout command', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpAgent = mockFixture.getCodexAcpAgent();

const sessionState: SessionState = createTestSessionState();
const codexAcpAgent = fixture.getCodexAcpAgent();
await codexAcpAgent.initialize({protocolVersion: 1});

const logoutSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "logout").mockResolvedValue();
fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false);

// @ts-expect-error - exercising private helper
const handled = await codexAcpAgent.availableCommands.handleCommand({ name: "logout", input: null }, sessionState);
const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []});

expect(handled).toBe(true);
expect(logoutSpy).toHaveBeenCalledTimes(1);
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/command-logout.json");
fixture.clearAcpConnectionDump();
const prompt: acp.ContentBlock[] = [{ type: "text", text: "/logout " }];
await codexAcpAgent.prompt({sessionId: newSessionResponse.sessionId, prompt: prompt });
await expect(fixture.getAcpConnectionDump(["sessionId"])).toMatchFileSnapshot("data/command-logout.json");
});

it('handles skills command', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpAgent = mockFixture.getCodexAcpAgent();

const sessionState: SessionState = createTestSessionState();
const codexAcpAgent = fixture.getCodexAcpAgent();
await codexAcpAgent.initialize({protocolVersion: 1});

const skillsResponse: SkillsListResponse = {
fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false);
vi.spyOn(fixture.getCodexAcpClient(), "listSkills").mockResolvedValue({
data: [{
cwd: "/workspace",
skills: [
Expand All @@ -766,29 +768,28 @@ describe('ACP server test', { timeout: 40_000 }, () => {
],
errors: []
}]
};
const skillsSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "listSkills").mockResolvedValue(skillsResponse);
});

// @ts-expect-error - exercising private helper
const handled = await codexAcpAgent.availableCommands.handleCommand({ name: "skills", input: null }, sessionState);
const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []});

expect(handled).toBe(true);
expect(skillsSpy).toHaveBeenCalledTimes(1);
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/command-skills.json");
fixture.clearAcpConnectionDump();
const prompt: acp.ContentBlock[] = [{ type: "text", text: "/skills " }];
await codexAcpAgent.prompt({sessionId: newSessionResponse.sessionId, prompt: prompt });

await expect(fixture.getAcpConnectionDump(["sessionId"])).toMatchFileSnapshot("data/command-skills.json");
});

it('handles mcp command', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpAgent = mockFixture.getCodexAcpAgent();

const sessionState: SessionState = createTestSessionState();
const codexAcpAgent = fixture.getCodexAcpAgent();
await codexAcpAgent.initialize({protocolVersion: 1});

const mcpResponse: ListMcpServerStatusResponse = {
fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false);
vi.spyOn(fixture.getCodexAcpClient(), "listMcpServers").mockResolvedValue({
data: [
{
name: "fs",
tools: { listFiles: { name: "listFiles", inputSchema: { type: "object" } } },
resources: [{ name: "workspace", uri: "file:///workspace" }],
tools: {listFiles: {name: "listFiles", inputSchema: {type: "object"}}},
resources: [{name: "workspace", uri: "file:///workspace"}],
resourceTemplates: [],
authStatus: "bearerToken"
},
Expand All @@ -801,15 +802,37 @@ describe('ACP server test', { timeout: 40_000 }, () => {
}
],
nextCursor: null
};
const mcpSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "listMcpServers").mockResolvedValue(mcpResponse);
});

// @ts-expect-error - exercising private helper
const handled = await codexAcpAgent.availableCommands.handleCommand({ name: "mcp", input: null }, sessionState);
const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []});

fixture.clearAcpConnectionDump();
const prompt: acp.ContentBlock[] = [{ type: "text", text: "/mcp " }];
await codexAcpAgent.prompt({sessionId: newSessionResponse.sessionId, prompt: prompt });
await expect(fixture.getAcpConnectionDump(["sessionId"])).toMatchFileSnapshot("data/command-mcp.json");
});

it('handles builtin slash command locally when prompt has attachments', async () => {
const codexAcpAgent = fixture.getCodexAcpAgent();
await codexAcpAgent.initialize({protocolVersion: 1});

fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false);

const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []});
const prompt: acp.ContentBlock[] = [
{ type: "text", text: "/status " },
{
type: "resource_link",
name: "editor.xml",
uri: "file:///editor.xml",
description: "File that is opened in the IDE and is currently viewed by the user",
},
];

expect(handled).toBe(true);
expect(mcpSpy).toHaveBeenCalledTimes(1);
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/command-mcp.json");
fixture.clearAcpConnectionDump();
await codexAcpAgent.prompt({sessionId: newSessionResponse.sessionId, prompt: prompt });
const transportDump = fixture.getAcpConnectionDump([]);
expect(transportDump).contain(`**Session:** \`${newSessionResponse.sessionId}\``);
});

const mockModels: Model[] = [
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/CodexACPAgent/data/command-logout.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-id",
"sessionId": "sessionId",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/CodexACPAgent/data/command-mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-id",
"sessionId": "sessionId",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/CodexACPAgent/data/command-skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-id",
"sessionId": "sessionId",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
Expand Down
Loading