diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 1a80d6c..7132337 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -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", diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index c81e642..a125d71 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -41,14 +41,6 @@ export class CodexCommands { } } - async tryHandle(prompt: acp.ContentBlock[], sessionState: SessionState): Promise { - const command = this.parseCommand(prompt); - if (command) { - return this.handleCommand(command, sessionState); - } - return false; - } - private buildAvailableCommands(skillsEntries: SkillsListEntry[]): AvailableCommand[] { const commands = new Map(); @@ -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 { - const sessionId = sessionState.sessionId; + async tryHandleCommand(prompt: acp.ContentBlock[], sessionState: SessionState): Promise { + 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); @@ -180,7 +168,7 @@ export class CodexCommands { return true; } default: - await this.sendUnknownCommandMessage(command.name, sessionId); + await this.sendUnknownCommandMessage(commandName, sessionId); return true; } } @@ -354,4 +342,4 @@ export class CodexCommands { } } -type ParsedCommand = { name: string; input: string | null }; +type ParsedCommand = { name: string; }; diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 27ff179..a66a827 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -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"; @@ -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: [ @@ -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" }, @@ -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[] = [ diff --git a/src/__tests__/CodexACPAgent/data/command-logout.json b/src/__tests__/CodexACPAgent/data/command-logout.json index 3f01e6b..6514dc1 100644 --- a/src/__tests__/CodexACPAgent/data/command-logout.json +++ b/src/__tests__/CodexACPAgent/data/command-logout.json @@ -2,7 +2,7 @@ "method": "sessionUpdate", "args": [ { - "sessionId": "session-id", + "sessionId": "sessionId", "update": { "sessionUpdate": "agent_message_chunk", "content": { diff --git a/src/__tests__/CodexACPAgent/data/command-mcp.json b/src/__tests__/CodexACPAgent/data/command-mcp.json index 7798da4..adcc6d2 100644 --- a/src/__tests__/CodexACPAgent/data/command-mcp.json +++ b/src/__tests__/CodexACPAgent/data/command-mcp.json @@ -2,7 +2,7 @@ "method": "sessionUpdate", "args": [ { - "sessionId": "session-id", + "sessionId": "sessionId", "update": { "sessionUpdate": "agent_message_chunk", "content": { diff --git a/src/__tests__/CodexACPAgent/data/command-skills.json b/src/__tests__/CodexACPAgent/data/command-skills.json index efe71da..54b9591 100644 --- a/src/__tests__/CodexACPAgent/data/command-skills.json +++ b/src/__tests__/CodexACPAgent/data/command-skills.json @@ -2,7 +2,7 @@ "method": "sessionUpdate", "args": [ { - "sessionId": "session-id", + "sessionId": "sessionId", "update": { "sessionUpdate": "agent_message_chunk", "content": {