diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 40bb6892..1fd043fd 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -54,6 +54,7 @@ import { PermissionUpdate, Query, query, + renameSession, Settings, SDKAssistantMessageError, SDKMessageOrigin, @@ -734,6 +735,61 @@ export class ClaudeAcpAgent implements Agent { const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]); + // /rename — handled entirely inside the ACP server so the user + // can retitle a session inline without leaving the chat input. Writes a + // custom-title entry to the session JSONL via the SDK; the new title is + // surfaced through listSessions() the next time the client refreshes. + if (firstText.startsWith("/rename") && /^\/rename(\s|$)/.test(firstText)) { + const title = firstText.slice("/rename".length).trim(); + if (!title) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Usage: /rename \n" }, + }, + }); + return { stopReason: "end_turn" }; + } + try { + await renameSession(params.sessionId, title, { dir: session.cwd }); + // Notify the client so it can repaint its session list inline; the + // protocol's session_info_update carries the new title, plus a fresh + // updatedAt timestamp so sort order reflects the latest activity. + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "session_info_update", + title, + updatedAt: new Date().toISOString(), + }, + }); + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `Renamed session to "${title}".\n`, + }, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `Failed to rename session: ${message}\n`, + }, + }, + }); + } + return { stopReason: "end_turn" }; + } + if (session.promptRunning) { session.input.push(userMessage); const order = session.nextPendingOrder++; @@ -2462,7 +2518,7 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] "todos", ]; - return commands + const sdkCommands = commands .map((command) => { const input = command.argumentHint ? { @@ -2482,6 +2538,19 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] }; }) .filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name)); + + // Custom ACP-server builtins — appended so they appear in the `/` picker + // alongside SDK-provided commands. Handled inline in `prompt()` before the + // SDK is invoked. + const customBuiltins: AvailableCommand[] = [ + { + name: "rename", + description: "rename this session", + input: { hint: "new session title" }, + }, + ]; + + return [...sdkCommands, ...customBuiltins]; } function formatUriAsLink(uri: string): string { diff --git a/src/tests/rename-slash-command.test.ts b/src/tests/rename-slash-command.test.ts new file mode 100644 index 00000000..df1bfa12 --- /dev/null +++ b/src/tests/rename-slash-command.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; + +const { mockRenameSession } = vi.hoisted(() => ({ + mockRenameSession: vi.fn(async () => {}), +})); + +vi.mock("@anthropic-ai/claude-agent-sdk", async () => { + const actual = await vi.importActual>( + "@anthropic-ai/claude-agent-sdk", + ); + return { + ...actual, + renameSession: mockRenameSession, + }; +}); + +vi.mock("../tools.js", async () => ({ + createPostToolUseHook: () => () => {}, + registerHookCallback: () => {}, + toolInfoFromToolUse: () => ({}), + toolUpdateFromDiffToolResponse: () => ({}), + toolUpdateFromToolResult: () => ({}), + planEntries: () => [], +})); + +import { ClaudeAcpAgent } from "../acp-agent.js"; +import { Pushable } from "../utils.js"; + +type AnyAgent = ClaudeAcpAgent & { + sessions: Record; +}; + +function createAgent(updates: SessionNotification[]): ClaudeAcpAgent { + const mockClient = { + sessionUpdate: async (notification: SessionNotification) => { + updates.push(notification); + }, + } as unknown as AgentSideConnection; + return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); +} + +function injectSession(agent: AnyAgent, sessionId: string, cwd: string): Pushable { + const input = new Pushable(); + async function* never(): AsyncGenerator { + // Intentionally yields nothing — if /rename short-circuits correctly, the + // SDK loop should never advance. + yield* []; + } + agent.sessions[sessionId] = { + query: never() as any, + input, + cancelled: false, + cwd, + sessionFingerprint: JSON.stringify({ cwd, mcpServers: [] }), + modes: { currentModeId: "default", availableModes: [] }, + models: { currentModelId: "default", availableModels: [] }, + modelInfos: [], + settingsManager: { dispose: vi.fn() } as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + abortController: new AbortController(), + emitRawSDKMessages: false, + contextWindowSize: 200000, + }; + return input; +} + +describe("/rename slash command", () => { + beforeEach(() => { + mockRenameSession.mockClear(); + mockRenameSession.mockImplementation(async () => {}); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("calls renameSession and confirms the new title", async () => { + const updates: SessionNotification[] = []; + const agent = createAgent(updates) as AnyAgent; + injectSession(agent, "test-session", "/tmp/project"); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/rename Quarterly review notes" }], + }); + + expect(response.stopReason).toBe("end_turn"); + expect(mockRenameSession).toHaveBeenCalledExactlyOnceWith( + "test-session", + "Quarterly review notes", + { dir: "/tmp/project" }, + ); + + const texts = updates + .map((u) => (u.update as any)) + .filter((u) => u.sessionUpdate === "agent_message_chunk") + .map((u) => u.content?.text ?? ""); + expect(texts.some((t) => t.includes("Renamed session to") && t.includes("Quarterly review notes"))) + .toBe(true); + + // The client must also see a session_info_update so it can repaint the + // session list without waiting for the next listSessions() call. + const infoUpdates = updates + .map((u) => u.update as any) + .filter((u) => u.sessionUpdate === "session_info_update"); + expect(infoUpdates).toHaveLength(1); + expect(infoUpdates[0].title).toBe("Quarterly review notes"); + }); + + it("emits a usage hint when no title is supplied", async () => { + const updates: SessionNotification[] = []; + const agent = createAgent(updates) as AnyAgent; + injectSession(agent, "test-session", "/tmp/project"); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/rename" }], + }); + + expect(response.stopReason).toBe("end_turn"); + expect(mockRenameSession).not.toHaveBeenCalled(); + + const texts = updates + .map((u) => (u.update as any)) + .filter((u) => u.sessionUpdate === "agent_message_chunk") + .map((u) => u.content?.text ?? ""); + expect(texts.some((t) => t.includes("Usage: /rename"))).toBe(true); + }); + + it("surfaces SDK errors as a chat message rather than throwing", async () => { + const updates: SessionNotification[] = []; + const agent = createAgent(updates) as AnyAgent; + injectSession(agent, "test-session", "/tmp/project"); + + mockRenameSession.mockImplementation(async () => { + throw new Error("session JSONL not found"); + }); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/rename Untitled" }], + }); + + expect(response.stopReason).toBe("end_turn"); + const texts = updates + .map((u) => (u.update as any)) + .filter((u) => u.sessionUpdate === "agent_message_chunk") + .map((u) => u.content?.text ?? ""); + expect(texts.some((t) => t.includes("Failed to rename session"))).toBe(true); + }); + + it("does not match /renamed or other prefixes", async () => { + const updates: SessionNotification[] = []; + const agent = createAgent(updates) as AnyAgent; + injectSession(agent, "test-session", "/tmp/project"); + + // The fake SDK generator yields nothing, so `agent.prompt` will surface + // a "Session did not end in result" error from the normal loop. That's + // fine — what we want to confirm is that we *reached* that loop instead + // of being intercepted as a /rename call. + await expect( + agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/renamed something" }], + }), + ).rejects.toThrow(); + expect(mockRenameSession).not.toHaveBeenCalled(); + }); +});