mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
feat(checkpoints): explain why a message has no workspace checkpoint
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<Boolean> {
|
||||
return Boolean.create({ value: await controller.hasWorkspaceCheckpointForMessage(request.value) })
|
||||
export async function checkpointExistsForMessage(
|
||||
controller: Controller,
|
||||
request: Int64Request,
|
||||
): Promise<CheckpointExistsForMessageResponse> {
|
||||
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],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<SdkUserMessage[]>,
|
||||
])
|
||||
const metadata = sessionRecord?.metadata as Record<string, unknown> | 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<string, unknown> | undefined,
|
||||
runCount: number | undefined,
|
||||
sessionRecord: { cwd?: string; workspaceRoot?: string } | undefined,
|
||||
fallbackCwd?: () => Promise<string>,
|
||||
): 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
|
||||
|
||||
@@ -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<string, unknown> | 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<CheckpointSkip>
|
||||
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<string, unknown> } | undefined): CheckpointEntry[] {
|
||||
const checkpoint =
|
||||
session?.metadata?.checkpoint &&
|
||||
|
||||
@@ -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<WorkspaceRestoreUnavailableReason, string> = {
|
||||
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<WorkspaceRestoreAvailability["state"], string> = {
|
||||
pending: "Checking for a workspace checkpoint…",
|
||||
@@ -35,6 +52,15 @@ const WORKSPACE_RESTORE_TOOLTIPS: Record<WorkspaceRestoreAvailability["state"],
|
||||
unavailable: "No workspace checkpoint was created for this message.",
|
||||
}
|
||||
|
||||
const CHECKPOINT_REASON_BY_PROTO: Record<CheckpointUnavailableReason, WorkspaceRestoreUnavailableReason> = {
|
||||
[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<UserMessageProps> = ({ text, images, files, messageTs, canRestoreWorkspace }) => {
|
||||
const { navigateToSettings, enableCheckpointsSetting } = useExtensionState()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
@@ -44,41 +70,54 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
|
||||
const [savingMode, setSavingMode] = useState<"chat" | "workspace" | undefined>()
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>()
|
||||
const [workspaceRestorePopoverOpen, setWorkspaceRestorePopoverOpen] = useState(false)
|
||||
const [hasWorkspaceCheckpoint, setHasWorkspaceCheckpoint] = useState<boolean | undefined>()
|
||||
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 () => {
|
||||
|
||||
@@ -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(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
|
||||
|
||||
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(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
|
||||
|
||||
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(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
|
||||
|
||||
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(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
|
||||
|
||||
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(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
|
||||
|
||||
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(<UserMessage messageTs={123} text="Update this" />)
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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";
|
||||
|
||||
@@ -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<string, unknown> | 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<CheckpointSkip>;
|
||||
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<string, unknown> | 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<CheckpointEntry | undefined> => {
|
||||
): 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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user