Merge remote-tracking branch 'origin/main' into pr58-sync
# Conflicts: # packages/server/src/shared/messages.ts
This commit is contained in:
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+72
-1
@@ -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: "" },
|
||||
|
||||
@@ -1678,6 +1678,7 @@ const CheckoutPrStatusSchema = z.object({
|
||||
state: z.string(),
|
||||
baseRefName: z.string(),
|
||||
headRefName: z.string(),
|
||||
isMerged: z.boolean(),
|
||||
})
|
||||
|
||||
export const CheckoutPrStatusResponseSchema = z.object({
|
||||
|
||||
@@ -471,6 +471,109 @@ describe("checkout git utilities", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("returns merged PR status when no open PR exists for the current branch", async () => {
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||
|
||||
const fakeBinDir = join(tempDir, "fake-bin-gh-merged");
|
||||
mkdirSync(fakeBinDir);
|
||||
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
|
||||
symlinkSync(gitPath, join(fakeBinDir, "git"));
|
||||
writeFileSync(
|
||||
join(fakeBinDir, "gh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"if [[ \"${1-}\" == \"--version\" ]]; then",
|
||||
" echo \"gh version 2.0.0\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"args=\"$*\"",
|
||||
"if [[ \"$args\" == *\"state=open\"* ]]; then",
|
||||
" echo \"[]\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if [[ \"$args\" == *\"state=closed\"* ]]; then",
|
||||
" echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/123\",\"title\":\"Ship feature\",\"state\":\"closed\",\"merged_at\":\"2026-02-18T00:00:00Z\",\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"echo \"unexpected gh args: $args\" >&2",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
|
||||
try {
|
||||
const status = await getPullRequestStatus(repoDir);
|
||||
expect(status.githubFeaturesEnabled).toBe(true);
|
||||
expect(status.status).not.toBeNull();
|
||||
expect(status.status?.url).toContain("/pull/123");
|
||||
expect(status.status?.baseRefName).toBe("main");
|
||||
expect(status.status?.headRefName).toBe("feature");
|
||||
expect(status.status?.isMerged).toBe(true);
|
||||
expect(status.status?.state).toBe("merged");
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat closed-unmerged PRs as shipped status", async () => {
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||
|
||||
const fakeBinDir = join(tempDir, "fake-bin-gh-closed");
|
||||
mkdirSync(fakeBinDir);
|
||||
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
|
||||
symlinkSync(gitPath, join(fakeBinDir, "git"));
|
||||
writeFileSync(
|
||||
join(fakeBinDir, "gh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"if [[ \"${1-}\" == \"--version\" ]]; then",
|
||||
" echo \"gh version 2.0.0\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"args=\"$*\"",
|
||||
"if [[ \"$args\" == *\"state=open\"* ]]; then",
|
||||
" echo \"[]\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if [[ \"$args\" == *\"state=closed\"* ]]; then",
|
||||
" echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/999\",\"title\":\"Closed without merge\",\"state\":\"closed\",\"merged_at\":null,\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"echo \"unexpected gh args: $args\" >&2",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
|
||||
try {
|
||||
const status = await getPullRequestStatus(repoDir);
|
||||
expect(status.githubFeaturesEnabled).toBe(true);
|
||||
expect(status.status).toBeNull();
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns typed MergeConflictError on merge conflicts", async () => {
|
||||
const conflictFile = join(repoDir, "conflict.txt");
|
||||
writeFileSync(conflictFile, "base\n");
|
||||
|
||||
@@ -1517,6 +1517,7 @@ export interface PullRequestStatus {
|
||||
state: string;
|
||||
baseRefName: string;
|
||||
headRefName: string;
|
||||
isMerged: boolean;
|
||||
}
|
||||
|
||||
export interface PullRequestStatusResult {
|
||||
@@ -1592,6 +1593,62 @@ async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listPullRequestsForHead(options: {
|
||||
cwd: string;
|
||||
repo: string;
|
||||
owner: string;
|
||||
head: string;
|
||||
state: "open" | "closed";
|
||||
}): Promise<any[]> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${options.repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${options.owner}:${options.head}`,
|
||||
"-F",
|
||||
`state=${options.state}`,
|
||||
],
|
||||
{ cwd: options.cwd }
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
function buildPullRequestStatus(current: any, fallbackHead: string): PullRequestStatus | null {
|
||||
if (!current || typeof current !== "object") {
|
||||
return null;
|
||||
}
|
||||
const url = current.html_url ?? current.url;
|
||||
const title = current.title;
|
||||
if (typeof url !== "string" || typeof title !== "string" || !url || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedAt =
|
||||
typeof current.merged_at === "string" && current.merged_at.trim().length > 0
|
||||
? current.merged_at
|
||||
: null;
|
||||
const state =
|
||||
mergedAt !== null
|
||||
? "merged"
|
||||
: typeof current.state === "string" && current.state.trim().length > 0
|
||||
? current.state
|
||||
: "";
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? fallbackHead,
|
||||
isMerged: mergedAt !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPullRequest(
|
||||
cwd: string,
|
||||
options: CreatePullRequestOptions
|
||||
@@ -1652,22 +1709,15 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
|
||||
};
|
||||
}
|
||||
const owner = repo.split("/")[0];
|
||||
let stdout: string;
|
||||
let openPulls: any[];
|
||||
try {
|
||||
({ stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${owner}:${head}`,
|
||||
"-F",
|
||||
"state=open",
|
||||
],
|
||||
{ cwd }
|
||||
));
|
||||
openPulls = await listPullRequestsForHead({
|
||||
cwd,
|
||||
repo,
|
||||
owner,
|
||||
head,
|
||||
state: "open",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
return {
|
||||
@@ -1677,22 +1727,50 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
||||
if (!current) {
|
||||
const openPull = openPulls[0] ?? null;
|
||||
const openStatus = buildPullRequestStatus(openPull, head);
|
||||
if (openStatus) {
|
||||
return {
|
||||
status: openStatus,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
let closedPulls: any[];
|
||||
try {
|
||||
closedPulls = await listPullRequestsForHead({
|
||||
cwd,
|
||||
repo,
|
||||
owner,
|
||||
head,
|
||||
state: "closed",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const mergedClosedPull =
|
||||
closedPulls.find(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof entry.merged_at === "string" &&
|
||||
entry.merged_at.trim().length > 0
|
||||
) ?? null;
|
||||
const mergedStatus = buildPullRequestStatus(mergedClosedPull, head);
|
||||
if (!mergedStatus) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: {
|
||||
url: current.html_url ?? current.url,
|
||||
title: current.title,
|
||||
state: current.state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? head,
|
||||
},
|
||||
status: mergedStatus,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user