Merge pull request #56 from getpaseo/research/claude-rewind-command
Add Claude provider /rewind command support
This commit is contained in:
@@ -51,6 +51,7 @@ describe("claude agent commands E2E", () => {
|
|||||||
// These are skills that come from CLAUDE.md configurations
|
// These are skills that come from CLAUDE.md configurations
|
||||||
// At minimum we should have some commands available
|
// At minimum we should have some commands available
|
||||||
expect(commandNames.length).toBeGreaterThan(0);
|
expect(commandNames.length).toBeGreaterThan(0);
|
||||||
|
expect(commandNames).toContain("rewind");
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
test("returns error for non-existent agent", async () => {
|
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)
|
// Should have at least one command (skills are loaded from user/project settings)
|
||||||
// The exact commands depend on what skills are configured
|
// The exact commands depend on what skills are configured
|
||||||
expect(commands.length).toBeGreaterThan(0);
|
expect(commands.length).toBeGreaterThan(0);
|
||||||
|
expect(commandNames).toContain("rewind");
|
||||||
|
|
||||||
await session.close();
|
await session.close();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|||||||
@@ -789,6 +789,63 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
|||||||
120_000
|
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(
|
test(
|
||||||
"resumes a persisted session with context preserved",
|
"resumes a persisted session with context preserved",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -212,6 +212,21 @@ const VALID_CLAUDE_MODES = new Set(
|
|||||||
DEFAULT_MODES.map((mode) => mode.id)
|
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.
|
// Orchestrator instructions moved to shared module.
|
||||||
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
||||||
|
|
||||||
@@ -672,6 +687,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private activeSidechains = new Map<string, string>();
|
private activeSidechains = new Map<string, string>();
|
||||||
private compacting = false;
|
private compacting = false;
|
||||||
private queryRestartNeeded = false;
|
private queryRestartNeeded = false;
|
||||||
|
private userMessageIds: string[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: ClaudeAgentConfig,
|
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
|
// Reset cancel flag at the start of each turn to prevent stale state from previous turns
|
||||||
this.turnCancelRequested = false;
|
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 sdkMessage = this.toSdkUserMessage(prompt);
|
||||||
const queue = new Pushable<AgentStreamEvent>();
|
const queue = new Pushable<AgentStreamEvent>();
|
||||||
this.eventQueue = queue;
|
this.eventQueue = queue;
|
||||||
@@ -1007,11 +1029,284 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||||
const q = await this.ensureQuery();
|
const q = await this.ensureQuery();
|
||||||
const commands = await q.supportedCommands();
|
const commands = await q.supportedCommands();
|
||||||
return commands.map((cmd) => ({
|
const commandMap = new Map<string, AgentSlashCommand>();
|
||||||
name: cmd.name,
|
for (const cmd of commands) {
|
||||||
description: cmd.description,
|
if (!commandMap.has(cmd.name)) {
|
||||||
argumentHint: cmd.argumentHint,
|
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> {
|
private async primeSelectableModelIds(query: Query): Promise<void> {
|
||||||
@@ -1095,6 +1390,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
MCP_TIMEOUT: "600000",
|
MCP_TIMEOUT: "600000",
|
||||||
MCP_TOOL_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),
|
// If we have a session ID from a previous query (e.g., after interrupt),
|
||||||
// resume that session to continue the conversation history.
|
// resume that session to continue the conversation history.
|
||||||
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
||||||
@@ -1174,6 +1471,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
content.push({ type: "text", text: prompt });
|
content.push({ type: "text", text: prompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageId = randomUUID();
|
||||||
|
this.rememberUserMessageId(messageId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "user",
|
type: "user",
|
||||||
message: {
|
message: {
|
||||||
@@ -1181,6 +1481,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
content,
|
content,
|
||||||
},
|
},
|
||||||
parent_tool_use_id: null,
|
parent_tool_use_id: null,
|
||||||
|
uuid: messageId,
|
||||||
session_id: this.claudeSessionId ?? "",
|
session_id: this.claudeSessionId ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1426,17 +1727,34 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.compacting = false;
|
this.compacting = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const messageId =
|
||||||
|
typeof message.uuid === "string" && message.uuid.length > 0
|
||||||
|
? message.uuid
|
||||||
|
: undefined;
|
||||||
|
this.rememberUserMessageId(messageId);
|
||||||
const content = message.message?.content;
|
const content = message.message?.content;
|
||||||
if (typeof content === "string" && content.length > 0) {
|
if (typeof content === "string" && content.length > 0) {
|
||||||
// String content from user messages (e.g., local command output)
|
// String content from user messages (e.g., local command output)
|
||||||
events.push({
|
events.push({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
item: { type: "user_message", text: content },
|
item: {
|
||||||
|
type: "user_message",
|
||||||
|
text: content,
|
||||||
|
...(messageId ? { messageId } : {}),
|
||||||
|
},
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
});
|
});
|
||||||
} else if (Array.isArray(content)) {
|
} else if (Array.isArray(content)) {
|
||||||
const timelineItems = this.mapBlocksToTimeline(content, { turnContext });
|
const timelineItems = this.mapBlocksToTimeline(content, { turnContext });
|
||||||
for (const item of timelineItems) {
|
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" });
|
events.push({ type: "timeline", item, provider: "claude" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1723,6 +2041,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (entry.isSidechain) {
|
if (entry.isSidechain) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.type === "user" && typeof entry.uuid === "string") {
|
||||||
|
this.rememberUserMessageId(entry.uuid);
|
||||||
|
}
|
||||||
const items = this.convertHistoryEntry(entry);
|
const items = this.convertHistoryEntry(entry);
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
timeline.push(...items);
|
timeline.push(...items);
|
||||||
@@ -2300,9 +2621,14 @@ export function convertClaudeHistoryEntry(
|
|||||||
if (entry.type === "user") {
|
if (entry.type === "user") {
|
||||||
const text = extractUserMessageText(content);
|
const text = extractUserMessageText(content);
|
||||||
if (text) {
|
if (text) {
|
||||||
|
const messageId =
|
||||||
|
typeof entry.uuid === "string" && entry.uuid.length > 0
|
||||||
|
? entry.uuid
|
||||||
|
: undefined;
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "user_message",
|
type: "user_message",
|
||||||
text,
|
text,
|
||||||
|
...(messageId ? { messageId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-1
@@ -12,6 +12,7 @@ import type {
|
|||||||
|
|
||||||
const sdkMocks = vi.hoisted(() => ({
|
const sdkMocks = vi.hoisted(() => ({
|
||||||
query: vi.fn(),
|
query: vi.fn(),
|
||||||
|
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||||
@@ -65,6 +66,7 @@ function buildSdkQueryMock() {
|
|||||||
setModel: vi.fn(async () => undefined),
|
setModel: vi.fn(async () => undefined),
|
||||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
supportedCommands: vi.fn(async () => []),
|
supportedCommands: vi.fn(async () => []),
|
||||||
|
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +93,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
let previousClaudeConfigDir: string | undefined;
|
let previousClaudeConfigDir: string | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
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-"));
|
tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-"));
|
||||||
cwd = path.join(tempRoot, "repo");
|
cwd = path.join(tempRoot, "repo");
|
||||||
@@ -107,6 +113,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
[
|
[
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "user",
|
type: "user",
|
||||||
|
uuid: "history-user-uuid",
|
||||||
sessionId: "history-session",
|
sessionId: "history-session",
|
||||||
cwd,
|
cwd,
|
||||||
message: {
|
message: {
|
||||||
@@ -133,6 +140,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
sdkMocks.query.mockReset();
|
sdkMocks.query.mockReset();
|
||||||
|
sdkMocks.lastQuery = null;
|
||||||
if (previousClaudeConfigDir === undefined) {
|
if (previousClaudeConfigDir === undefined) {
|
||||||
delete process.env.CLAUDE_CONFIG_DIR;
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
} else {
|
} else {
|
||||||
@@ -206,4 +214,67 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
expect(timelineText).toContain(HISTORY_USER_MARKER);
|
expect(timelineText).toContain(HISTORY_USER_MARKER);
|
||||||
expect(timelineText).toContain(HISTORY_ASSISTANT_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;
|
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 [
|
return [
|
||||||
{ name: "help", description: "Help", argumentHint: "" },
|
{ name: "help", description: "Help", argumentHint: "" },
|
||||||
{ name: "context", description: "Context", argumentHint: "" },
|
{ name: "context", description: "Context", argumentHint: "" },
|
||||||
|
|||||||
Reference in New Issue
Block a user