diff --git a/apps/vscode/proto/cline/checkpoints.proto b/apps/vscode/proto/cline/checkpoints.proto index 7a4d7ceef6..a99a620925 100644 --- a/apps/vscode/proto/cline/checkpoints.proto +++ b/apps/vscode/proto/cline/checkpoints.proto @@ -19,9 +19,32 @@ service CheckpointsService { // "View Changes" button only when there is something to show. rpc checkpointLatestChangesCount(EmptyRequest) returns (Int64); // Reports whether Reset Code can restore the workspace from the user - // message with the given ts. Asked when the user opens a message for - // editing, so the answer reflects the checkpoints that exist right then. - rpc checkpointExistsForMessage(Int64Request) returns (Boolean); + // message with the given ts, and if not, why not. Asked when the user opens + // a message for editing, so the answer reflects the checkpoints that exist + // right then. + rpc checkpointExistsForMessage(Int64Request) returns (CheckpointExistsForMessageResponse); +} + +// Why Reset Code cannot restore the workspace from a user message. Kept in +// sync with the webview's WorkspaceRestoreAvailability reasons. +enum CheckpointUnavailableReason { + CHECKPOINT_UNAVAILABLE_REASON_UNSPECIFIED = 0; + // Checkpoints are disabled in settings right now. + CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINTS_DISABLED = 1; + // The workspace was not a git repository when the turn ran, so no + // checkpoint could be taken. Initializing git enables future checkpoints. + CHECKPOINT_UNAVAILABLE_REASON_NO_GIT_REPOSITORY = 2; + // Checkpoint creation was attempted for the turn but failed. + CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_FAILED = 3; + // The turn's checkpoint could not be located (for example, the transcript + // no longer maps to it after a reload). No more specific cause is known. + CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_UNAVAILABLE = 4; +} + +message CheckpointExistsForMessageResponse { + bool exists = 1; + // Set only when exists is false. + CheckpointUnavailableReason reason = 2; } message CheckpointRestoreRequest { diff --git a/apps/vscode/src/core/controller/checkpoints/checkpointExistsForMessage.ts b/apps/vscode/src/core/controller/checkpoints/checkpointExistsForMessage.ts index f5105e6c8a..724f40cfff 100644 --- a/apps/vscode/src/core/controller/checkpoints/checkpointExistsForMessage.ts +++ b/apps/vscode/src/core/controller/checkpoints/checkpointExistsForMessage.ts @@ -1,12 +1,30 @@ -import { Boolean, Int64Request } from "@shared/proto/cline/common" +import { CheckpointExistsForMessageResponse, CheckpointUnavailableReason } from "@shared/proto/cline/checkpoints" +import { Int64Request } from "@shared/proto/cline/common" import { Controller } from ".." +const REASON_BY_NAME = { + checkpoints_disabled: CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINTS_DISABLED, + no_git_repository: CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_NO_GIT_REPOSITORY, + checkpoint_failed: CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_FAILED, + checkpoint_unavailable: CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_UNAVAILABLE, +} as const + /** * Reports whether Reset Code can restore the workspace from the user message - * with the given ts. The webview asks when the user opens a message for - * editing, so the answer reflects the checkpoints that exist at that moment - * rather than a snapshot taken when the turn ended. + * with the given ts — and if not, why not, so the webview can explain it + * instead of showing one generic tooltip. The webview asks when the user + * opens a message for editing, so the answer reflects the checkpoints that + * exist at that moment rather than a snapshot taken when the turn ended. */ -export async function checkpointExistsForMessage(controller: Controller, request: Int64Request): Promise { - return Boolean.create({ value: await controller.hasWorkspaceCheckpointForMessage(request.value) }) +export async function checkpointExistsForMessage( + controller: Controller, + request: Int64Request, +): Promise { + const result = await controller.hasWorkspaceCheckpointForMessage(request.value) + return CheckpointExistsForMessageResponse.create({ + exists: result.exists, + reason: result.exists + ? CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_UNSPECIFIED + : REASON_BY_NAME[result.reason], + }) } diff --git a/apps/vscode/src/sdk/SdkController.test.ts b/apps/vscode/src/sdk/SdkController.test.ts index f9cbda8780..ef3a9f9ae8 100644 --- a/apps/vscode/src/sdk/SdkController.test.ts +++ b/apps/vscode/src/sdk/SdkController.test.ts @@ -531,6 +531,73 @@ describe("hasWorkspaceCheckpointForMessage", () => { { role: "user", content: "continue" }, ] + it("names a recorded no-git skip for the exact run", async () => { + const controller = Object.assign(Object.create(SdkController.prototype), { + task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } }, + sessions: { + getActiveSession: () => ({ + sessionId: "task-a", + sdkHost: { + get: async () => ({ + cwd: "C:/work", + metadata: { + checkpointSkips: [{ reason: "no_git_repository", runCount: 1, createdAt: 1 }], + }, + }), + readLiveMessages: vi.fn().mockResolvedValue(sdkMessages), + readMessages: vi.fn().mockResolvedValue([]), + }, + }), + }, + }) + + await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({ + exists: false, + reason: "no_git_repository", + }) + }) + + it("names a recorded failed skip for the exact run", async () => { + const controller = Object.assign(Object.create(SdkController.prototype), { + task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } }, + sessions: { + getActiveSession: () => ({ + sessionId: "task-a", + sdkHost: { + get: async () => ({ + cwd: "C:/work", + metadata: { + checkpointSkips: [{ reason: "checkpoint_failed", runCount: 1, createdAt: 1 }], + }, + }), + readLiveMessages: vi.fn().mockResolvedValue(sdkMessages), + readMessages: vi.fn().mockResolvedValue([]), + }, + }), + }, + }) + + await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({ + exists: false, + reason: "checkpoint_failed", + }) + }) + + it("reports checkpoints_disabled from the current setting without a lookup", async () => { + const createRemoteConfigAwareSessionHost = vi.fn() + const controller = { + task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } }, + sessions: { getActiveSession: () => undefined, createRemoteConfigAwareSessionHost }, + stateManager: { getGlobalSettingsKey: () => false }, + } + + await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({ + exists: false, + reason: "checkpoints_disabled", + }) + expect(createRemoteConfigAwareSessionHost).not.toHaveBeenCalled() + }) + it("reads the live conversation of the active session", async () => { const readLiveMessages = vi.fn().mockResolvedValue(sdkMessages) const readMessages = vi.fn().mockResolvedValue([]) @@ -550,8 +617,13 @@ describe("hasWorkspaceCheckpointForMessage", () => { }, } - await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toBe(true) - await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toBe(false) + await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toEqual({ + exists: true, + }) + await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({ + exists: false, + reason: "checkpoint_unavailable", + }) expect(readLiveMessages).toHaveBeenCalledWith("task-a") expect(readMessages).not.toHaveBeenCalled() }) @@ -588,7 +660,9 @@ describe("hasWorkspaceCheckpointForMessage", () => { resolveContextMentions: async (text: string) => text, }) - await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toBe(true) + await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toEqual({ + exists: true, + }) await expect( SdkController.prototype.editMessageAndRegenerate.call(controller as never, { messageTs: 3, @@ -676,7 +750,9 @@ describe("hasWorkspaceCheckpointForMessage", () => { createRemoteConfigAwareSessionHost: vi.fn().mockResolvedValue(tempHost), } - await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toBe(true) + await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({ + exists: true, + }) expect(tempHost.get).toHaveBeenCalledWith("task-a") expect(tempHost.dispose).toHaveBeenCalledWith("workspaceCheckpointForMessage") }) @@ -688,7 +764,10 @@ describe("hasWorkspaceCheckpointForMessage", () => { createRemoteConfigAwareSessionHost: vi.fn().mockRejectedValue(new Error("host unavailable")), } - await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toBe(false) + await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({ + exists: false, + reason: "checkpoint_unavailable", + }) }) it("reports no checkpoint for messages that did not start a run", async () => { @@ -704,7 +783,10 @@ describe("hasWorkspaceCheckpointForMessage", () => { createRemoteConfigAwareSessionHost, } - await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toBe(false) + await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toEqual({ + exists: false, + reason: "checkpoint_unavailable", + }) expect(createRemoteConfigAwareSessionHost).not.toHaveBeenCalled() }) }) diff --git a/apps/vscode/src/sdk/SdkController.ts b/apps/vscode/src/sdk/SdkController.ts index faf337929b..92e02c06f8 100644 --- a/apps/vscode/src/sdk/SdkController.ts +++ b/apps/vscode/src/sdk/SdkController.ts @@ -4,8 +4,10 @@ // Controller but delegates session lifecycle (initTask, askResponse, // cancelTask, …) to the Cline SDK (@cline/core) and bridges SDK events to // the webview's gRPC streams. +import { execFile } from "node:child_process" import * as fs from "node:fs/promises" import * as path from "node:path" +import { promisify } from "node:util" import { type CompareCheckpointResult, createRestoredCheckpointMetadata, @@ -16,6 +18,7 @@ import { loadRulesForSystemPromptFromRecords, type PreparedRemoteConfigCoreIntegration, type RuleConfig, + readCheckpointSkips, readSessionCheckpointHistory, resolveDefaultMcpSettingsPath, type SessionHistoryRecord, @@ -119,6 +122,8 @@ import type { VscodeTerminalExecutionMode } from "./vscode-terminal-execution-mo import { WebviewGrpcBridge } from "./webview-grpc-bridge" import { resolveWorkspaceManagerPaths, resolveWorkspaceRootPath } from "./workspace-root" +const execFileAsync = promisify(execFile) + /** * Log a stub warning and return undefined. */ @@ -2084,16 +2089,24 @@ export class Controller { * live session; then, as in editMessageAndRegenerate, a temporary host is * created for the read and disposed afterwards. */ - async hasWorkspaceCheckpointForMessage(messageTs: number): Promise { + async hasWorkspaceCheckpointForMessage( + messageTs: number, + ): Promise< + | { exists: true } + | { exists: false; reason: "checkpoints_disabled" | "no_git_repository" | "checkpoint_failed" | "checkpoint_unavailable" } + > { + if (this.stateManager?.getGlobalSettingsKey?.("enableCheckpointsSetting") === false) { + return { exists: false, reason: "checkpoints_disabled" } + } const activeSession = this.sessions.getActiveSession() const currentTask = this.task if (!currentTask) { - return false + return { exists: false, reason: "checkpoint_unavailable" } } const clineMessages = currentTask.messageStateHandler.getClineMessages() const targetIndex = clineMessages.findIndex((message) => message.ts === messageTs) if (targetIndex === -1 || getCheckpointRunCountForMessage(clineMessages, targetIndex) === undefined) { - return false + return { exists: false, reason: "checkpoint_unavailable" } } const userOrdinal = clineMessages.slice(0, targetIndex + 1).filter(isVisibleCheckpointUserMessage).length const sessionId = activeSession?.sessionId ?? currentTask.taskId @@ -2104,18 +2117,33 @@ export class Controller { sessionHost.get(sessionId), readCurrentMessages(sessionHost, sessionId) as Promise, ]) + const metadata = sessionRecord?.metadata as Record | undefined const sdkIndex = findSdkUserMessageIndexByOrdinal(sdkMessages, userOrdinal) if (sdkIndex === -1) { - return false + return { + exists: false, + reason: await this.resolveCheckpointUnavailableReason(metadata, undefined, sessionRecord, () => + this.getWorkspaceRoot(), + ), + } } const runCount = getSdkCheckpointRunCountForMessageIndex(sdkMessages, sdkIndex) - return ( + if ( runCount !== undefined && findCheckpointForRun(readSessionCheckpointHistory(sessionRecord), runCount) !== undefined + ) { + return { exists: true } + } + const resolvedReason = await this.resolveCheckpointUnavailableReason(metadata, runCount, sessionRecord, () => + this.getWorkspaceRoot(), ) + return { + exists: false, + reason: resolvedReason, + } } catch (error) { Logger.debug(`[SdkController] Failed to resolve workspace checkpoint for message ${messageTs}: ${error}`) - return false + return { exists: false, reason: "checkpoint_unavailable" } } finally { try { await tempHost?.dispose("workspaceCheckpointForMessage") @@ -2125,6 +2153,40 @@ export class Controller { } } + /** + * Names why a turn has no checkpoint. A skip recorded for the exact run + * wins; failing that, the latest recorded skip explains sessions whose + * older turns all skipped (e.g. every turn ran outside a git repo). With + * no record — turns from before reasons were recorded, or a transcript + * that no longer maps to the session — a live probe distinguishes a + * non-git workspace right now from the generic leftover case. + */ + async resolveCheckpointUnavailableReason( + metadata: Record | undefined, + runCount: number | undefined, + sessionRecord: { cwd?: string; workspaceRoot?: string } | undefined, + fallbackCwd?: () => Promise, + ): Promise<"no_git_repository" | "checkpoint_failed" | "checkpoint_unavailable"> { + const skips = readCheckpointSkips(metadata) + const exact = runCount === undefined ? undefined : skips.find((skip) => skip.runCount === runCount) + const recorded = exact ?? skips.at(-1) + if (recorded) { + return recorded.reason + } + const cwd = sessionRecord?.cwd?.trim() || sessionRecord?.workspaceRoot?.trim() || (fallbackCwd ? await fallbackCwd() : "") + try { + const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"], { + windowsHide: true, + }) + if (stdout.trim() !== "true") { + return "no_git_repository" + } + } catch { + return "no_git_repository" + } + return "checkpoint_unavailable" + } + /** * Gates the "View Changes" button on the completion row: the number of * files changed since the latest checkpoint, or 0 when nothing can be diff --git a/apps/vscode/src/test/cline-core-vitest-stub.ts b/apps/vscode/src/test/cline-core-vitest-stub.ts index 32934cf663..855e4d4111 100644 --- a/apps/vscode/src/test/cline-core-vitest-stub.ts +++ b/apps/vscode/src/test/cline-core-vitest-stub.ts @@ -173,6 +173,39 @@ export interface CheckpointEntry { kind?: "stash" | "commit" } +export type CheckpointSkipReason = "no_git_repository" | "checkpoint_failed" + +export interface CheckpointSkip { + reason: CheckpointSkipReason + runCount?: number + createdAt: number +} + +export function readCheckpointSkips(metadata: Record | undefined): CheckpointSkip[] { + const candidate = metadata?.checkpointSkips + if (!Array.isArray(candidate)) { + return [] + } + return candidate.flatMap((entry): CheckpointSkip[] => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return [] + } + const record = entry as Partial + if (record.reason !== "no_git_repository" && record.reason !== "checkpoint_failed") { + return [] + } + const createdAt = Number(record.createdAt ?? 0) + if (!Number.isFinite(createdAt)) { + return [] + } + const runCount = + typeof record.runCount === "number" && Number.isInteger(record.runCount) && record.runCount >= 1 + ? record.runCount + : undefined + return [{ reason: record.reason, createdAt, ...(runCount ? { runCount } : {}) }] + }) +} + export function readSessionCheckpointHistory(session: { metadata?: Record } | undefined): CheckpointEntry[] { const checkpoint = session?.metadata?.checkpoint && diff --git a/apps/vscode/webview-ui/src/components/chat/UserMessage.tsx b/apps/vscode/webview-ui/src/components/chat/UserMessage.tsx index aa502c8c05..32e3e18c01 100644 --- a/apps/vscode/webview-ui/src/components/chat/UserMessage.tsx +++ b/apps/vscode/webview-ui/src/components/chat/UserMessage.tsx @@ -1,3 +1,4 @@ +import { CheckpointUnavailableReason } from "@shared/proto/cline/checkpoints" import { Int64Request } from "@shared/proto/cline/common" import { EditMessageAndRegenerateRequest } from "@shared/proto/cline/task" import type React from "react" @@ -24,10 +25,26 @@ interface UserMessageProps { * checkpoint is looked up when the editor opens, so the button is pending * until the answer arrives. */ +type WorkspaceRestoreUnavailableReason = + | "checkpoints_disabled" + | "no_git_repository" + | "checkpoint_failed" + | "checkpoint_unavailable" + type WorkspaceRestoreAvailability = | { state: "pending" } | { state: "available" } - | { state: "unavailable"; reason: "checkpoints_disabled" | "checkpoint_unavailable" } + | { state: "unavailable"; reason: WorkspaceRestoreUnavailableReason } + +const WORKSPACE_RESTORE_UNAVAILABLE_TOOLTIPS: Record = { + checkpoints_disabled: + "No checkpoint is available for this message. Enable Checkpoints in Settings to create checkpoints for future messages.", + no_git_repository: + "No workspace checkpoint was created for this message because the workspace is not a git repository. Initialize git (git init) to create checkpoints for future messages.", + checkpoint_failed: + "No workspace checkpoint was created for this message because checkpoint creation failed. Check that git is working in this workspace; future messages will retry.", + checkpoint_unavailable: "No workspace checkpoint was created for this message.", +} const WORKSPACE_RESTORE_TOOLTIPS: Record = { pending: "Checking for a workspace checkpoint…", @@ -35,6 +52,15 @@ const WORKSPACE_RESTORE_TOOLTIPS: Record = { + [CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_UNSPECIFIED]: "checkpoint_unavailable", + [CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINTS_DISABLED]: "checkpoints_disabled", + [CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_NO_GIT_REPOSITORY]: "no_git_repository", + [CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_FAILED]: "checkpoint_failed", + [CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_UNAVAILABLE]: "checkpoint_unavailable", + [CheckpointUnavailableReason.UNRECOGNIZED]: "checkpoint_unavailable", +} + const UserMessage: React.FC = ({ text, images, files, messageTs, canRestoreWorkspace }) => { const { navigateToSettings, enableCheckpointsSetting } = useExtensionState() const [isEditing, setIsEditing] = useState(false) @@ -44,41 +70,54 @@ const UserMessage: React.FC = ({ text, images, files, messageT const [savingMode, setSavingMode] = useState<"chat" | "workspace" | undefined>() const [errorMessage, setErrorMessage] = useState() const [workspaceRestorePopoverOpen, setWorkspaceRestorePopoverOpen] = useState(false) - const [hasWorkspaceCheckpoint, setHasWorkspaceCheckpoint] = useState() + const [checkpointLookup, setCheckpointLookup] = useState< + { exists: boolean; reason?: WorkspaceRestoreUnavailableReason } | undefined + >() const highlightedText = useMemo(() => highlightText(text), [text]) const workspaceRestoreAvailability: WorkspaceRestoreAvailability | undefined = !canRestoreWorkspace ? undefined - : hasWorkspaceCheckpoint === undefined + : checkpointLookup === undefined ? { state: "pending" } - : hasWorkspaceCheckpoint + : checkpointLookup.exists ? { state: "available" } : { state: "unavailable", - reason: enableCheckpointsSetting === false ? "checkpoints_disabled" : "checkpoint_unavailable", + // The backend reports checkpoints_disabled itself; the current + // setting is only a fallback for older hosts that omit a reason. + reason: + checkpointLookup.reason ?? + (enableCheckpointsSetting === false ? "checkpoints_disabled" : "checkpoint_unavailable"), } - const workspaceRestoreTooltip = workspaceRestoreAvailability && WORKSPACE_RESTORE_TOOLTIPS[workspaceRestoreAvailability.state] + const workspaceRestoreTooltip = + workspaceRestoreAvailability && + (workspaceRestoreAvailability.state === "unavailable" + ? WORKSPACE_RESTORE_UNAVAILABLE_TOOLTIPS[workspaceRestoreAvailability.reason] + : WORKSPACE_RESTORE_TOOLTIPS[workspaceRestoreAvailability.state]) const workspaceRestorePending = workspaceRestoreAvailability?.state === "pending" // The checkpoint is looked up when the user opens the editor, not pushed // with every state update, so the answer covers the checkpoint written for // the latest turn. A failed lookup is reported as no checkpoint. useEffect(() => { - setHasWorkspaceCheckpoint(undefined) + setCheckpointLookup(undefined) if (!isEditing || !canRestoreWorkspace || messageTs === undefined) { return } let cancelled = false CheckpointsServiceClient.checkpointExistsForMessage(Int64Request.create({ value: messageTs })) .then( - (result) => result.value, + (result) => ({ + exists: result.exists, + reason: result.exists ? undefined : CHECKPOINT_REASON_BY_PROTO[result.reason], + }), (error) => { console.error("Failed to look up the workspace checkpoint for this message:", error) - return false + return { exists: false, reason: undefined } }, ) - .then((exists) => { + .then((lookup) => { if (!cancelled) { - setHasWorkspaceCheckpoint(exists) + setCheckpointLookup(lookup) } }) return () => { diff --git a/apps/vscode/webview-ui/src/components/chat/__tests__/UserMessage.ime.test.tsx b/apps/vscode/webview-ui/src/components/chat/__tests__/UserMessage.ime.test.tsx index cfc2ff4a8e..cf984a9970 100644 --- a/apps/vscode/webview-ui/src/components/chat/__tests__/UserMessage.ime.test.tsx +++ b/apps/vscode/webview-ui/src/components/chat/__tests__/UserMessage.ime.test.tsx @@ -47,7 +47,7 @@ describe("UserMessage – IME composition handling", () => { }, ) vi.mocked(TaskServiceClient.editMessageAndRegenerate).mockResolvedValue({}) - vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ value: true }) + vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: true, reason: 0 }) }) it("does NOT send when IME composition Enter is pressed while editing", () => { @@ -136,7 +136,7 @@ describe("UserMessage – IME composition handling", () => { it("shows Reset Code as busy and does not restore until the checkpoint lookup answers", async () => { const user = userEvent.setup() - let answer: (result: { value: boolean }) => void = () => {} + let answer: (result: { exists: boolean; reason: number }) => void = () => {} vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockReturnValue( new Promise((resolve) => { answer = resolve @@ -153,7 +153,7 @@ describe("UserMessage – IME composition handling", () => { await user.click(resetCode) expect(TaskServiceClient.editMessageAndRegenerate).not.toHaveBeenCalled() - act(() => answer({ value: true })) + act(() => answer({ exists: true, reason: 0 })) await waitFor(() => expect(resetCode).not.toHaveAttribute("aria-busy")) expect(resetCode).not.toHaveAttribute("aria-disabled") await user.click(resetCode) @@ -166,7 +166,7 @@ describe("UserMessage – IME composition handling", () => { it("keeps Reset Chat enabled and disables Reset Code when checkpoints were off", async () => { const user = userEvent.setup() enableCheckpointsSetting = false - vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ value: false }) + vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 1 }) render() await user.click(screen.getByText("Update this")) @@ -184,7 +184,7 @@ describe("UserMessage – IME composition handling", () => { it("opens the disabled Reset Code explanation from the keyboard", async () => { const user = userEvent.setup() enableCheckpointsSetting = false - vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ value: false }) + vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 1 }) render() await user.click(screen.getByText("Update this")) @@ -202,7 +202,8 @@ describe("UserMessage – IME composition handling", () => { it("explains when checkpoint creation was enabled but no checkpoint exists", async () => { const user = userEvent.setup() - vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ value: false }) + // CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_UNAVAILABLE + vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 4 }) render() await user.click(screen.getByText("Update this")) @@ -213,6 +214,34 @@ describe("UserMessage – IME composition handling", () => { expect(await screen.findByText("No workspace checkpoint was created for this message.")).toBeInTheDocument() }) + it("names a non-git workspace as the reason no checkpoint exists", async () => { + const user = userEvent.setup() + // CHECKPOINT_UNAVAILABLE_REASON_NO_GIT_REPOSITORY + vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 2 }) + render() + + await user.click(screen.getByText("Update this")) + const resetCode = screen.getByRole("button", { name: "Reset Code" }) + await waitFor(() => expect(resetCode).not.toHaveAttribute("aria-busy")) + expect(resetCode).toHaveAttribute("aria-disabled", "true") + await user.hover(resetCode) + expect(await screen.findByText(/not a git repository/)).toBeInTheDocument() + }) + + it("names a failed checkpoint creation as the reason no checkpoint exists", async () => { + const user = userEvent.setup() + // CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_FAILED + vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 3 }) + render() + + await user.click(screen.getByText("Update this")) + const resetCode = screen.getByRole("button", { name: "Reset Code" }) + await waitFor(() => expect(resetCode).not.toHaveAttribute("aria-busy")) + expect(resetCode).toHaveAttribute("aria-disabled", "true") + await user.hover(resetCode) + expect(await screen.findByText(/checkpoint creation failed/)).toBeInTheDocument() + }) + it("hides Reset Code for messages that did not start a run", async () => { const user = userEvent.setup() render() diff --git a/sdk/packages/core/src/hooks/checkpoint-hooks.test.ts b/sdk/packages/core/src/hooks/checkpoint-hooks.test.ts index b463c5cf3b..ed111a3bc3 100644 --- a/sdk/packages/core/src/hooks/checkpoint-hooks.test.ts +++ b/sdk/packages/core/src/hooks/checkpoint-hooks.test.ts @@ -12,6 +12,7 @@ import { checkpointScratchDir, createCheckpointHooks, deleteCheckpointRefs, + readCheckpointSkips, } from "./checkpoint-hooks"; const execFile = promisify(execFileCallback); @@ -596,6 +597,58 @@ describe("createCheckpointHooks", () => { ).toEqual([1, 2]); }); + it("records a no_git_repository skip when the workspace is not a git repository", async () => { + const cwd = await mkdtemp(join(tmpdir(), "core-checkpoint-nogit-")); + let metadata: Record | undefined; + try { + const hooks = createCheckpointHooks({ + cwd, + sessionId: "sess_nogit", + readSessionMetadata: async () => metadata, + writeSessionMetadata: async (next) => { + metadata = next; + }, + }); + + await runCheckpointHooks(hooks); + + expect(metadata?.checkpoint).toBeUndefined(); + expect(metadata?.checkpointSkips).toEqual([ + expect.objectContaining({ reason: "no_git_repository", runCount: 1 }), + ]); + expect(readCheckpointSkips(metadata)).toEqual([ + expect.objectContaining({ reason: "no_git_repository", runCount: 1 }), + ]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("records a checkpoint_failed skip when a custom checkpoint returns nothing", async () => { + const cwd = await createGitRepo(); + let metadata: Record | undefined; + try { + const hooks = createCheckpointHooks({ + cwd, + sessionId: "sess_custom_skip", + createCheckpoint: () => undefined, + readSessionMetadata: async () => metadata, + writeSessionMetadata: async (next) => { + metadata = next; + }, + }); + + await runCheckpointHooks(hooks); + + expect(readCheckpointSkips(metadata)).toEqual([ + expect.objectContaining({ reason: "checkpoint_failed", runCount: 1 }), + ]); + } finally { + await deleteCheckpointRefs(cwd, "sess_custom_skip"); + await rm(cwd, { recursive: true, force: true }); + } + }); + it("emits one checkpoint.snapshot event per snapshot attempt without file paths", async () => { const cwd = await createGitRepo(); const sessionId = "sess_telemetry"; diff --git a/sdk/packages/core/src/hooks/checkpoint-hooks.ts b/sdk/packages/core/src/hooks/checkpoint-hooks.ts index 5dd05ebe64..2e50800798 100644 --- a/sdk/packages/core/src/hooks/checkpoint-hooks.ts +++ b/sdk/packages/core/src/hooks/checkpoint-hooks.ts @@ -84,6 +84,21 @@ export interface CheckpointEntry { kind?: "stash" | "commit"; } +/** + * Why a user turn ended without a workspace checkpoint. Persisted in session + * metadata so the missing-checkpoint explanation still names the cause after + * a window reload, instead of collapsing every failure into "no checkpoint". + * `createdAt` orders records; reasons carry a run count when the turn that + * skipped is known (custom checkpoint implementations may skip without one). + */ +export type CheckpointSkipReason = "no_git_repository" | "checkpoint_failed"; + +export interface CheckpointSkip { + reason: CheckpointSkipReason; + runCount?: number; + createdAt: number; +} + export interface CheckpointMetadata { latest: CheckpointEntry; history: CheckpointEntry[]; @@ -120,6 +135,45 @@ function warn(logger: BasicLogger | undefined, message: string): void { logger?.log(message, { severity: "warn" }); } +/** + * Reads persisted checkpoint skips. Tolerant of malformed entries (the + * metadata is plain JSON that older builds also write): unknown reasons and + * non-positive run counts are dropped rather than failing the whole lookup. + */ +export function readCheckpointSkips( + metadata: Record | undefined, +): CheckpointSkip[] { + const candidate = metadata?.checkpointSkips; + if (!Array.isArray(candidate)) { + return []; + } + return candidate.flatMap((entry): CheckpointSkip[] => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return []; + } + const record = entry as Partial; + if ( + record.reason !== "no_git_repository" && + record.reason !== "checkpoint_failed" + ) { + return []; + } + const createdAt = Number(record.createdAt ?? 0); + if (!Number.isFinite(createdAt)) { + return []; + } + const runCount = + typeof record.runCount === "number" && + Number.isInteger(record.runCount) && + record.runCount >= 1 + ? record.runCount + : undefined; + return [ + { reason: record.reason, createdAt, ...(runCount ? { runCount } : {}) }, + ]; + }); +} + function readCheckpointMetadata( metadata: Record | undefined, ): CheckpointMetadata | undefined { @@ -479,6 +533,22 @@ export async function retainCheckpointRefs( ); } +function upsertCheckpointSkip( + skips: readonly CheckpointSkip[], + skip: CheckpointSkip, +): CheckpointSkip[] { + const existingIndex = + skip.runCount === undefined + ? -1 + : skips.findIndex((candidate) => candidate.runCount === skip.runCount); + if (existingIndex < 0) { + return [...skips, skip]; + } + return skips.map((candidate, index) => + index === existingIndex ? skip : candidate, + ); +} + function upsertCheckpointHistory( history: readonly CheckpointEntry[], entry: CheckpointEntry, @@ -534,17 +604,24 @@ export function createCheckpointHooks( const createCheckpoint = async ( runCount: number, - ): Promise => { + ): Promise< + | { entry: CheckpointEntry } + | { skipReason: CheckpointSkipReason } + | undefined + > => { if (options.createCheckpoint) { - return await options.createCheckpoint({ + const entry = await options.createCheckpoint({ cwd: options.cwd, sessionId: options.sessionId, runCount, }); + // A custom implementation owns its outcome; record it as a failure + // rather than guessing (it may not be git-backed at all). + return entry ? { entry } : { skipReason: "checkpoint_failed" }; } if (!(await ensureGitRepository())) { - return undefined; + return { skipReason: "no_git_repository" }; } const startedAt = Date.now(); @@ -609,7 +686,9 @@ export function createCheckpointHooks( "Checkpoint HEAD fallback failed", ); captureSnapshot(fallback ? "head_fallback" : "skipped"); - return fallback; + return fallback + ? { entry: fallback } + : { skipReason: "checkpoint_failed" as const }; } if (!ref) { // A clean worktree with no untracked files — the HEAD entry is the @@ -618,7 +697,9 @@ export function createCheckpointHooks( "Checkpoint HEAD fallback failed", ); captureSnapshot(fallback ? "head_clean" : "skipped"); - return fallback; + return fallback + ? { entry: fallback } + : { skipReason: "checkpoint_failed" as const }; } // Store the stash commit under a private ref namespace so it is @@ -636,15 +717,17 @@ export function createCheckpointHooks( `Checkpoint store failed: ${error instanceof Error ? error.message : String(error)}`, ); captureSnapshot("skipped"); - return undefined; + return { skipReason: "checkpoint_failed" as const }; } captureSnapshot("stash"); return { - ref, - createdAt: Date.now(), - runCount, - kind: "stash", + entry: { + ref, + createdAt: Date.now(), + runCount, + kind: "stash", + }, }; }; @@ -689,16 +772,44 @@ export function createCheckpointHooks( if (!introducedUserRun && alreadyCheckpointed) { return undefined; } - const entry = await createCheckpoint(runCount); - if (!entry) { + const result = await createCheckpoint(runCount); + if (!result) { return undefined; } + if ("skipReason" in result) { + // Record why this turn has no checkpoint so the restore UI can + // name the cause later (including after a reload). Best effort: + // a failed metadata write must not fail the turn. + try { + await options.writeSessionMetadata({ + ...(metadata ?? {}), + checkpointSkips: upsertCheckpointSkip( + readCheckpointSkips(metadata), + { + reason: result.skipReason, + runCount, + createdAt: Date.now(), + }, + ), + }); + } catch { + // The turn proceeds without a checkpoint either way. + } + return undefined; + } + const entry = result.entry; + // Preserve any recorded skips; a successful checkpoint for this run + // supersedes a skip recorded for the same run. + const checkpointSkips = readCheckpointSkips(metadata).filter( + (skip) => skip.runCount !== runCount, + ); if (existing?.latest.ref === entry.ref) { return undefined; } const history = upsertCheckpointHistory(existing?.history ?? [], entry); await options.writeSessionMetadata({ ...(metadata ?? {}), + checkpointSkips, checkpoint: { latest: entry, history, diff --git a/sdk/packages/core/src/index.ts b/sdk/packages/core/src/index.ts index 061461a13a..e826a4dc42 100644 --- a/sdk/packages/core/src/index.ts +++ b/sdk/packages/core/src/index.ts @@ -460,7 +460,10 @@ export { export type { CheckpointEntry, CheckpointMetadata, + CheckpointSkip, + CheckpointSkipReason, } from "./hooks/checkpoint-hooks"; +export { readCheckpointSkips } from "./hooks/checkpoint-hooks"; export * from "./hub"; export { HubRuntimeHost } from "./hub/runtime-host/hub-runtime-host"; export { RemoteRuntimeHost } from "./hub/runtime-host/remote-runtime-host";