= {
wordBreak: 'break-word',
textWrap: 'pretty' as any,
},
- revertBtn: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- width: 26,
- height: 26,
- marginTop: 4,
- flexShrink: 0,
- borderRadius: 'var(--radius-md)',
- border: '1px solid var(--border-primary)',
- background: 'var(--bg-secondary)',
- color: 'var(--text-secondary)',
- opacity: 0,
- transition: 'opacity 150ms ease, color 150ms ease, background 150ms ease',
- },
aiRow: {
display: 'flex',
gap: 10,
diff --git a/web/src/components/chat/ToolCallCard.test.tsx b/web/src/components/chat/ToolCallCard.test.tsx
index cb79f8df..2efbe3f7 100644
--- a/web/src/components/chat/ToolCallCard.test.tsx
+++ b/web/src/components/chat/ToolCallCard.test.tsx
@@ -1,11 +1,7 @@
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import ToolCallCard from './ToolCallCard'
-vi.mock('./CheckpointInlineMark', () => ({
- default: ({ checkpointId }: { checkpointId: string }) => cp:{checkpointId},
-}))
-
describe('ToolCallCard', () => {
it('shows running state and expands/collapses', () => {
render(
@@ -44,7 +40,6 @@ describe('ToolCallCard', () => {
replace_string: 'new',
}),
toolResult: 'ok',
- checkpointId: 'cp1',
timestamp: 1,
} as any}
/>,
@@ -53,6 +48,5 @@ describe('ToolCallCard', () => {
expect(screen.getAllByText('a.ts').length).toBeGreaterThan(0)
expect(screen.getByText('old')).toBeInTheDocument()
expect(screen.getByText('new')).toBeInTheDocument()
- expect(screen.getByText('cp:cp1')).toBeInTheDocument()
})
})
diff --git a/web/src/components/chat/ToolCallCard.tsx b/web/src/components/chat/ToolCallCard.tsx
index 34a31317..a2f80e1d 100644
--- a/web/src/components/chat/ToolCallCard.tsx
+++ b/web/src/components/chat/ToolCallCard.tsx
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { type ChatMessage } from '@/stores/useChatStore'
import { Loader2, Wrench, CheckCircle2, XCircle, ChevronRight } from 'lucide-react'
-import CheckpointInlineMark from './CheckpointInlineMark'
interface ToolCallCardProps {
message: ChatMessage
@@ -47,11 +46,6 @@ export default function ToolCallCard({ message, groupedWithPrev = false }: ToolC
{isError && }
{argsSummary && {argsSummary}}
{resultStats && · {resultStats}}
- {message.checkpointId && (
-
-
-
- )}
{expanded && (
diff --git a/web/src/components/panels/FileChangePanel.test.tsx b/web/src/components/panels/FileChangePanel.test.tsx
index 692f0aee..a18cef17 100644
--- a/web/src/components/panels/FileChangePanel.test.tsx
+++ b/web/src/components/panels/FileChangePanel.test.tsx
@@ -23,7 +23,6 @@ const mockGatewayAPI = {
undoRestore: vi.fn(),
loadSession: vi.fn(),
listSessionTodos: vi.fn(),
- listCheckpoints: vi.fn(),
};
vi.mock("@/context/RuntimeProvider", () => ({
@@ -119,9 +118,6 @@ describe("FileChangePanel", () => {
mockGatewayAPI.listSessionTodos.mockResolvedValue({
payload: { items: [] },
});
- mockGatewayAPI.listCheckpoints.mockResolvedValue({
- payload: [],
- });
useChatStore.setState({ isGenerating: false } as never);
useSessionStore.setState({ currentSessionId: "sess-1" } as never);
useUIStore.setState({
diff --git a/web/src/stores/useChatStore.ts b/web/src/stores/useChatStore.ts
index 53a9a3f0..70f7ed4b 100644
--- a/web/src/stores/useChatStore.ts
+++ b/web/src/stores/useChatStore.ts
@@ -23,10 +23,6 @@ export interface ChatMessage {
toolArgs?: string
toolResult?: string
toolStatus?: 'running' | 'done' | 'error'
- /** 与该 tool_call 关联的 checkpoint ID(由 CheckpointCreated 事件时序关联) */
- checkpointId?: string
- /** Checkpoint 撤回状态:available 可撤回 / restoring 正在撤回 / restored 已撤回 */
- checkpointStatus?: 'available' | 'restoring' | 'restored'
/** Verification 摘要数据(仅 type === 'verification' 使用) */
verificationData?: VerificationRunRecord
/** Acceptance 决策数据(仅 type === 'acceptance' 使用) */
@@ -96,14 +92,6 @@ interface ChatState {
appendToolOutput: (toolCallId: string, chunk: string) => void
/** 将所有运行中的工具条目标记为指定状态,用于终止事件兜底收敛 UI。 */
finalizeRunningToolCalls: (status: 'done' | 'error') => void
- /** 把 checkpointId 关联到一条 tool_call 消息(由 CheckpointCreated 时序关联触发) */
- attachCheckpointToToolCall: (toolCallId: string, checkpointId: string) => void
- /** 更新某条已挂 checkpoint 的 tool_call 消息的撤回状态 */
- setCheckpointStatus: (toolCallId: string, status: NonNullable) => void
- /** 将所有 available 的 checkpoint 标记为 restored */
- markAllCheckpointsRestored: () => void
- /** 将所有 restored 的 checkpoint 标记回 available */
- markAllCheckpointsAvailable: () => void
/** 更新一条 verification 消息的 data(verification 进行中持续更新同一条消息) */
updateVerificationMessage: (messageId: string, data: VerificationRunRecord) => void
setGenerating: (v: boolean) => void
@@ -326,42 +314,6 @@ export const useChatStore = create((set) => ({
),
})),
- attachCheckpointToToolCall: (toolCallId, checkpointId) =>
- set((s) => ({
- messages: s.messages.map((m) =>
- m.toolCallId === toolCallId && m.type === 'tool_call'
- ? { ...m, checkpointId, checkpointStatus: 'available' as const }
- : m
- ),
- })),
-
- setCheckpointStatus: (toolCallId, status) =>
- set((s) => ({
- messages: s.messages.map((m) =>
- m.toolCallId === toolCallId && m.type === 'tool_call'
- ? { ...m, checkpointStatus: status }
- : m
- ),
- })),
-
- markAllCheckpointsRestored: () =>
- set((s) => ({
- messages: s.messages.map((m) =>
- m.checkpointStatus === 'available'
- ? { ...m, checkpointStatus: 'restored' as const }
- : m
- ),
- })),
-
- markAllCheckpointsAvailable: () =>
- set((s) => ({
- messages: s.messages.map((m) =>
- m.checkpointStatus === 'restored'
- ? { ...m, checkpointStatus: 'available' as const }
- : m
- ),
- })),
-
updateVerificationMessage: (messageId, data) =>
set((s) => ({
messages: s.messages.map((m) =>
diff --git a/web/src/stores/useRuntimeInsightStore.ts b/web/src/stores/useRuntimeInsightStore.ts
index ba04f30d..5f99c640 100644
--- a/web/src/stores/useRuntimeInsightStore.ts
+++ b/web/src/stores/useRuntimeInsightStore.ts
@@ -5,7 +5,6 @@ import {
type BudgetEstimateFailedPayload,
type CheckpointCreatedPayload,
type CheckpointDiffResultPayload,
- type CheckpointEntry,
type CheckpointRestoredPayload,
type CheckpointUndoRestorePayload,
type CheckpointWarningPayload,
@@ -40,7 +39,6 @@ export interface TodoHistoryEntry extends TodoViewItem {
}
interface RuntimeInsightState {
- checkpoints: CheckpointEntry[]
checkpointDiff: CheckpointDiffResultPayload | null
checkpointEvents: Array
checkpointWarning: CheckpointWarningPayload | null
@@ -62,7 +60,6 @@ interface RuntimeInsightState {
ledgerReconciled: LedgerReconciledPayload | null
budgetUsageRatio: number | null
- setCheckpoints: (checkpoints: CheckpointEntry[]) => void
setCheckpointDiff: (diff: CheckpointDiffResultPayload | null) => void
addCheckpointEvent: (event: CheckpointCreatedPayload | CheckpointRestoredPayload | CheckpointUndoRestorePayload) => void
setCheckpointWarning: (warning: CheckpointWarningPayload | null) => void
@@ -85,7 +82,6 @@ interface RuntimeInsightState {
}
const initialState = {
- checkpoints: [] as CheckpointEntry[],
checkpointDiff: null as CheckpointDiffResultPayload | null,
checkpointEvents: [] as Array,
checkpointWarning: null as CheckpointWarningPayload | null,
@@ -131,7 +127,6 @@ function patchLatestVerification(
export const useRuntimeInsightStore = create((set) => ({
...initialState,
- setCheckpoints: (checkpoints) => set({ checkpoints }),
setCheckpointDiff: (checkpointDiff) => set({ checkpointDiff }),
addCheckpointEvent: (event) => set((s) => ({ checkpointEvents: [...s.checkpointEvents, event] })),
setCheckpointWarning: (checkpointWarning) => set({ checkpointWarning }),
diff --git a/web/src/stores/useSessionStore.test.ts b/web/src/stores/useSessionStore.test.ts
index 89022593..e86be9c3 100644
--- a/web/src/stores/useSessionStore.test.ts
+++ b/web/src/stores/useSessionStore.test.ts
@@ -259,7 +259,7 @@ describe('useSessionStore', () => {
expect(session.time).toBe('1970-01-01T00:00:00.000Z')
})
- it('switchSession concurrently fetches todos and checkpoints', async () => {
+ it('switchSession concurrently fetches todos and runtime snapshot', async () => {
const mockBindStream = vi.fn().mockResolvedValue({})
const mockLoadSession = vi.fn().mockResolvedValue({
payload: { messages: [{ role: 'user', content: 'hello', tool_calls: [] }] },
@@ -270,24 +270,21 @@ describe('useSessionStore', () => {
summary: { total: 1, required_total: 1, required_completed: 0, required_failed: 0, required_open: 1 },
},
})
- const mockListCheckpoints = vi.fn().mockResolvedValue({
- payload: [{ checkpoint_id: 'cp1', session_id: 'sess-2', reason: 'test', status: 'active', restorable: true, created_at_ms: Date.now() }],
- })
+ const mockGetRuntimeSnapshot = vi.fn().mockResolvedValue({ payload: {} })
const mockAPI = {
bindStream: mockBindStream,
loadSession: mockLoadSession,
listSessionTodos: mockListSessionTodos,
- listCheckpoints: mockListCheckpoints,
+ getRuntimeSnapshot: mockGetRuntimeSnapshot,
} as any
await useSessionStore.getState().switchSession('sess-2', mockAPI)
expect(mockLoadSession).toHaveBeenCalledWith('sess-2')
expect(mockListSessionTodos).toHaveBeenCalledWith('sess-2')
- expect(mockListCheckpoints).toHaveBeenCalledWith({ session_id: 'sess-2', limit: 50 })
+ expect(mockGetRuntimeSnapshot).toHaveBeenCalledWith('sess-2')
const insightStore = useRuntimeInsightStore.getState()
expect(insightStore.todoSnapshot?.items?.[0].id).toBe('t1')
- expect(insightStore.checkpoints[0].checkpoint_id).toBe('cp1')
})
})
diff --git a/web/src/stores/useSessionStore.ts b/web/src/stores/useSessionStore.ts
index 81d8274c..9018e7e0 100644
--- a/web/src/stores/useSessionStore.ts
+++ b/web/src/stores/useSessionStore.ts
@@ -132,22 +132,18 @@ export type BackendMessage = {
is_error?: boolean
}
-/** 并发拉取 session 详情 + todos + checkpoints + runtime snapshot,并把后者写入对应 store。
- * todos / checkpoints / runtime snapshot 失败用 .catch 兜底,不阻断主流程的 loadSession。 */
+/** 并发拉取 session 详情 + todos + runtime snapshot,并把后者写入对应 store。
+ * todos / runtime snapshot 失败用 .catch 兜底,不阻断主流程的 loadSession。 */
export async function loadSessionWithInsights(gatewayAPI: GatewayAPI, sessionId: string) {
- const [sessionFrame, todosResult, checkpointsResult, runtimeSnapshotResult] = await Promise.all([
+ const [sessionFrame, todosResult, runtimeSnapshotResult] = await Promise.all([
gatewayAPI.loadSession(sessionId),
(gatewayAPI.listSessionTodos?.(sessionId) ?? Promise.resolve(null)).catch(() => null),
- (gatewayAPI.listCheckpoints?.({ session_id: sessionId, limit: 50 }) ?? Promise.resolve(null)).catch(() => null),
(gatewayAPI.getRuntimeSnapshot?.(sessionId) ?? Promise.resolve(null)).catch(() => null),
])
const insightStore = useRuntimeInsightStore.getState()
if (todosResult?.payload) {
insightStore.setTodoSnapshot(todosResult.payload)
}
- if (checkpointsResult?.payload) {
- insightStore.setCheckpoints(checkpointsResult.payload)
- }
const pendingQuestion = runtimeSnapshotResult?.payload?.pending_user_question
if (pendingQuestion) {
useChatStore.getState().setPendingUserQuestion(pendingQuestion)
@@ -312,7 +308,7 @@ export const useSessionStore = create((set, get) => ({
// 3. Bind stream (events will be discarded due to isTransitioning)
await gatewayAPI.bindStream({ session_id: sessionId, channel: 'all' })
- // 4. Load historical messages (concurrently fetch todos + checkpoints)
+ // 4. Load historical messages (concurrently fetch todos + runtime snapshot)
const sessionFrame = await loadSessionWithInsights(gatewayAPI, sessionId)
const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string }
@@ -413,7 +409,7 @@ export const useSessionStore = create((set, get) => ({
await gatewayAPI.bindStream({ session_id: firstSession.id, channel: 'all' })
set({ _initialBindDone: true })
- // Load historical messages for the auto-selected session (concurrently fetch todos + checkpoints)
+ // Load historical messages for the auto-selected session (concurrently fetch todos + runtime snapshot)
const sessionFrame = await loadSessionWithInsights(gatewayAPI, firstSession.id)
const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string }
if (sessionData.messages && sessionData.messages.length > 0) {
diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts
index 64066373..55df0530 100644
--- a/web/src/utils/eventBridge.test.ts
+++ b/web/src/utils/eventBridge.test.ts
@@ -1697,9 +1697,9 @@ describe("eventBridge", () => {
);
});
- it("CheckpointCreated attaches checkpointId to the latest done tool_call", () => {
+ it("CheckpointCreated only records runtime insight and does not decorate completed tool calls", () => {
const api = createMockGatewayAPI();
- // 先创建并完成一个 tool call
+
handleGatewayEvent(
{
type: EventType.ToolStart,
@@ -1728,7 +1728,6 @@ describe("eventBridge", () => {
},
api,
);
- // 然后创建 checkpoint
handleGatewayEvent(
{
type: EventType.CheckpointCreated,
@@ -1753,8 +1752,12 @@ describe("eventBridge", () => {
const toolMsg = useChatStore
.getState()
.messages.find((m) => m.type === "tool_call");
- expect(toolMsg?.checkpointId).toBe("cp1");
- expect(toolMsg?.checkpointStatus).toBe("available");
+ expect((toolMsg as any)?.checkpointId).toBeUndefined();
+ expect((toolMsg as any)?.checkpointStatus).toBeUndefined();
+ expect(useRuntimeInsightStore.getState().checkpointEvents[0]).toMatchObject({
+ checkpoint_id: "cp1",
+ reason: "pre_write",
+ });
});
it("CheckpointCreated with pre_restore_guard does not override latest rollback baseline", () => {
diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts
index b90013b6..24ed94f1 100644
--- a/web/src/utils/eventBridge.ts
+++ b/web/src/utils/eventBridge.ts
@@ -41,12 +41,10 @@ import {
type PayloadRecord = Record | undefined;
-// 模块级缓存:最新 verification 消息 ID 与最近完成的 tool_call ID
-// 用于避免每次 verification stage / checkpoint 事件都全量扫描 messages 数组
+// 模块级缓存最新 verification 消息 ID,避免每次 verification stage 事件都全量扫描 messages 数组。
let _latestVerificationMsgId: string | undefined;
-let _latestDoneToolCallId: string | undefined;
-// 模块级缓存最新的 checkpoint_id,用于工具占位条目关联后续端到端 diff。
+// 模块级缓存最新的 checkpoint_id,用于文件变更面板关联后续端到端 diff。
let _latestCheckpointId: string | undefined;
let _latestRunDiffRequestId = 0;
let _latestRestoreSyncRequestId = 0;
@@ -64,7 +62,6 @@ const CHECKPOINT_REASON_PRE_RESTORE_GUARD = "pre_restore_guard";
export function resetEventBridgeCursors() {
const keepCheckpointBaseline = useUIStore.getState().isRestoringCheckpoint;
_latestVerificationMsgId = undefined;
- _latestDoneToolCallId = undefined;
_latestCheckpointId = keepCheckpointBaseline
? _latestCheckpointId
: undefined;
@@ -741,8 +738,7 @@ export function handleGatewayEvent(
}
case EventType.ToolResult: {
- const tcId = settleToolResultMessage(eventPayload);
- _latestDoneToolCallId = tcId;
+ settleToolResultMessage(eventPayload);
break;
}
@@ -1095,12 +1091,6 @@ export function handleGatewayEvent(
const payload = eventPayload as CheckpointCreatedPayload | undefined;
if (payload) {
insightStore.addCheckpointEvent(payload);
- if (_latestDoneToolCallId) {
- chatStore.attachCheckpointToToolCall(
- _latestDoneToolCallId,
- payload.checkpoint_id,
- );
- }
if (payload.reason !== CHECKPOINT_REASON_PRE_RESTORE_GUARD) {
_latestCheckpointId = payload.checkpoint_id;
}
@@ -1153,7 +1143,6 @@ export function handleGatewayEvent(
break;
}
useUIStore.getState().clearCheckpointRollbackUndo();
- chatStore.markAllCheckpointsRestored();
refreshSessionAfterCheckpointRestoreEvent(
gatewayAPI,
payload.session_id,
@@ -1183,7 +1172,6 @@ export function handleGatewayEvent(
uiStore.showToast("Rollback undo completed", "success");
break;
}
- chatStore.markAllCheckpointsAvailable();
refreshSessionAfterCheckpointRestoreEvent(
gatewayAPI,
payload.session_id,
diff --git a/web/src/utils/findCheckpointBeforeMessage.test.ts b/web/src/utils/findCheckpointBeforeMessage.test.ts
deleted file mode 100644
index 88f6e4ff..00000000
--- a/web/src/utils/findCheckpointBeforeMessage.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { findCheckpointBeforeMessage } from './findCheckpointBeforeMessage'
-import { type ChatMessage } from '@/stores/useChatStore'
-
-function userMsg(id: string, content = ''): ChatMessage {
- return { id, role: 'user', type: 'text', content, timestamp: 0 }
-}
-
-function toolCallMsg(id: string, opts: { checkpointId?: string } = {}): ChatMessage {
- return {
- id,
- role: 'tool',
- type: 'tool_call',
- content: '',
- toolCallId: id,
- toolName: 'demo',
- toolStatus: 'done',
- checkpointId: opts.checkpointId,
- timestamp: 0,
- }
-}
-
-function assistantMsg(id: string, content = ''): ChatMessage {
- return { id, role: 'assistant', type: 'text', content, timestamp: 0 }
-}
-
-describe('findCheckpointBeforeMessage', () => {
- it('returns null for the very first user message', () => {
- const messages = [userMsg('u1')]
- expect(findCheckpointBeforeMessage(messages, 'u1')).toBeNull()
- })
-
- it('returns null when the message is not in the list', () => {
- const messages = [userMsg('u1'), toolCallMsg('t1', { checkpointId: 'cp_a' })]
- expect(findCheckpointBeforeMessage(messages, 'missing')).toBeNull()
- })
-
- it('returns the most recent tool_call checkpoint before the user message', () => {
- const messages = [
- userMsg('u1'),
- toolCallMsg('t1', { checkpointId: 'cp_a' }),
- assistantMsg('a1'),
- userMsg('u2'),
- ]
- expect(findCheckpointBeforeMessage(messages, 'u2')).toEqual({ checkpointId: 'cp_a' })
- })
-
- it('skips tool_call messages without checkpointId', () => {
- const messages = [
- userMsg('u1'),
- toolCallMsg('t1', { checkpointId: 'cp_a' }),
- toolCallMsg('t2'), // no checkpoint
- userMsg('u2'),
- ]
- expect(findCheckpointBeforeMessage(messages, 'u2')).toEqual({ checkpointId: 'cp_a' })
- })
-
- it('returns null when only non-tool_call messages precede the user message', () => {
- const messages = [
- assistantMsg('a1', 'welcome'),
- userMsg('u1'),
- ]
- expect(findCheckpointBeforeMessage(messages, 'u1')).toBeNull()
- })
-
- it('uses the latest preceding checkpoint when multiple exist', () => {
- const messages = [
- userMsg('u1'),
- toolCallMsg('t1', { checkpointId: 'cp_a' }),
- assistantMsg('a1'),
- userMsg('u2'),
- toolCallMsg('t2', { checkpointId: 'cp_b' }),
- assistantMsg('a2'),
- userMsg('u3'),
- ]
- expect(findCheckpointBeforeMessage(messages, 'u3')).toEqual({ checkpointId: 'cp_b' })
- })
-})
diff --git a/web/src/utils/findCheckpointBeforeMessage.ts b/web/src/utils/findCheckpointBeforeMessage.ts
deleted file mode 100644
index 5f0f7ebe..00000000
--- a/web/src/utils/findCheckpointBeforeMessage.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { type ChatMessage } from '@/stores/useChatStore'
-
-/**
- * 在消息列表中找到给定用户消息发送前最近一次 tool_call 的 checkpoint。
- *
- * 由于 checkpoint 在协议层挂载在 tool_call 消息上(由 `_latestDoneToolCallId` 时序关联),
- * "回退到 user message U_n 发送时的状态" 等价于"恢复 U_n 之前最近一条带 checkpointId 的 tool_call 的 cp"。
- *
- * 第一条 user message 之前没有 cp 时返回 null —— 调用方据此决定是否渲染 revert 按钮。
- */
-export function findCheckpointBeforeMessage(
- messages: ChatMessage[],
- userMessageId: string,
-): { checkpointId: string } | null {
- const idx = messages.findIndex((m) => m.id === userMessageId)
- if (idx <= 0) return null
- for (let i = idx - 1; i >= 0; i--) {
- const m = messages[i]
- if (m.type === 'tool_call' && m.checkpointId) {
- return { checkpointId: m.checkpointId }
- }
- }
- return null
-}
From 9792490c5f7940d2434b76d8e7686b97c443cfb1 Mon Sep 17 00:00:00 2001
From: Yumiue <229866007@qq.com>
Date: Sun, 17 May 2026 09:31:18 +0800
Subject: [PATCH 5/5] test(checkpoint): cover baseline rollback edge cases
---
internal/checkpoint/per_edit_snapshot_test.go | 41 ++++++++
internal/runtime/checkpoint_flow_test.go | 94 +++++++++++++++++++
2 files changed, 135 insertions(+)
diff --git a/internal/checkpoint/per_edit_snapshot_test.go b/internal/checkpoint/per_edit_snapshot_test.go
index f21b1846..27358e13 100644
--- a/internal/checkpoint/per_edit_snapshot_test.go
+++ b/internal/checkpoint/per_edit_snapshot_test.go
@@ -1973,6 +1973,46 @@ func TestFinalizeExactForCheckpointPaths_CapturesSelectedCurrentPaths(t *testing
}
}
+func TestFinalizeExactForCheckpointPaths_UsesVersionMetaWhenDisplayPathIndexIsMissing(t *testing.T) {
+ store, workdir := newTestStore(t)
+ target := writeWorkdirFile(t, workdir, "fallback.txt", "before\n")
+ if _, err := store.CapturePreWrite(target); err != nil {
+ t.Fatalf("CapturePreWrite: %v", err)
+ }
+ if err := os.WriteFile(target, []byte("source\n"), 0o644); err != nil {
+ t.Fatalf("write source: %v", err)
+ }
+ if _, err := store.FinalizeWithExactState("cp-source"); err != nil {
+ t.Fatalf("FinalizeWithExactState: %v", err)
+ }
+ store.Reset()
+
+ store.indexMu.Lock()
+ store.displayPaths = map[string]string{}
+ store.indexMu.Unlock()
+
+ if err := os.WriteFile(target, []byte("guard\n"), 0o644); err != nil {
+ t.Fatalf("write guard: %v", err)
+ }
+ written, err := store.FinalizeExactForCheckpointPaths("cp-guard", "cp-source", []string{"fallback.txt"})
+ if err != nil {
+ t.Fatalf("FinalizeExactForCheckpointPaths: %v", err)
+ }
+ if !written {
+ t.Fatal("FinalizeExactForCheckpointPaths written = false, want true")
+ }
+
+ if err := os.WriteFile(target, []byte("drift\n"), 0o644); err != nil {
+ t.Fatalf("write drift: %v", err)
+ }
+ if err := store.RestoreExact(context.Background(), "cp-guard"); err != nil {
+ t.Fatalf("RestoreExact(cp-guard): %v", err)
+ }
+ if got := mustReadFile(t, target); got != "guard\n" {
+ t.Fatalf("restored content = %q, want guard", got)
+ }
+}
+
func TestFinalizeExactForCheckpointPaths_CapturesDeletedAndCreatedCurrentState(t *testing.T) {
store, workdir := newTestStore(t)
deleted := writeWorkdirFile(t, workdir, "deleted.txt", "before delete\n")
@@ -2042,6 +2082,7 @@ func TestFinalizeExactForCheckpointPaths_ValidatesInputsAndPaths(t *testing.T) {
}{
{name: "empty checkpoint", checkpoint: "", source: "cp-source", paths: []string{"tracked.txt"}, want: "empty checkpointID"},
{name: "empty source", checkpoint: "cp-guard", source: "", paths: []string{"tracked.txt"}, want: "source checkpoint id required"},
+ {name: "missing source", checkpoint: "cp-guard", source: "cp-missing", paths: []string{"tracked.txt"}, want: "checkpoint cp-missing not found"},
{name: "empty paths", checkpoint: "cp-guard", source: "cp-source", paths: nil, want: "exact snapshot paths required"},
{name: "missing path", checkpoint: "cp-guard", source: "cp-source", paths: []string{"missing.txt"}, want: "baseline version for path missing.txt not found"},
}
diff --git a/internal/runtime/checkpoint_flow_test.go b/internal/runtime/checkpoint_flow_test.go
index 034d02c7..8c3c1295 100644
--- a/internal/runtime/checkpoint_flow_test.go
+++ b/internal/runtime/checkpoint_flow_test.go
@@ -611,6 +611,100 @@ func TestRestoreCheckpointBaselineRejectsPathsThatNormalizeEmpty(t *testing.T) {
}
}
+func TestRestoreCheckpointBaselineRejectsNilPathsBeforeLoadingCheckpoint(t *testing.T) {
+ fixture := newRuntimeCheckpointFixture(t)
+
+ _, _, err := fixture.service.restoreCheckpointBaseline(context.Background(), fixture.session.ID, "cp-missing", nil)
+ if err == nil || !strings.Contains(err.Error(), "baseline restore paths required") {
+ t.Fatalf("restoreCheckpointBaseline() error = %v, want baseline restore paths required", err)
+ }
+}
+
+func TestRestoreCheckpointBaselineRejectsMissingStores(t *testing.T) {
+ var service Service
+
+ _, _, err := service.restoreCheckpointBaseline(context.Background(), "session-1", "cp-1", []string{"baseline.txt"})
+ if err == nil || !strings.Contains(err.Error(), "store not available") {
+ t.Fatalf("restoreCheckpointBaseline() error = %v, want store not available", err)
+ }
+}
+
+func TestRestoreCheckpointBaselineRejectsUnavailableOrNonRestorableCheckpoint(t *testing.T) {
+ fixture := newRuntimeCheckpointFixture(t)
+ target := filepath.Join(fixture.workdir, "baseline.txt")
+ if err := os.WriteFile(target, []byte("before baseline"), 0o644); err != nil {
+ t.Fatalf("WriteFile(before baseline) error = %v", err)
+ }
+ if _, err := fixture.perEditStore.CapturePreWrite(target); err != nil {
+ t.Fatalf("CapturePreWrite() error = %v", err)
+ }
+ if _, err := fixture.perEditStore.FinalizeWithExactState("cp-baseline-code"); err != nil {
+ t.Fatalf("FinalizeWithExactState() error = %v", err)
+ }
+ fixture.perEditStore.Reset()
+
+ tests := []struct {
+ name string
+ id string
+ status agentsession.CheckpointStatus
+ restorable bool
+ want string
+ }{
+ {
+ name: "unavailable status",
+ id: "cp-unavailable",
+ status: agentsession.CheckpointStatusRestored,
+ restorable: true,
+ want: "expected available",
+ },
+ {
+ name: "not restorable",
+ id: "cp-not-restorable",
+ status: agentsession.CheckpointStatusAvailable,
+ restorable: false,
+ want: "not restorable",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ record, err := fixture.checkpointStore.CreateCheckpoint(context.Background(), checkpoint.CreateCheckpointInput{
+ Record: agentsession.CheckpointRecord{
+ CheckpointID: tt.id,
+ WorkspaceKey: agentsession.WorkspacePathKey(fixture.session.Workdir),
+ SessionID: fixture.session.ID,
+ RunID: "run-" + tt.id,
+ Workdir: fixture.session.Workdir,
+ CreatedAt: time.Now(),
+ Reason: agentsession.CheckpointReasonManual,
+ CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-baseline-code"),
+ Restorable: tt.restorable,
+ Status: tt.status,
+ },
+ SessionCP: agentsession.SessionCheckpoint{
+ ID: agentsession.NewID("sc"),
+ SessionID: fixture.session.ID,
+ HeadJSON: `{"workdir":"` + fixture.session.Workdir + `"}`,
+ MessagesJSON: `[]`,
+ CreatedAt: time.Now(),
+ },
+ })
+ if err != nil {
+ t.Fatalf("CreateCheckpoint() error = %v", err)
+ }
+ if tt.status != agentsession.CheckpointStatusAvailable {
+ if err := fixture.checkpointStore.UpdateCheckpointStatus(context.Background(), record.CheckpointID, tt.status); err != nil {
+ t.Fatalf("UpdateCheckpointStatus() error = %v", err)
+ }
+ }
+
+ _, _, err = fixture.service.restoreCheckpointBaseline(context.Background(), fixture.session.ID, record.CheckpointID, []string{"baseline.txt"})
+ if err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("restoreCheckpointBaseline() error = %v, want %q", err, tt.want)
+ }
+ })
+ }
+}
+
func TestRestoreCheckpointBaselineWrapsRestoreBaselineError(t *testing.T) {
fixture := newRuntimeCheckpointFixture(t)
target := filepath.Join(fixture.workdir, "baseline.txt")