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
71 changes: 70 additions & 1 deletion src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
PermissionUpdate,
Query,
query,
renameSession,
Settings,
SDKAssistantMessageError,
SDKMessageOrigin,
Expand Down Expand Up @@ -734,6 +735,61 @@ export class ClaudeAcpAgent implements Agent {
const isLocalOnlyCommand =
firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);

// /rename <new title> — 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 <new session title>\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++;
Expand Down Expand Up @@ -2462,7 +2518,7 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[]
"todos",
];

return commands
const sdkCommands = commands
.map((command) => {
const input = command.argumentHint
? {
Expand All @@ -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 {
Expand Down
179 changes: 179 additions & 0 deletions src/tests/rename-slash-command.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>(
"@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<string, any>;
};

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<any> {
const input = new Pushable<any>();
async function* never(): AsyncGenerator<any> {
// 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();
});
});