feat(server): add Claude /rewind command support

This commit is contained in:
Mohamed Boudra
2026-02-18 13:08:40 +07:00
parent dab08865c7
commit e88a0c1db6
6 changed files with 476 additions and 8 deletions
@@ -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 () => {
@@ -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);
@@ -789,6 +789,63 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
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<void> => {
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 () => {
@@ -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<string, string>();
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<AgentStreamEvent>();
this.eventQueue = queue;
@@ -1007,11 +1029,284 @@ class ClaudeAgentSession implements AgentSession {
async listCommands(): Promise<AgentSlashCommand[]> {
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<string, AgentSlashCommand>();
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<AgentStreamEvent> {
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 <user_message_uuid>.",
};
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 <user_message_uuid> 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 <user_message_uuid>.",
};
}
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<Query> {
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<void> {
@@ -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 } : {}),
});
}
}
@@ -12,6 +12,7 @@ import type {
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | 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 }
);
});
});
@@ -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: "" },