From e88a0c1db6bce3583b14fdef54b563e743a15480 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Feb 2026 13:08:40 +0700 Subject: [PATCH] feat(server): add Claude /rewind command support --- .../claude-agent-commands.e2e.test.ts | 1 + .../providers/claude-agent-commands.test.ts | 1 + .../agent/providers/claude-agent.test.ts | 57 +++ .../server/agent/providers/claude-agent.ts | 338 +++++++++++++++++- ...ude-agent.voice-history-regression.test.ts | 73 +++- .../server/test-utils/fake-agent-client.ts | 14 +- 6 files changed, 476 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts index 3fe8cf0ac..e800e5b5b 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts @@ -51,6 +51,7 @@ describe("claude agent commands E2E", () => { // These are skills that come from CLAUDE.md configurations // At minimum we should have some commands available expect(commandNames.length).toBeGreaterThan(0); + expect(commandNames).toContain("rewind"); }, 60000); test("returns error for non-existent agent", async () => { diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts index 530cc8b8e..ac9098e5f 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts @@ -85,6 +85,7 @@ const hasClaudeCredentials = // Should have at least one command (skills are loaded from user/project settings) // The exact commands depend on what skills are configured expect(commands.length).toBeGreaterThan(0); + expect(commandNames).toContain("rewind"); await session.close(); }, 30000); diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 81aff983e..51d4d343c 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -789,6 +789,63 @@ async function startAgentMcpServer(): Promise { 120_000 ); + test( + "supports /rewind by reverting the latest file changes", + async () => { + const cwd = tmpCwd(); + const client = new ClaudeAgentClient({ logger }); + const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); + const session = await client.createSession(config); + const filePath = path.join(cwd, "rewind-target.txt"); + const tokenA = `REWIND_A_${Date.now().toString(36)}`; + const tokenB = `REWIND_B_${Date.now().toString(36)}`; + + const runPrompt = async (prompt: string): Promise => { + for await (const event of session.stream(prompt)) { + await autoApprove(session, event); + if (event.type === "turn_failed") { + throw new Error(event.error); + } + if (event.type === "turn_completed") { + break; + } + } + }; + + try { + await runPrompt( + [ + "Create a file named rewind-target.txt in the current directory.", + `Set the file content to exactly: ${tokenA}`, + "Do not add extra text or commentary.", + ].join(" ") + ); + expect(existsSync(filePath)).toBe(true); + expect(readFileSync(filePath, "utf8")).toContain(tokenA); + + await runPrompt( + [ + "Edit rewind-target.txt in place.", + `Replace the entire file content with exactly: ${tokenB}`, + "Do not add extra text or commentary.", + ].join(" ") + ); + expect(readFileSync(filePath, "utf8")).toContain(tokenB); + + const rewind = await session.run("/rewind"); + expect(rewind.finalText.toLowerCase()).toContain("rewound"); + + const contentAfterRewind = readFileSync(filePath, "utf8"); + expect(contentAfterRewind).toContain(tokenA); + expect(contentAfterRewind).not.toContain(tokenB); + } finally { + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 240_000 + ); + test( "resumes a persisted session with context preserved", async () => { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index f6c378351..ee614fb7f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -212,6 +212,21 @@ const VALID_CLAUDE_MODES = new Set( DEFAULT_MODES.map((mode) => mode.id) ); +const REWIND_COMMAND_NAME = "rewind"; +const REWIND_COMMAND: AgentSlashCommand = { + name: REWIND_COMMAND_NAME, + description: "Rewind tracked files to a previous user message", + argumentHint: "[user_message_uuid]", +}; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +type SlashCommandInvocation = { + commandName: string; + args?: string; + rawInput: string; +}; + // Orchestrator instructions moved to shared module. type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" }; @@ -672,6 +687,7 @@ class ClaudeAgentSession implements AgentSession { private activeSidechains = new Map(); private compacting = false; private queryRestartNeeded = false; + private userMessageIds: string[] = []; constructor( config: ClaudeAgentConfig, @@ -787,6 +803,12 @@ class ClaudeAgentSession implements AgentSession { // Reset cancel flag at the start of each turn to prevent stale state from previous turns this.turnCancelRequested = false; + const slashCommand = this.resolveSlashCommandInvocation(prompt); + if (slashCommand?.commandName === REWIND_COMMAND_NAME) { + yield* this.streamRewindCommand(slashCommand); + return; + } + const sdkMessage = this.toSdkUserMessage(prompt); const queue = new Pushable(); this.eventQueue = queue; @@ -1007,11 +1029,284 @@ class ClaudeAgentSession implements AgentSession { async listCommands(): Promise { const q = await this.ensureQuery(); const commands = await q.supportedCommands(); - return commands.map((cmd) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - })); + const commandMap = new Map(); + for (const cmd of commands) { + if (!commandMap.has(cmd.name)) { + commandMap.set(cmd.name, { + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + }); + } + } + if (!commandMap.has(REWIND_COMMAND_NAME)) { + commandMap.set(REWIND_COMMAND_NAME, REWIND_COMMAND); + } + return Array.from(commandMap.values()).sort((a, b) => + a.name.localeCompare(b.name) + ); + } + + private resolveSlashCommandInvocation( + prompt: AgentPromptInput + ): SlashCommandInvocation | null { + if (typeof prompt !== "string") { + return null; + } + const parsed = this.parseSlashCommandInput(prompt); + if (!parsed) { + return null; + } + return parsed.commandName === REWIND_COMMAND_NAME ? parsed : null; + } + + private parseSlashCommandInput(text: string): SlashCommandInvocation | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/") || trimmed.length <= 1) { + return null; + } + const withoutPrefix = trimmed.slice(1); + const firstWhitespaceIdx = withoutPrefix.search(/\s/); + const commandName = + firstWhitespaceIdx === -1 + ? withoutPrefix + : withoutPrefix.slice(0, firstWhitespaceIdx); + if (!commandName || commandName.includes("/")) { + return null; + } + const rawArgs = + firstWhitespaceIdx === -1 + ? "" + : withoutPrefix.slice(firstWhitespaceIdx + 1).trim(); + return rawArgs.length > 0 + ? { commandName, args: rawArgs, rawInput: trimmed } + : { commandName, rawInput: trimmed }; + } + + private async *streamRewindCommand( + invocation: SlashCommandInvocation + ): AsyncGenerator { + yield { type: "turn_started", provider: "claude" }; + yield { + type: "timeline", + provider: "claude", + item: { type: "user_message", text: invocation.rawInput }, + }; + + try { + const rewindAttempt = await this.attemptRewind(invocation.args); + if (!rewindAttempt.messageId || !rewindAttempt.result) { + yield { + type: "turn_failed", + provider: "claude", + error: + rewindAttempt.error ?? + "No prior user message available to rewind. Use /rewind .", + }; + return; + } + yield { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: this.buildRewindSuccessMessage( + rewindAttempt.messageId, + rewindAttempt.result + ), + }, + }; + yield { type: "turn_completed", provider: "claude" }; + } catch (error) { + yield { + type: "turn_failed", + provider: "claude", + error: + error instanceof Error + ? error.message + : "Failed to rewind tracked files", + }; + } + } + + private buildRewindSuccessMessage( + targetUserMessageId: string, + rewindResult: { filesChanged?: string[]; insertions?: number; deletions?: number } + ): string { + const fileCount = Array.isArray(rewindResult.filesChanged) + ? rewindResult.filesChanged.length + : undefined; + const stats: string[] = []; + if (typeof fileCount === "number") { + stats.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`); + } + if (typeof rewindResult.insertions === "number") { + stats.push(`${rewindResult.insertions} insertions`); + } + if (typeof rewindResult.deletions === "number") { + stats.push(`${rewindResult.deletions} deletions`); + } + if (stats.length > 0) { + return `Rewound tracked files to message ${targetUserMessageId} (${stats.join(", ")}).`; + } + return `Rewound tracked files to message ${targetUserMessageId}.`; + } + + private async attemptRewind( + args: string | undefined + ): Promise<{ + messageId: string | null; + result?: { filesChanged?: string[]; insertions?: number; deletions?: number }; + error?: string; + }> { + if (typeof args === "string" && args.trim().length > 0) { + const candidate = args.trim().split(/\s+/)[0] ?? ""; + if (!UUID_PATTERN.test(candidate)) { + return { + messageId: null, + error: + "Invalid message UUID. Usage: /rewind or /rewind", + }; + } + const rewindResult = await this.rewindFilesOnce(candidate); + if (rewindResult.canRewind) { + return { messageId: candidate, result: rewindResult }; + } + return { + messageId: null, + error: + rewindResult.error ?? + `No file checkpoint found for message ${candidate}.`, + }; + } + + const candidates = this.getRewindCandidateUserMessageIds(); + if (candidates.length === 0) { + return { + messageId: null, + error: + "No prior user message available to rewind. Use /rewind .", + }; + } + + let lastError: string | undefined; + for (const candidate of candidates) { + try { + const rewindResult = await this.rewindFilesOnce(candidate); + if (rewindResult.canRewind) { + return { messageId: candidate, result: rewindResult }; + } + if (rewindResult.error) { + lastError = rewindResult.error; + } + } catch (error) { + lastError = + error instanceof Error + ? error.message + : "Failed to rewind tracked files."; + } + } + + return { + messageId: null, + error: + lastError ?? + "No rewind checkpoints are currently available for this session.", + }; + } + + private async rewindFilesOnce(messageId: string): Promise<{ + canRewind: boolean; + error?: string; + filesChanged?: string[]; + insertions?: number; + deletions?: number; + }> { + try { + const query = await this.ensureFreshQuery(); + return await query.rewindFiles(messageId, { dryRun: false }); + } catch (error) { + // The Claude SDK transport can close after a rewind call. + // If that happens, mark the query stale so a follow-up attempt uses a fresh query. + this.queryRestartNeeded = true; + throw error; + } + } + + private async ensureFreshQuery(): Promise { + if (this.query) { + this.queryRestartNeeded = true; + } + return this.ensureQuery(); + } + + private getRewindCandidateUserMessageIds(): string[] { + const candidates: string[] = []; + const pushUnique = (value: string | null | undefined) => { + if ( + typeof value === "string" && + value.length > 0 && + !candidates.includes(value) + ) { + candidates.push(value); + } + }; + + const historyIds = this.readUserMessageIdsFromHistoryFile(); + for (let idx = historyIds.length - 1; idx >= 0; idx -= 1) { + pushUnique(historyIds[idx]); + } + for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) { + const item = this.persistedHistory[idx]; + if (item?.type === "user_message") { + pushUnique(item.messageId); + } + } + for (let idx = this.userMessageIds.length - 1; idx >= 0; idx -= 1) { + pushUnique(this.userMessageIds[idx]); + } + + return candidates; + } + + private readUserMessageIdsFromHistoryFile(): string[] { + if (!this.claudeSessionId) { + return []; + } + const historyPath = this.resolveHistoryPath(this.claudeSessionId); + if (!historyPath || !fs.existsSync(historyPath)) { + return []; + } + try { + const ids: string[] = []; + const content = fs.readFileSync(historyPath, "utf8"); + for (const line of content.split(/\n+/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed); + if (entry?.type === "user" && typeof entry.uuid === "string") { + ids.push(entry.uuid); + } + } catch { + // ignore malformed lines + } + } + return ids; + } catch { + return []; + } + } + + private rememberUserMessageId(messageId: string | null | undefined): void { + if (typeof messageId !== "string" || messageId.length === 0) { + return; + } + const last = this.userMessageIds[this.userMessageIds.length - 1]; + if (last === messageId) { + return; + } + this.userMessageIds.push(messageId); } private async primeSelectableModelIds(query: Query): Promise { @@ -1095,6 +1390,8 @@ class ClaudeAgentSession implements AgentSession { MCP_TIMEOUT: "600000", MCP_TOOL_TIMEOUT: "600000", }, + // Required for provider-level /rewind support. + enableFileCheckpointing: true, // If we have a session ID from a previous query (e.g., after interrupt), // resume that session to continue the conversation history. ...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}), @@ -1174,6 +1471,9 @@ class ClaudeAgentSession implements AgentSession { content.push({ type: "text", text: prompt }); } + const messageId = randomUUID(); + this.rememberUserMessageId(messageId); + return { type: "user", message: { @@ -1181,6 +1481,7 @@ class ClaudeAgentSession implements AgentSession { content, }, parent_tool_use_id: null, + uuid: messageId, session_id: this.claudeSessionId ?? "", }; } @@ -1426,17 +1727,34 @@ class ClaudeAgentSession implements AgentSession { this.compacting = false; break; } + const messageId = + typeof message.uuid === "string" && message.uuid.length > 0 + ? message.uuid + : undefined; + this.rememberUserMessageId(messageId); const content = message.message?.content; if (typeof content === "string" && content.length > 0) { // String content from user messages (e.g., local command output) events.push({ type: "timeline", - item: { type: "user_message", text: content }, + item: { + type: "user_message", + text: content, + ...(messageId ? { messageId } : {}), + }, provider: "claude", }); } else if (Array.isArray(content)) { const timelineItems = this.mapBlocksToTimeline(content, { turnContext }); for (const item of timelineItems) { + if (item.type === "user_message" && messageId && !item.messageId) { + events.push({ + type: "timeline", + item: { ...item, messageId }, + provider: "claude", + }); + continue; + } events.push({ type: "timeline", item, provider: "claude" }); } } @@ -1723,6 +2041,9 @@ class ClaudeAgentSession implements AgentSession { if (entry.isSidechain) { continue; } + if (entry.type === "user" && typeof entry.uuid === "string") { + this.rememberUserMessageId(entry.uuid); + } const items = this.convertHistoryEntry(entry); if (items.length > 0) { timeline.push(...items); @@ -2300,9 +2621,14 @@ export function convertClaudeHistoryEntry( if (entry.type === "user") { const text = extractUserMessageText(content); if (text) { + const messageId = + typeof entry.uuid === "string" && entry.uuid.length > 0 + ? entry.uuid + : undefined; timeline.push({ type: "user_message", text, + ...(messageId ? { messageId } : {}), }); } } diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index fd6e2a3f4..1a1e25912 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -12,6 +12,7 @@ import type { const sdkMocks = vi.hoisted(() => ({ query: vi.fn(), + lastQuery: null as ReturnType | null, })); vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ @@ -65,6 +66,7 @@ function buildSdkQueryMock() { setModel: vi.fn(async () => undefined), supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), }; } @@ -91,7 +93,11 @@ describe("ClaudeAgentSession history replay regression", () => { let previousClaudeConfigDir: string | undefined; beforeEach(() => { - sdkMocks.query.mockImplementation(() => buildSdkQueryMock()); + sdkMocks.query.mockImplementation(() => { + const mock = buildSdkQueryMock(); + sdkMocks.lastQuery = mock; + return mock; + }); tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-")); cwd = path.join(tempRoot, "repo"); @@ -107,6 +113,7 @@ describe("ClaudeAgentSession history replay regression", () => { [ JSON.stringify({ type: "user", + uuid: "history-user-uuid", sessionId: "history-session", cwd, message: { @@ -133,6 +140,7 @@ describe("ClaudeAgentSession history replay regression", () => { afterEach(() => { sdkMocks.query.mockReset(); + sdkMocks.lastQuery = null; if (previousClaudeConfigDir === undefined) { delete process.env.CLAUDE_CONFIG_DIR; } else { @@ -206,4 +214,67 @@ describe("ClaudeAgentSession history replay regression", () => { expect(timelineText).toContain(HISTORY_USER_MARKER); expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); }); + + test("listCommands includes rewind command", async () => { + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { + provider: "claude", + cwd, + }, + }; + + const session = await client.resumeSession(handle, { cwd }); + try { + const commands = await session.listCommands?.(); + expect(commands?.some((command) => command.name === "rewind")).toBe(true); + } finally { + await session.close(); + } + }); + + test("slash /rewind uses latest user message id from persisted history", async () => { + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { + provider: "claude", + cwd, + }, + }; + + const session = await client.resumeSession(handle, { cwd }); + const events: AgentStreamEvent[] = []; + + try { + for await (const event of session.stream("/rewind")) { + events.push(event); + if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + break; + } + } + } finally { + await session.close(); + } + + expect(events.some((event) => event.type === "turn_started")).toBe(true); + expect(events.some((event) => event.type === "turn_completed")).toBe(true); + expect(sdkMocks.lastQuery).toBeTruthy(); + expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1); + expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith( + "history-user-uuid", + { dryRun: false } + ); + }); }); diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 71a817438..c71f6e6cd 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -570,7 +570,19 @@ class FakeAgentSession implements AgentSession { return commands; } - // claude/opencode: keep stable/deterministic. + // Keep deterministic defaults for non-codex providers. + if (this.providerName === "claude") { + return [ + { name: "help", description: "Help", argumentHint: "" }, + { name: "context", description: "Context", argumentHint: "" }, + { + name: "rewind", + description: "Rewind tracked files to a previous user message", + argumentHint: "[user_message_uuid]", + }, + ]; + } + return [ { name: "help", description: "Help", argumentHint: "" }, { name: "context", description: "Context", argumentHint: "" },