From 53f543c1d05451bd77e29cbdf4509dcd4362a965 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Fri, 15 May 2026 23:30:56 -0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(undo)=EF=BC=9A=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=96=B0=E5=A2=9Eundo=E9=9D=A2=E6=9D=BF=EF=BC=8C=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=96=B0=E5=A2=9Eguard,=E6=94=AF=E6=8C=81undo?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/checkpoint/per_edit_snapshot.go | 53 +++++ internal/runtime/checkpoint_flow_test.go | 77 ++++++- internal/runtime/checkpoint_restore.go | 40 ++-- .../panels/FileChangePanel.test.tsx | 126 +++++++++++ web/src/components/panels/FileChangePanel.tsx | 115 +++++++++- web/src/stores/useSessionStore.ts | 3 + web/src/stores/useUIStore.ts | 36 +++ web/src/utils/eventBridge.test.ts | 211 +++++++++++++++++- web/src/utils/eventBridge.ts | 55 +++-- 9 files changed, 678 insertions(+), 38 deletions(-) diff --git a/internal/checkpoint/per_edit_snapshot.go b/internal/checkpoint/per_edit_snapshot.go index 46f87a28..37b61ea2 100644 --- a/internal/checkpoint/per_edit_snapshot.go +++ b/internal/checkpoint/per_edit_snapshot.go @@ -332,6 +332,59 @@ func (s *PerEditSnapshotStore) FinalizeExactForCheckpoints(checkpointID string, return true, nil } +// FinalizeExactForCheckpointPaths 为指定 checkpoint 的部分路径捕获当前精确状态,用于 baseline rollback 的 Undo guard。 +func (s *PerEditSnapshotStore) FinalizeExactForCheckpointPaths(checkpointID string, sourceCheckpointID string, relPaths []string) (bool, error) { + if strings.TrimSpace(checkpointID) == "" { + return false, fmt.Errorf("per-edit: empty checkpointID") + } + if strings.TrimSpace(sourceCheckpointID) == "" { + return false, fmt.Errorf("per-edit: source checkpoint id required") + } + if len(relPaths) == 0 { + return false, fmt.Errorf("per-edit: exact snapshot paths required") + } + + source, err := s.readCheckpointMeta(sourceCheckpointID) + if err != nil { + return false, err + } + + baseVersions := make(map[string]int) + s.indexMu.Lock() + for _, relPath := range relPaths { + _, _, hash, version, resolveErr := s.resolveBaselineRestoreTargetLocked(source, sourceCheckpointID, relPath) + if resolveErr != nil { + s.indexMu.Unlock() + return false, resolveErr + } + if _, ok := baseVersions[hash]; !ok { + baseVersions[hash] = version + } + } + s.indexMu.Unlock() + + if len(baseVersions) == 0 { + return false, nil + } + exactSnapshot, err := s.captureExactStateSnapshot(baseVersions) + if err != nil { + return false, err + } + if len(exactSnapshot) == 0 { + return false, nil + } + meta := CheckpointMeta{ + CheckpointID: checkpointID, + CreatedAt: time.Now().UTC(), + FileVersions: exactSnapshot, + ExactFileVersions: exactSnapshot, + } + if err := s.writeCheckpointMeta(meta); err != nil { + return false, err + } + return true, nil +} + // captureExactStateSnapshot 为当前 pending 里的每个文件追加一个“checkpoint 结束态”精确版本。 func (s *PerEditSnapshotStore) captureExactStateSnapshot(baseVersions map[string]int) (map[string]int, error) { s.indexMu.Lock() diff --git a/internal/runtime/checkpoint_flow_test.go b/internal/runtime/checkpoint_flow_test.go index b5f86d8e..14cea44a 100644 --- a/internal/runtime/checkpoint_flow_test.go +++ b/internal/runtime/checkpoint_flow_test.go @@ -472,8 +472,73 @@ func TestRestoreCheckpointBaselineEmitsModeAndPaths(t *testing.T) { if len(payload.Paths) != 1 || payload.Paths[0] != "baseline.txt" { t.Fatalf("restore payload paths = %#v, want [baseline.txt]", payload.Paths) } - if payload.GuardCheckpointID != "" { - t.Fatalf("baseline restore guard checkpoint id = %q, want empty", payload.GuardCheckpointID) + if payload.GuardCheckpointID == "" { + t.Fatal("baseline restore guard checkpoint id is empty") + } +} + +func TestUndoRestoreCheckpoint_RestoresBaselineRollbackGuardPaths(t *testing.T) { + fixture := newRuntimeCheckpointFixture(t) + targetA := filepath.Join(fixture.workdir, "baseline-a.txt") + targetB := filepath.Join(fixture.workdir, "baseline-b.txt") + if err := os.WriteFile(targetA, []byte("a before"), 0o644); err != nil { + t.Fatalf("WriteFile(targetA before) error = %v", err) + } + if err := os.WriteFile(targetB, []byte("b before"), 0o644); err != nil { + t.Fatalf("WriteFile(targetB before) error = %v", err) + } + if _, err := fixture.perEditStore.CapturePreWrite(targetA); err != nil { + t.Fatalf("CapturePreWrite(targetA) error = %v", err) + } + if _, err := fixture.perEditStore.CapturePreWrite(targetB); err != nil { + t.Fatalf("CapturePreWrite(targetB) error = %v", err) + } + + state := newRunState("run-baseline-undo", fixture.session) + if err := fixture.service.createStartOfTurnCheckpoint(context.Background(), &state); err != nil { + t.Fatalf("createStartOfTurnCheckpoint() error = %v", err) + } + records, err := fixture.checkpointStore.ListCheckpoints(context.Background(), fixture.session.ID, checkpoint.ListCheckpointOpts{}) + if err != nil { + t.Fatalf("ListCheckpoints() error = %v", err) + } + if len(records) != 1 { + t.Fatalf("records = %#v, want 1", records) + } + cpRecord := records[0] + if err := fixture.checkpointStore.UpdateCheckpointStatus(context.Background(), cpRecord.CheckpointID, agentsession.CheckpointStatusAvailable); err != nil { + t.Fatalf("UpdateCheckpointStatus() error = %v", err) + } + if err := os.WriteFile(targetA, []byte("a after"), 0o644); err != nil { + t.Fatalf("WriteFile(targetA after) error = %v", err) + } + if err := os.WriteFile(targetB, []byte("b after"), 0o644); err != nil { + t.Fatalf("WriteFile(targetB after) error = %v", err) + } + + if _, err := fixture.service.RestoreCheckpoint(context.Background(), GatewayRestoreInput{ + SessionID: fixture.session.ID, + CheckpointID: cpRecord.CheckpointID, + Mode: "baseline", + Paths: []string{"baseline-a.txt"}, + }); err != nil { + t.Fatalf("RestoreCheckpoint(baseline) error = %v", err) + } + if got := string(mustReadRuntimeFile(t, targetA)); got != "a before" { + t.Fatalf("baseline restored targetA = %q, want a before", got) + } + if got := string(mustReadRuntimeFile(t, targetB)); got != "b after" { + t.Fatalf("baseline restored targetB = %q, want b after", got) + } + + if _, err := fixture.service.UndoRestoreCheckpoint(context.Background(), fixture.session.ID); err != nil { + t.Fatalf("UndoRestoreCheckpoint() error = %v", err) + } + if got := string(mustReadRuntimeFile(t, targetA)); got != "a after" { + t.Fatalf("undo targetA = %q, want a after", got) + } + if got := string(mustReadRuntimeFile(t, targetB)); got != "b after" { + t.Fatalf("undo targetB = %q, want b after", got) } } @@ -503,7 +568,7 @@ func TestRestoreCheckpointBaselineRejectsPathsThatNormalizeEmpty(t *testing.T) { t.Fatalf("UpdateCheckpointStatus() error = %v", err) } - _, err = fixture.service.restoreCheckpointBaseline(context.Background(), fixture.session.ID, cpRecord.CheckpointID, []string{"./", " . "}) + _, _, err = fixture.service.restoreCheckpointBaseline(context.Background(), fixture.session.ID, cpRecord.CheckpointID, []string{"./", " . "}) if err == nil || !strings.Contains(err.Error(), "baseline restore paths required") { t.Fatalf("restoreCheckpointBaseline() error = %v, want baseline restore paths required", err) } @@ -535,9 +600,9 @@ func TestRestoreCheckpointBaselineWrapsRestoreBaselineError(t *testing.T) { t.Fatalf("UpdateCheckpointStatus() error = %v", err) } - _, err = fixture.service.restoreCheckpointBaseline(context.Background(), fixture.session.ID, cpRecord.CheckpointID, []string{"missing.txt"}) - if err == nil || !strings.Contains(err.Error(), "baseline restore code") || !strings.Contains(err.Error(), "missing.txt") { - t.Fatalf("restoreCheckpointBaseline() error = %v, want wrapped missing baseline path error", err) + _, _, err = fixture.service.restoreCheckpointBaseline(context.Background(), fixture.session.ID, cpRecord.CheckpointID, []string{"missing.txt"}) + if err == nil || !strings.Contains(err.Error(), "baseline guard") || !strings.Contains(err.Error(), "missing.txt") { + t.Fatalf("restoreCheckpointBaseline() error = %v, want wrapped missing baseline guard path error", err) } } diff --git a/internal/runtime/checkpoint_restore.go b/internal/runtime/checkpoint_restore.go index 17038559..f8ece70e 100644 --- a/internal/runtime/checkpoint_restore.go +++ b/internal/runtime/checkpoint_restore.go @@ -197,14 +197,14 @@ func (s *Service) RestoreCheckpoint(ctx context.Context, input GatewayRestoreInp } if mode == "baseline" { paths := normalizeBaselineRestorePaths(input.Paths) - result, err := s.restoreCheckpointBaseline(ctx, input.SessionID, input.CheckpointID, paths) + result, guardRecord, err := s.restoreCheckpointBaseline(ctx, input.SessionID, input.CheckpointID, paths) if err != nil { return RestoreResult{}, err } _ = s.emit(ctx, EventCheckpointRestored, "", result.SessionID, CheckpointRestoredPayload{ CheckpointID: result.CheckpointID, SessionID: result.SessionID, - GuardCheckpointID: "", + GuardCheckpointID: guardRecord.CheckpointID, Mode: "baseline", Paths: paths, }) @@ -326,43 +326,55 @@ func (s *Service) restoreCheckpointBaseline( ctx context.Context, sessionID, checkpointID string, paths []string, -) (RestoreResult, error) { +) (RestoreResult, agentsession.CheckpointRecord, error) { if s.checkpointStore == nil || s.perEditStore == nil { - return RestoreResult{}, fmt.Errorf("checkpoint: store not available") + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: store not available") } sessionID = strings.TrimSpace(sessionID) checkpointID = strings.TrimSpace(checkpointID) if sessionID == "" || checkpointID == "" { - return RestoreResult{}, fmt.Errorf("checkpoint: session_id and checkpoint_id required") + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: session_id and checkpoint_id required") } if len(paths) == 0 { - return RestoreResult{}, fmt.Errorf("checkpoint: baseline restore paths required") + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: baseline restore paths required") } record, _, err := s.checkpointStore.GetCheckpoint(ctx, checkpointID) if err != nil { - return RestoreResult{}, err + return RestoreResult{}, agentsession.CheckpointRecord{}, err } if record.SessionID != sessionID { - return RestoreResult{}, fmt.Errorf("checkpoint: session mismatch") + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: session mismatch") } if record.Status != agentsession.CheckpointStatusAvailable { - return RestoreResult{}, fmt.Errorf("checkpoint: status is %s, expected available", record.Status) + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: status is %s, expected available", record.Status) } if !record.Restorable { - return RestoreResult{}, fmt.Errorf("checkpoint: not restorable") + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: not restorable") } perEditID := checkpoint.PerEditCheckpointIDFromRef(record.CodeCheckpointRef) if perEditID == "" { - return RestoreResult{}, fmt.Errorf("checkpoint: %s has no code snapshot", checkpointID) + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: %s has no code snapshot", checkpointID) } relPaths := normalizeBaselineRestorePaths(paths) if len(relPaths) == 0 { - return RestoreResult{}, fmt.Errorf("checkpoint: baseline restore paths required") + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: baseline restore paths required") + } + guardID := agentsession.NewID("checkpoint") + guardWritten, finalizeErr := s.perEditStore.FinalizeExactForCheckpointPaths(guardID, perEditID, relPaths) + if finalizeErr != nil { + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: finalize baseline guard: %w", finalizeErr) + } + guardRecord, guardErr := s.createGuardCheckpoint(ctx, sessionID, record.RunID, guardID, guardWritten, "") + if guardErr != nil { + if guardWritten { + _ = s.perEditStore.DeleteCheckpoint(guardID) + } + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: create baseline guard: %w", guardErr) } if err := s.perEditStore.RestoreBaseline(ctx, perEditID, relPaths); err != nil { - return RestoreResult{}, fmt.Errorf("checkpoint: baseline restore code: %w", err) + return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: baseline restore code: %w", err) } - return RestoreResult{CheckpointID: checkpointID, SessionID: sessionID}, nil + return RestoreResult{CheckpointID: checkpointID, SessionID: sessionID}, guardRecord, nil } // normalizeBaselineRestorePaths 统一 baseline 文件回退路径,保证恢复执行与事件通知使用同一组相对路径。 diff --git a/web/src/components/panels/FileChangePanel.test.tsx b/web/src/components/panels/FileChangePanel.test.tsx index 8e553908..8f306c8b 100644 --- a/web/src/components/panels/FileChangePanel.test.tsx +++ b/web/src/components/panels/FileChangePanel.test.tsx @@ -20,6 +20,7 @@ const mockGatewayAPI = { listGitDiffFiles: vi.fn(), readGitDiffFile: vi.fn(), restoreCheckpoint: vi.fn(), + undoRestore: vi.fn(), loadSession: vi.fn(), listSessionTodos: vi.fn(), listCheckpoints: vi.fn(), @@ -102,6 +103,12 @@ describe("FileChangePanel", () => { session_id: "sess-1", }, }); + mockGatewayAPI.undoRestore.mockResolvedValue({ + payload: { + checkpoint_id: "cp-1", + session_id: "sess-1", + }, + }); mockGatewayAPI.loadSession.mockResolvedValue({ payload: { id: "sess-1", @@ -177,6 +184,8 @@ describe("FileChangePanel", () => { changesPanelWidth: 560, theme: "dark", isRestoringCheckpoint: false, + checkpointRollbackUndo: null, + showToast: vi.fn(), } as never); }); @@ -268,6 +277,123 @@ describe("FileChangePanel", () => { }); }); + it("shows file rollback undo entry and hides stale file change list", () => { + useUIStore.setState({ + checkpointRollbackUndo: { + sessionId: "sess-1", + checkpointId: "cp-restored", + guardCheckpointId: "guard-1", + paths: ["src/a.txt"], + status: "idle", + }, + } as never); + + render(); + + expect(screen.getByTestId("checkpoint-undo-restore")).toBeInTheDocument(); + expect(screen.getByTestId("undo-last-rollback")).toBeEnabled(); + expect(screen.queryByText("src/a.txt")).not.toBeInTheDocument(); + expect(screen.queryByTestId("changes-content-stack")).not.toBeInTheDocument(); + }); + + it("shows file change list after rollback undo state is cleared", () => { + useUIStore.setState({ + checkpointRollbackUndo: null, + fileChanges: [ + { + id: "fc-new", + path: "src/new.txt", + status: "modified", + additions: 1, + deletions: 1, + checkpoint_id: "cp-new", + rollback_checkpoint_id: "cp-rollback-new", + hunks: [], + }, + ], + } as never); + + render(); + + expect(screen.queryByTestId("checkpoint-undo-restore")).not.toBeInTheDocument(); + expect(screen.getByTestId("changes-content-stack")).toBeInTheDocument(); + expect(screen.getByText("src/new.txt")).toBeInTheDocument(); + }); + + it("calls undoRestore after confirming file rollback undo", async () => { + useUIStore.setState({ + checkpointRollbackUndo: { + sessionId: "sess-1", + checkpointId: "cp-restored", + guardCheckpointId: "guard-1", + paths: ["src/a.txt"], + status: "idle", + }, + } as never); + + render(); + + fireEvent.click(screen.getByTestId("undo-last-rollback")); + fireEvent.click(screen.getByRole("button", { name: "Undo rollback" })); + + await waitFor(() => { + expect(mockGatewayAPI.undoRestore).toHaveBeenCalledWith("sess-1"); + }); + expect(useUIStore.getState().checkpointRollbackUndo?.status).toBe("undoing"); + }); + + it("keeps file rollback undo entry and reports failure when undoRestore fails", async () => { + mockGatewayAPI.undoRestore.mockRejectedValue(new Error("network down")); + const showToast = vi.fn(); + useUIStore.setState({ + showToast, + checkpointRollbackUndo: { + sessionId: "sess-1", + checkpointId: "cp-restored", + guardCheckpointId: "guard-1", + paths: ["src/a.txt"], + status: "idle", + }, + } as never); + + render(); + + fireEvent.click(screen.getByTestId("undo-last-rollback")); + fireEvent.click(screen.getByRole("button", { name: "Undo rollback" })); + + await waitFor(() => { + expect(showToast).toHaveBeenCalledWith( + "Undo rollback failed: network down", + "error", + ); + }); + expect(useUIStore.getState().checkpointRollbackUndo?.status).toBe("idle"); + expect(screen.getByTestId("undo-last-rollback")).toBeEnabled(); + }); + + it("disables file rollback undo while generating or restoring", () => { + useUIStore.setState({ + checkpointRollbackUndo: { + sessionId: "sess-1", + checkpointId: "cp-restored", + guardCheckpointId: "guard-1", + paths: ["src/a.txt"], + status: "idle", + }, + } as never); + useChatStore.setState({ isGenerating: true } as never); + + const { rerender } = render(); + + expect(screen.getByTestId("undo-last-rollback")).toBeDisabled(); + + useChatStore.setState({ isGenerating: false } as never); + useUIStore.setState({ isRestoringCheckpoint: true } as never); + rerender(); + + expect(screen.getByTestId("undo-last-rollback")).toBeDisabled(); + }); + it("rolls back only remaining file changes after one file was already restored", async () => { useUIStore.setState({ fileChanges: [ diff --git a/web/src/components/panels/FileChangePanel.tsx b/web/src/components/panels/FileChangePanel.tsx index 5477ae4b..ba038d5f 100644 --- a/web/src/components/panels/FileChangePanel.tsx +++ b/web/src/components/panels/FileChangePanel.tsx @@ -19,6 +19,7 @@ import { PanelRightClose, RefreshCw, RotateCcw, + Undo2, X, } from "lucide-react"; import { useGatewayAPI } from "@/context/RuntimeProvider"; @@ -453,13 +454,20 @@ function ChangesView() { const isRestoringCheckpoint = useUIStore( (state) => state.isRestoringCheckpoint, ); + const checkpointRollbackUndo = useUIStore( + (state) => state.checkpointRollbackUndo, + ); const setRestoringCheckpoint = useUIStore( (state) => state.setRestoringCheckpoint, ); + const setCheckpointRollbackUndoStatus = useUIStore( + (state) => state.setCheckpointRollbackUndoStatus, + ); const showToast = useUIStore((state) => state.showToast); const fileChanges = useUIStore((state) => state.fileChanges); const [expandedIds, setExpandedIds] = useState>(new Set()); const [confirmingRollbackAll, setConfirmingRollbackAll] = useState(false); + const [confirmingUndoRestore, setConfirmingUndoRestore] = useState(false); const counts = useMemo(() => getChangeCounts(fileChanges), [fileChanges]); const rollbackGroups = useMemo(() => { const groups = new Map(); @@ -485,6 +493,15 @@ function ChangesView() { : canRollbackAll ? "Rollback all files in this run" : "No rollback checkpoint available for current file changes"; + const activeUndo = + checkpointRollbackUndo?.sessionId === sessionId + ? checkpointRollbackUndo + : null; + const undoDisabled = + !activeUndo || + isGenerating || + isRestoringCheckpoint || + activeUndo.status === "undoing"; const scrollAreaRef = useRef(null); const toggleExpanded = (id: string) => { @@ -517,6 +534,21 @@ function ChangesView() { } } + // handleUndoRestore 只触发撤销文件回退,请求成功后的 UI 收敛由 checkpoint_undo_restore 事件负责。 + async function handleUndoRestore() { + setConfirmingUndoRestore(false); + if (!gatewayAPI || !sessionId || undoDisabled) return; + + setCheckpointRollbackUndoStatus("undoing"); + try { + await gatewayAPI.undoRestore(sessionId); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + showToast(`Undo rollback failed: ${message}`, "error"); + setCheckpointRollbackUndoStatus("idle"); + } + } + return (
@@ -543,6 +575,43 @@ function ChangesView() { Rollback all
+ {activeUndo && ( +
+
+ + Last rollback can be undone + + + {activeUndo.paths.length} file rollback + +
+ +
+ )}
{fileChanges.length} 个文件 @@ -560,7 +629,12 @@ function ChangesView() { data-testid="changes-scroll-area" style={styles.scrollArea} > - {fileChanges.length === 0 ? ( + {activeUndo ? ( +
+ Rollback completed. Use Undo last rollback above to restore the + rolled back file changes. +
+ ) : fileChanges.length === 0 ? (
当前会话暂无文件变更
) : (
@@ -587,6 +661,17 @@ function ChangesView() { onCancel={() => setConfirmingRollbackAll(false)} /> )} + {confirmingUndoRestore && ( + setConfirmingUndoRestore(false)} + /> + )}
); } @@ -1417,6 +1502,34 @@ const styles: Record = { fontFamily: "var(--font-ui)", flexWrap: "wrap", }, + undoRestoreBar: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + marginTop: 8, + padding: "8px 10px", + border: "1px solid var(--border-primary)", + borderRadius: 8, + background: "rgba(59, 130, 246, 0.08)", + }, + undoRestoreText: { + display: "flex", + flexDirection: "column", + gap: 2, + minWidth: 0, + }, + undoRestoreTitle: { + color: "var(--text-primary)", + fontSize: 12, + fontWeight: 600, + fontFamily: "var(--font-ui)", + }, + undoRestoreMeta: { + color: "var(--text-tertiary)", + fontSize: 11, + fontFamily: "var(--font-mono)", + }, summaryWrap: { display: "flex", gap: 10, diff --git a/web/src/stores/useSessionStore.ts b/web/src/stores/useSessionStore.ts index 5f5fcd7f..81d8274c 100644 --- a/web/src/stores/useSessionStore.ts +++ b/web/src/stores/useSessionStore.ts @@ -304,6 +304,7 @@ export const useSessionStore = create((set, get) => ({ chatStore.setTransitioning(true) chatStore.clearMessages() useRuntimeInsightStore.getState().reset() + useUIStore.getState().clearCheckpointRollbackUndo() // 2. Update session ID set({ currentSessionId: sessionId }) @@ -350,6 +351,7 @@ export const useSessionStore = create((set, get) => ({ } useChatStore.getState().clearMessages() useRuntimeInsightStore.getState().reset() + useUIStore.getState().clearCheckpointRollbackUndo() set({ currentSessionId: '', currentProjectId: '' }) }, @@ -375,6 +377,7 @@ export const useSessionStore = create((set, get) => ({ } useChatStore.getState().clearMessages() useRuntimeInsightStore.getState().reset() + useUIStore.getState().clearCheckpointRollbackUndo() set({ currentSessionId: '', currentProjectId: '' }) }, diff --git a/web/src/stores/useUIStore.ts b/web/src/stores/useUIStore.ts index 2a013f80..0b1a68dc 100644 --- a/web/src/stores/useUIStore.ts +++ b/web/src/stores/useUIStore.ts @@ -12,6 +12,14 @@ export interface Toast { type: "info" | "error" | "success"; } +export interface CheckpointRollbackUndoState { + sessionId: string; + checkpointId: string; + guardCheckpointId: string; + paths: string[]; + status: "idle" | "undoing"; +} + export interface FileChange { id: string; path: string; @@ -215,6 +223,7 @@ interface UIState { searchQuery: string; fileChanges: FileChange[]; isRestoringCheckpoint: boolean; + checkpointRollbackUndo: CheckpointRollbackUndoState | null; gitDiffSummary: GitDiffSummary; gitDiffLoading: boolean; gitDiffError: string; @@ -239,6 +248,13 @@ interface UIState { rejectFileChange: (id: string) => void; clearFileChanges: () => void; setRestoringCheckpoint: (restoring: boolean) => void; + setCheckpointRollbackUndo: ( + undo: Omit, + ) => void; + setCheckpointRollbackUndoStatus: ( + status: CheckpointRollbackUndoState["status"], + ) => void; + clearCheckpointRollbackUndo: () => void; openPreviewTab: (path: string) => OpenPreviewTabResult; openGitDiffTab: (path: string) => OpenPreviewTabResult; activatePreviewTab: (id: string) => void; @@ -272,6 +288,7 @@ export const useUIStore = create((set) => ({ searchQuery: "", fileChanges: [], isRestoringCheckpoint: false, + checkpointRollbackUndo: null, gitDiffSummary: createEmptyGitDiffSummary(), gitDiffLoading: false, gitDiffError: "", @@ -316,6 +333,25 @@ export const useUIStore = create((set) => ({ clearFileChanges: () => set({ fileChanges: [] }), setRestoringCheckpoint: (isRestoringCheckpoint) => set({ isRestoringCheckpoint }), + setCheckpointRollbackUndo: (undo) => + set({ + checkpointRollbackUndo: { + ...undo, + status: "idle", + }, + }), + setCheckpointRollbackUndoStatus: (status) => + set((state) => + state.checkpointRollbackUndo + ? { + checkpointRollbackUndo: { + ...state.checkpointRollbackUndo, + status, + }, + } + : state, + ), + clearCheckpointRollbackUndo: () => set({ checkpointRollbackUndo: null }), openPreviewTab: (path) => { const normalizedPath = path.trim(); const tabID = `file:${normalizedPath}`; diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts index a2213c3e..9bf2b53e 100644 --- a/web/src/utils/eventBridge.test.ts +++ b/web/src/utils/eventBridge.test.ts @@ -47,6 +47,7 @@ beforeEach(() => { toasts: [], fileChanges: [], isRestoringCheckpoint: false, + checkpointRollbackUndo: null, } as any); }); @@ -304,6 +305,45 @@ describe("eventBridge", () => { expect(change?.status).toBe("pending"); }); + it("ToolStart file placeholders clear stale rollback undo state", () => { + const api = createMockGatewayAPI(); + useUIStore.setState({ + checkpointRollbackUndo: { + sessionId: "sess-1", + checkpointId: "cp-rollback", + guardCheckpointId: "guard-rollback", + paths: ["old.txt"], + status: "idle", + }, + } as any); + + handleGatewayEvent( + { + type: EventType.ToolStart, + payload: { + payload: { + runtime_event_type: EventType.ToolStart, + payload: { + name: "filesystem_write_file", + id: "tc-new-change", + arguments: '{"path":"new-change.txt"}', + }, + }, + }, + session_id: "sess-1", + run_id: "run-1", + }, + api, + ); + + expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); + expect( + useUIStore + .getState() + .fileChanges.some((entry) => entry.path === "new-change.txt"), + ).toBe(true); + }); + it("ToolResult updates an existing tool call message", () => { const api = createMockGatewayAPI(); // 先触发 ToolStart 创建工具消息 @@ -1048,6 +1088,7 @@ describe("eventBridge", () => { expect(loadSession).toHaveBeenCalledWith("sess-1"); expect(useUIStore.getState().fileChanges).toHaveLength(0); + expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); }); it("baseline CheckpointRestored removes only restored file changes without reloading the session", async () => { @@ -1089,7 +1130,7 @@ describe("eventBridge", () => { payload: { checkpoint_id: "cp1", session_id: "sess-1", - guard_checkpoint_id: "", + guard_checkpoint_id: "guard-baseline-1", mode: "baseline", paths: ["./src/a.txt"], }, @@ -1104,6 +1145,7 @@ describe("eventBridge", () => { expect(loadSession).not.toHaveBeenCalled(); expect(useUIStore.getState().isRestoringCheckpoint).toBe(false); + expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); expect(useUIStore.getState().fileChanges.map((entry) => entry.path)).toEqual( ["src/b.txt"], ); @@ -1142,7 +1184,7 @@ describe("eventBridge", () => { payload: { checkpoint_id: "cp1", session_id: "sess-1", - guard_checkpoint_id: "", + guard_checkpoint_id: "guard-baseline-all", mode: "baseline", paths: ["src/a.txt", "src/b.txt"], }, @@ -1157,6 +1199,13 @@ describe("eventBridge", () => { expect(loadSession).not.toHaveBeenCalled(); expect(useUIStore.getState().fileChanges).toHaveLength(0); + expect(useUIStore.getState().checkpointRollbackUndo).toMatchObject({ + sessionId: "sess-1", + checkpointId: "cp1", + guardCheckpointId: "guard-baseline-all", + paths: ["src/a.txt", "src/b.txt"], + status: "idle", + }); }); it("baseline CheckpointRestored invalidates in-flight run-scoped file change refreshes", async () => { @@ -1399,6 +1448,62 @@ describe("eventBridge", () => { expect(loadSession).toHaveBeenCalledWith("sess-1"); expect(useUIStore.getState().fileChanges).toHaveLength(0); + expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); + expect(useUIStore.getState().toasts.at(-1)).toMatchObject({ + message: "Checkpoint restore undone", + type: "success", + }); + }); + + it("CheckpointUndoRestore clears rollback undo state without reloading session", async () => { + const loadSession = vi.fn(); + const api = createMockGatewayAPI({ loadSession }); + useSessionStore.setState({ currentSessionId: "sess-1" } as any); + useUIStore.setState({ + checkpointRollbackUndo: { + sessionId: "sess-1", + checkpointId: "cp-rollback", + guardCheckpointId: "guard-rollback", + paths: ["src/a.txt"], + status: "undoing", + }, + fileChanges: [ + { + id: "fc-1", + path: "src/a.txt", + status: "modified", + additions: 1, + deletions: 0, + }, + ], + } as any); + + handleGatewayEvent( + { + type: EventType.CheckpointUndoRestore, + payload: { + payload: { + runtime_event_type: EventType.CheckpointUndoRestore, + payload: { + session_id: "sess-1", + guard_checkpoint_id: "guard-rollback", + }, + }, + }, + session_id: "sess-1", + run_id: "run-1", + }, + api, + ); + await Promise.resolve(); + + expect(loadSession).not.toHaveBeenCalled(); + expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); + expect(useUIStore.getState().fileChanges).toHaveLength(0); + expect(useUIStore.getState().toasts.at(-1)).toMatchObject({ + message: "Rollback undo completed", + type: "success", + }); }); it("VerificationStarted creates a verification ChatMessage", () => { @@ -1918,6 +2023,107 @@ describe("eventBridge", () => { ]); }); + it("run-scoped checkpoint diff clears stale rollback undo when new changes are returned", async () => { + const checkpointDiff = vi.fn(async () => ({ + payload: { + checkpoint_id: "cp-new", + files: { modified: ["fresh.txt"] }, + patch: "--- a/fresh.txt\n+++ b/fresh.txt\n@@ -1 +1 @@\n-old\n+new\n", + }, + })); + const api = createMockGatewayAPI({ checkpointDiff }); + useSessionStore.setState({ currentSessionId: "sess-1" } as any); + useGatewayStore.setState({ currentRunId: "run-1" } as any); + useUIStore.setState({ + checkpointRollbackUndo: { + sessionId: "sess-1", + checkpointId: "cp-rollback", + guardCheckpointId: "guard-rollback", + paths: ["old.txt"], + status: "idle", + }, + } as any); + + handleGatewayEvent( + { + type: EventType.CheckpointCreated, + payload: { + payload: { + runtime_event_type: EventType.CheckpointCreated, + payload: { + checkpoint_id: "cp-new", + code_checkpoint_ref: "c", + session_checkpoint_ref: "s", + commit_hash: "", + reason: "end_of_turn", + }, + }, + }, + session_id: "sess-1", + run_id: "run-1", + }, + api, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); + expect(useUIStore.getState().fileChanges.map((entry) => entry.path)).toEqual( + ["fresh.txt"], + ); + }); + + it("run-scoped checkpoint diff keeps rollback undo when no changes are returned", async () => { + const checkpointDiff = vi.fn(async () => ({ + payload: { + checkpoint_id: "cp-empty", + files: {}, + patch: "", + }, + })); + const api = createMockGatewayAPI({ checkpointDiff }); + useSessionStore.setState({ currentSessionId: "sess-1" } as any); + useGatewayStore.setState({ currentRunId: "run-1" } as any); + useUIStore.setState({ + checkpointRollbackUndo: { + sessionId: "sess-1", + checkpointId: "cp-rollback", + guardCheckpointId: "guard-rollback", + paths: ["old.txt"], + status: "idle", + }, + } as any); + + handleGatewayEvent( + { + type: EventType.CheckpointCreated, + payload: { + payload: { + runtime_event_type: EventType.CheckpointCreated, + payload: { + checkpoint_id: "cp-empty", + code_checkpoint_ref: "c", + session_checkpoint_ref: "s", + commit_hash: "", + reason: "end_of_turn", + }, + }, + }, + session_id: "sess-1", + run_id: "run-1", + }, + api, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(useUIStore.getState().checkpointRollbackUndo).toMatchObject({ + checkpointId: "cp-rollback", + guardCheckpointId: "guard-rollback", + }); + expect(useUIStore.getState().fileChanges).toHaveLength(0); + }); + it("does not let run diff prev_checkpoint_id overwrite per-file rollback checkpoint", async () => { const checkpointDiff = vi.fn(async () => ({ payload: { @@ -3203,6 +3409,7 @@ describe("eventBridge", () => { .getState() .fileChanges.find((entry) => entry.path === "after-double-restore.txt"); expect(change?.checkpoint_id).toBe("cp-new"); + expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); const textMessages = useChatStore .getState() diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts index 49e08bc5..40deb195 100644 --- a/web/src/utils/eventBridge.ts +++ b/web/src/utils/eventBridge.ts @@ -162,6 +162,7 @@ function _upsertFileChange( ) { const path = normalizeFilePath(rawPath); if (!path) return; + useUIStore.getState().clearCheckpointRollbackUndo(); const checkpointID = resolveRollbackCheckpointID(path); const existing = useUIStore .getState() @@ -395,14 +396,14 @@ function _refreshRunFileChanges( change.rollback_checkpoint_id ?? change.checkpoint_id, ]), ); - useUIStore - .getState() - .replaceFileChanges( - _fileChangesFromCheckpointDiff( - result.payload, - existingCheckpointByPath, - ), - ); + const nextFileChanges = _fileChangesFromCheckpointDiff( + result.payload, + existingCheckpointByPath, + ); + if (nextFileChanges.length > 0) { + useUIStore.getState().clearCheckpointRollbackUndo(); + } + useUIStore.getState().replaceFileChanges(nextFileChanges); }) .catch((error) => { console.warn("[eventBridge] checkpoint.diff run scope failed:", error); @@ -410,23 +411,30 @@ function _refreshRunFileChanges( } // applyBaselineCheckpointRestoreEvent 只同步文件级 baseline 回退,不刷新会话消息或 insight。 -function applyBaselineCheckpointRestoreEvent(payload: CheckpointRestoredPayload) { +function applyBaselineCheckpointRestoreEvent( + payload: CheckpointRestoredPayload, +): boolean { const restoredPaths = new Set( (payload.paths ?? []).map(normalizeFilePath).filter(Boolean), ); _latestRunDiffRequestId += 1; useUIStore.getState().setRestoringCheckpoint(false); + useUIStore.getState().clearCheckpointRollbackUndo(); if (restoredPaths.size === 0) { - return; + return false; } for (const path of restoredPaths) { _firstTouchRollbackCheckpointByPath.delete(path); } - useUIStore.setState((state) => ({ - fileChanges: state.fileChanges.filter( + let removedAllChanges = false; + useUIStore.setState((state) => { + const remaining = state.fileChanges.filter( (change) => !restoredPaths.has(normalizeFilePath(change.path)), - ), - })); + ); + removedAllChanges = state.fileChanges.length > 0 && remaining.length === 0; + return { fileChanges: remaining }; + }); + return removedAllChanges; } // refreshSessionAfterCheckpointRestoreEvent 仅在当前会话收到 restore/undo 事件时刷新会话与文件变更视图。 @@ -1131,10 +1139,19 @@ export function handleGatewayEvent( payload.session_id === useSessionStore.getState().currentSessionId ) { if (payload.mode === "baseline") { - applyBaselineCheckpointRestoreEvent(payload); + const removedAllChanges = applyBaselineCheckpointRestoreEvent(payload); + if (removedAllChanges && payload.guard_checkpoint_id?.trim()) { + useUIStore.getState().setCheckpointRollbackUndo({ + sessionId: payload.session_id, + checkpointId: payload.checkpoint_id, + guardCheckpointId: payload.guard_checkpoint_id, + paths: payload.paths ?? [], + }); + } uiStore.showToast("File rollback completed", "success"); break; } + useUIStore.getState().clearCheckpointRollbackUndo(); chatStore.markAllCheckpointsRestored(); refreshSessionAfterCheckpointRestoreEvent( gatewayAPI, @@ -1154,6 +1171,14 @@ export function handleGatewayEvent( if ( payload.session_id === useSessionStore.getState().currentSessionId ) { + const rollbackUndo = useUIStore.getState().checkpointRollbackUndo; + useUIStore.getState().clearCheckpointRollbackUndo(); + useUIStore.getState().clearFileChanges(); + useUIStore.getState().setRestoringCheckpoint(false); + if (rollbackUndo?.sessionId === payload.session_id) { + uiStore.showToast("Rollback undo completed", "success"); + break; + } chatStore.markAllCheckpointsAvailable(); refreshSessionAfterCheckpointRestoreEvent( gatewayAPI, From c2da625ee33734d47a6270189449878a2693a629 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sat, 16 May 2026 16:56:31 +0800 Subject: [PATCH 2/5] fix(checkpoint): address rollback undo review --- internal/checkpoint/per_edit_snapshot_test.go | 134 ++++++++++++++++++ internal/runtime/checkpoint_flow_test.go | 97 ++++++++++++- internal/runtime/checkpoint_restore.go | 4 + web/package-lock.json | 98 ++++++------- .../panels/FileChangePanel.test.tsx | 77 +++++++++- web/src/components/panels/FileChangePanel.tsx | 42 ++++-- web/src/utils/eventBridge.test.ts | 70 ++++++++- web/src/utils/eventBridge.ts | 8 +- 8 files changed, 447 insertions(+), 83 deletions(-) diff --git a/internal/checkpoint/per_edit_snapshot_test.go b/internal/checkpoint/per_edit_snapshot_test.go index a3e0da18..f21b1846 100644 --- a/internal/checkpoint/per_edit_snapshot_test.go +++ b/internal/checkpoint/per_edit_snapshot_test.go @@ -1920,3 +1920,137 @@ func TestRestoreBaseline_ErrorsWhenPathMissingFromBaseline(t *testing.T) { t.Fatalf("RestoreBaseline missing path error = %v", err) } } + +func TestFinalizeExactForCheckpointPaths_CapturesSelectedCurrentPaths(t *testing.T) { + store, workdir := newTestStore(t) + targetA := writeWorkdirFile(t, workdir, "a.txt", "a before\n") + targetB := writeWorkdirFile(t, workdir, "b.txt", "b before\n") + if _, err := store.CapturePreWrite(targetA); err != nil { + t.Fatalf("CapturePreWrite(a): %v", err) + } + if _, err := store.CapturePreWrite(targetB); err != nil { + t.Fatalf("CapturePreWrite(b): %v", err) + } + if err := os.WriteFile(targetA, []byte("a source\n"), 0o644); err != nil { + t.Fatalf("write a source: %v", err) + } + if err := os.WriteFile(targetB, []byte("b source\n"), 0o644); err != nil { + t.Fatalf("write b source: %v", err) + } + if _, err := store.FinalizeWithExactState("cp-source"); err != nil { + t.Fatalf("FinalizeWithExactState: %v", err) + } + store.Reset() + + if err := os.WriteFile(targetA, []byte("a guard\n"), 0o644); err != nil { + t.Fatalf("write a guard: %v", err) + } + if err := os.WriteFile(targetB, []byte("b guard\n"), 0o644); err != nil { + t.Fatalf("write b guard: %v", err) + } + written, err := store.FinalizeExactForCheckpointPaths("cp-guard", "cp-source", []string{"a.txt", "./b.txt", "a.txt"}) + if err != nil { + t.Fatalf("FinalizeExactForCheckpointPaths: %v", err) + } + if !written { + t.Fatal("FinalizeExactForCheckpointPaths written = false, want true") + } + + if err := os.WriteFile(targetA, []byte("a drift\n"), 0o644); err != nil { + t.Fatalf("write a drift: %v", err) + } + if err := os.WriteFile(targetB, []byte("b drift\n"), 0o644); err != nil { + t.Fatalf("write b drift: %v", err) + } + if err := store.RestoreExact(context.Background(), "cp-guard"); err != nil { + t.Fatalf("RestoreExact(cp-guard): %v", err) + } + if got := mustReadFile(t, targetA); got != "a guard\n" { + t.Fatalf("targetA = %q, want guard", got) + } + if got := mustReadFile(t, targetB); got != "b guard\n" { + t.Fatalf("targetB = %q, want guard", got) + } +} + +func TestFinalizeExactForCheckpointPaths_CapturesDeletedAndCreatedCurrentState(t *testing.T) { + store, workdir := newTestStore(t) + deleted := writeWorkdirFile(t, workdir, "deleted.txt", "before delete\n") + created := filepath.Join(workdir, "created.txt") + if _, err := store.CapturePreWrite(deleted); err != nil { + t.Fatalf("CapturePreWrite(deleted): %v", err) + } + if _, err := store.CapturePreWrite(created); err != nil { + t.Fatalf("CapturePreWrite(created): %v", err) + } + if err := os.Remove(deleted); err != nil { + t.Fatalf("remove deleted current: %v", err) + } + if err := os.WriteFile(created, []byte("created current\n"), 0o644); err != nil { + t.Fatalf("write created current: %v", err) + } + if _, err := store.FinalizeWithExactState("cp-source"); err != nil { + t.Fatalf("FinalizeWithExactState: %v", err) + } + store.Reset() + + written, err := store.FinalizeExactForCheckpointPaths("cp-guard", "cp-source", []string{"deleted.txt", "created.txt"}) + if err != nil { + t.Fatalf("FinalizeExactForCheckpointPaths: %v", err) + } + if !written { + t.Fatal("FinalizeExactForCheckpointPaths written = false, want true") + } + + if err := os.WriteFile(deleted, []byte("deleted drift\n"), 0o644); err != nil { + t.Fatalf("write deleted drift: %v", err) + } + if err := os.Remove(created); err != nil { + t.Fatalf("remove created drift: %v", err) + } + if err := store.RestoreExact(context.Background(), "cp-guard"); err != nil { + t.Fatalf("RestoreExact(cp-guard): %v", err) + } + if _, err := os.Stat(deleted); !os.IsNotExist(err) { + t.Fatalf("deleted should be absent after exact restore, stat err = %v", err) + } + if got := mustReadFile(t, created); got != "created current\n" { + t.Fatalf("created = %q, want current", got) + } +} + +func TestFinalizeExactForCheckpointPaths_ValidatesInputsAndPaths(t *testing.T) { + store, workdir := newTestStore(t) + target := writeWorkdirFile(t, workdir, "tracked.txt", "before\n") + if _, err := store.CapturePreWrite(target); err != nil { + t.Fatalf("CapturePreWrite: %v", err) + } + if err := os.WriteFile(target, []byte("after\n"), 0o644); err != nil { + t.Fatalf("write after: %v", err) + } + if _, err := store.FinalizeWithExactState("cp-source"); err != nil { + t.Fatalf("FinalizeWithExactState: %v", err) + } + store.Reset() + + tests := []struct { + name string + checkpoint string + source string + paths []string + want string + }{ + {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: "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"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := store.FinalizeExactForCheckpointPaths(tt.checkpoint, tt.source, tt.paths) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("FinalizeExactForCheckpointPaths error = %v, want %q", err, tt.want) + } + }) + } +} diff --git a/internal/runtime/checkpoint_flow_test.go b/internal/runtime/checkpoint_flow_test.go index 14cea44a..47c5fbf4 100644 --- a/internal/runtime/checkpoint_flow_test.go +++ b/internal/runtime/checkpoint_flow_test.go @@ -481,18 +481,25 @@ func TestUndoRestoreCheckpoint_RestoresBaselineRollbackGuardPaths(t *testing.T) fixture := newRuntimeCheckpointFixture(t) targetA := filepath.Join(fixture.workdir, "baseline-a.txt") targetB := filepath.Join(fixture.workdir, "baseline-b.txt") + targetC := filepath.Join(fixture.workdir, "baseline-c.txt") if err := os.WriteFile(targetA, []byte("a before"), 0o644); err != nil { t.Fatalf("WriteFile(targetA before) error = %v", err) } if err := os.WriteFile(targetB, []byte("b before"), 0o644); err != nil { t.Fatalf("WriteFile(targetB before) error = %v", err) } + if err := os.WriteFile(targetC, []byte("c before"), 0o644); err != nil { + t.Fatalf("WriteFile(targetC before) error = %v", err) + } if _, err := fixture.perEditStore.CapturePreWrite(targetA); err != nil { t.Fatalf("CapturePreWrite(targetA) error = %v", err) } if _, err := fixture.perEditStore.CapturePreWrite(targetB); err != nil { t.Fatalf("CapturePreWrite(targetB) error = %v", err) } + if _, err := fixture.perEditStore.CapturePreWrite(targetC); err != nil { + t.Fatalf("CapturePreWrite(targetC) error = %v", err) + } state := newRunState("run-baseline-undo", fixture.session) if err := fixture.service.createStartOfTurnCheckpoint(context.Background(), &state); err != nil { @@ -515,20 +522,26 @@ func TestUndoRestoreCheckpoint_RestoresBaselineRollbackGuardPaths(t *testing.T) if err := os.WriteFile(targetB, []byte("b after"), 0o644); err != nil { t.Fatalf("WriteFile(targetB after) error = %v", err) } + if err := os.WriteFile(targetC, []byte("c after"), 0o644); err != nil { + t.Fatalf("WriteFile(targetC after) error = %v", err) + } if _, err := fixture.service.RestoreCheckpoint(context.Background(), GatewayRestoreInput{ SessionID: fixture.session.ID, CheckpointID: cpRecord.CheckpointID, Mode: "baseline", - Paths: []string{"baseline-a.txt"}, + Paths: []string{"baseline-a.txt", "baseline-b.txt"}, }); err != nil { t.Fatalf("RestoreCheckpoint(baseline) error = %v", err) } if got := string(mustReadRuntimeFile(t, targetA)); got != "a before" { t.Fatalf("baseline restored targetA = %q, want a before", got) } - if got := string(mustReadRuntimeFile(t, targetB)); got != "b after" { - t.Fatalf("baseline restored targetB = %q, want b after", got) + if got := string(mustReadRuntimeFile(t, targetB)); got != "b before" { + t.Fatalf("baseline restored targetB = %q, want b before", got) + } + if got := string(mustReadRuntimeFile(t, targetC)); got != "c after" { + t.Fatalf("baseline restored targetC = %q, want c after", got) } if _, err := fixture.service.UndoRestoreCheckpoint(context.Background(), fixture.session.ID); err != nil { @@ -540,6 +553,9 @@ func TestUndoRestoreCheckpoint_RestoresBaselineRollbackGuardPaths(t *testing.T) if got := string(mustReadRuntimeFile(t, targetB)); got != "b after" { t.Fatalf("undo targetB = %q, want b after", got) } + if got := string(mustReadRuntimeFile(t, targetC)); got != "c after" { + t.Fatalf("undo targetC = %q, want c after", got) + } } func TestRestoreCheckpointBaselineRejectsPathsThatNormalizeEmpty(t *testing.T) { @@ -606,6 +622,81 @@ func TestRestoreCheckpointBaselineWrapsRestoreBaselineError(t *testing.T) { } } +func TestRestoreCheckpointBaselineMarksGuardBrokenWhenRestoreFails(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) + } + + state := newRunState("run-baseline-failed-restore", fixture.session) + if err := fixture.service.createStartOfTurnCheckpoint(context.Background(), &state); err != nil { + t.Fatalf("createStartOfTurnCheckpoint() error = %v", err) + } + records, err := fixture.checkpointStore.ListCheckpoints(context.Background(), fixture.session.ID, checkpoint.ListCheckpointOpts{}) + if err != nil { + t.Fatalf("ListCheckpoints() error = %v", err) + } + if len(records) != 1 { + t.Fatalf("records = %#v, want 1", records) + } + cpRecord := records[0] + if err := fixture.checkpointStore.UpdateCheckpointStatus(context.Background(), cpRecord.CheckpointID, agentsession.CheckpointStatusAvailable); err != nil { + t.Fatalf("UpdateCheckpointStatus() error = %v", err) + } + if err := os.Remove(target); err != nil { + t.Fatalf("Remove(target) error = %v", err) + } + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatalf("Mkdir(target) error = %v", err) + } + + _, _, err = fixture.service.restoreCheckpointBaseline(context.Background(), fixture.session.ID, cpRecord.CheckpointID, []string{"baseline.txt"}) + if err == nil || !strings.Contains(err.Error(), "baseline restore code") { + t.Fatalf("restoreCheckpointBaseline() error = %v, want baseline restore code", err) + } + + records, err = fixture.checkpointStore.ListCheckpoints(context.Background(), fixture.session.ID, checkpoint.ListCheckpointOpts{}) + if err != nil { + t.Fatalf("ListCheckpoints(all) error = %v", err) + } + seenBrokenGuard := false + for _, record := range records { + if record.Reason != agentsession.CheckpointReasonGuard { + continue + } + if record.Status != agentsession.CheckpointStatusBroken { + t.Fatalf("guard status = %q, want broken", record.Status) + } + if record.CodeCheckpointRef != "" { + if perEditID := checkpoint.PerEditCheckpointIDFromRef(record.CodeCheckpointRef); perEditID != "" { + if err := fixture.perEditStore.RestoreExact(context.Background(), perEditID); err == nil { + t.Fatalf("failed restore guard %q still has restorable per-edit metadata", perEditID) + } + } + } + seenBrokenGuard = true + } + if !seenBrokenGuard { + t.Fatal("expected failed baseline restore to leave a broken guard record") + } + + available, err := fixture.checkpointStore.ListCheckpoints(context.Background(), fixture.session.ID, checkpoint.ListCheckpointOpts{ + RestorableOnly: true, + }) + if err != nil { + t.Fatalf("ListCheckpoints(restorable) error = %v", err) + } + for _, record := range available { + if record.Reason == agentsession.CheckpointReasonGuard { + t.Fatalf("broken guard %q should not be returned as restorable", record.CheckpointID) + } + } +} + func TestUndoRestoreCheckpoint_RestoresGuardState(t *testing.T) { fixture := newRuntimeCheckpointFixture(t) target := filepath.Join(fixture.workdir, "undo.txt") diff --git a/internal/runtime/checkpoint_restore.go b/internal/runtime/checkpoint_restore.go index f8ece70e..bd7daf6a 100644 --- a/internal/runtime/checkpoint_restore.go +++ b/internal/runtime/checkpoint_restore.go @@ -372,6 +372,10 @@ func (s *Service) restoreCheckpointBaseline( return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: create baseline guard: %w", guardErr) } if err := s.perEditStore.RestoreBaseline(ctx, perEditID, relPaths); err != nil { + if guardWritten { + _ = s.perEditStore.DeleteCheckpoint(guardID) + } + _ = s.checkpointStore.UpdateCheckpointStatus(ctx, guardRecord.CheckpointID, agentsession.CheckpointStatusBroken) return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: baseline restore code: %w", err) } return RestoreResult{CheckpointID: checkpointID, SessionID: sessionID}, guardRecord, nil diff --git a/web/package-lock.json b/web/package-lock.json index 3ccdb3a6..4365c0fb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -144,6 +144,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -559,6 +560,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -607,6 +609,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2423,8 +2426,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2826,6 +2828,7 @@ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "*" } @@ -2871,6 +2874,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2882,6 +2886,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2988,6 +2993,7 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -3193,6 +3199,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3359,7 +3366,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -3379,7 +3385,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -3402,7 +3407,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -3418,8 +3422,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -3427,7 +3430,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -3683,6 +3685,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4053,6 +4056,7 @@ "resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -4288,7 +4292,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -4429,7 +4432,6 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -4443,7 +4445,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -4500,6 +4501,7 @@ "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -4900,6 +4902,7 @@ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5265,6 +5268,7 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5347,8 +5351,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.4.2", @@ -5433,6 +5436,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", @@ -5477,7 +5481,6 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5491,7 +5494,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5507,7 +5509,6 @@ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5521,7 +5522,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -6090,8 +6090,7 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "8.1.0", @@ -6422,6 +6421,7 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -7088,8 +7088,7 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -7397,7 +7396,6 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -7411,7 +7409,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -7427,8 +7424,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -7436,7 +7432,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -7459,16 +7454,14 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -7481,8 +7474,7 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -7496,16 +7488,14 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -7581,7 +7571,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8108,6 +8097,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -8725,7 +8715,8 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/mime": { "version": "2.6.0", @@ -8947,7 +8938,8 @@ "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ms": { "version": "2.1.3", @@ -9109,7 +9101,6 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9501,7 +9492,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9517,7 +9507,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9530,8 +9519,7 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", @@ -9613,6 +9601,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9625,6 +9614,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9638,8 +9628,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.17.0", @@ -9717,7 +9706,6 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -9727,8 +9715,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/readdir-glob/node_modules/brace-expansion": { "version": "2.1.0", @@ -9736,7 +9723,6 @@ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -9747,7 +9733,6 @@ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -10698,7 +10683,6 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -10839,6 +10823,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11011,6 +10996,7 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -11261,6 +11247,7 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -11350,7 +11337,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -11376,6 +11364,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11389,6 +11378,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -11815,7 +11805,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -11831,7 +11820,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", diff --git a/web/src/components/panels/FileChangePanel.test.tsx b/web/src/components/panels/FileChangePanel.test.tsx index 8f306c8b..692f0aee 100644 --- a/web/src/components/panels/FileChangePanel.test.tsx +++ b/web/src/components/panels/FileChangePanel.test.tsx @@ -259,6 +259,30 @@ describe("FileChangePanel", () => { }); it("rolls back all current file changes with one baseline restore request", async () => { + useUIStore.setState({ + fileChanges: [ + { + id: "fc-a", + path: "src/a.txt", + status: "modified", + additions: 2, + deletions: 2, + checkpoint_id: "cp-1", + rollback_checkpoint_id: "cp-rollback-1", + hunks: [], + }, + { + id: "fc-b", + path: "src/b.txt", + status: "modified", + additions: 1, + deletions: 0, + checkpoint_id: "cp-1", + rollback_checkpoint_id: "cp-rollback-1", + hunks: [], + }, + ], + } as never); render(); fireEvent.click(screen.getByTestId("restore-all-changes")); @@ -272,9 +296,48 @@ describe("FileChangePanel", () => { session_id: "sess-1", checkpoint_id: "cp-rollback-1", mode: "baseline", - paths: ["src/a.txt"], + paths: ["src/a.txt", "src/b.txt"], }); }); + expect(mockGatewayAPI.restoreCheckpoint).toHaveBeenCalledTimes(1); + }); + + it("does not rollback all when current file changes span multiple rollback baselines", () => { + useUIStore.setState({ + fileChanges: [ + { + id: "fc-a", + path: "src/a.txt", + status: "modified", + additions: 1, + deletions: 0, + checkpoint_id: "cp-1", + rollback_checkpoint_id: "cp-rollback-1", + hunks: [], + }, + { + id: "fc-b", + path: "src/b.txt", + status: "modified", + additions: 1, + deletions: 0, + checkpoint_id: "cp-2", + rollback_checkpoint_id: "cp-rollback-2", + hunks: [], + }, + ], + } as never); + + render(); + + const rollbackAll = screen.getByTestId("restore-all-changes"); + expect(rollbackAll).toBeDisabled(); + expect(rollbackAll).toHaveAttribute( + "title", + "Cannot rollback all files from multiple rollback baselines at once", + ); + fireEvent.click(rollbackAll); + expect(mockGatewayAPI.restoreCheckpoint).not.toHaveBeenCalled(); }); it("shows file rollback undo entry and hides stale file change list", () => { @@ -293,7 +356,9 @@ describe("FileChangePanel", () => { expect(screen.getByTestId("checkpoint-undo-restore")).toBeInTheDocument(); expect(screen.getByTestId("undo-last-rollback")).toBeEnabled(); expect(screen.queryByText("src/a.txt")).not.toBeInTheDocument(); - expect(screen.queryByTestId("changes-content-stack")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("changes-content-stack"), + ).not.toBeInTheDocument(); }); it("shows file change list after rollback undo state is cleared", () => { @@ -315,7 +380,9 @@ describe("FileChangePanel", () => { render(); - expect(screen.queryByTestId("checkpoint-undo-restore")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("checkpoint-undo-restore"), + ).not.toBeInTheDocument(); expect(screen.getByTestId("changes-content-stack")).toBeInTheDocument(); expect(screen.getByText("src/new.txt")).toBeInTheDocument(); }); @@ -339,7 +406,9 @@ describe("FileChangePanel", () => { await waitFor(() => { expect(mockGatewayAPI.undoRestore).toHaveBeenCalledWith("sess-1"); }); - expect(useUIStore.getState().checkpointRollbackUndo?.status).toBe("undoing"); + expect(useUIStore.getState().checkpointRollbackUndo?.status).toBe( + "undoing", + ); }); it("keeps file rollback undo entry and reports failure when undoRestore fails", async () => { diff --git a/web/src/components/panels/FileChangePanel.tsx b/web/src/components/panels/FileChangePanel.tsx index ba038d5f..bfb61b51 100644 --- a/web/src/components/panels/FileChangePanel.tsx +++ b/web/src/components/panels/FileChangePanel.tsx @@ -484,15 +484,26 @@ function ChangesView() { return groups; }, [fileChanges]); const canRollbackAll = rollbackGroups.size > 0; + const hasMultipleRollbackGroups = rollbackGroups.size > 1; + const rollbackAllCheckpointID = + rollbackGroups.size === 1 ? rollbackGroups.keys().next().value : undefined; + const rollbackAllPaths = rollbackAllCheckpointID + ? (rollbackGroups.get(rollbackAllCheckpointID) ?? []) + : []; const rollbackAllDisabled = - isGenerating || isRestoringCheckpoint || !canRollbackAll; + isGenerating || + isRestoringCheckpoint || + !canRollbackAll || + hasMultipleRollbackGroups; const rollbackAllTitle = isGenerating ? "Running; action is disabled" : isRestoringCheckpoint ? "Checkpoint restore in progress" - : canRollbackAll - ? "Rollback all files in this run" - : "No rollback checkpoint available for current file changes"; + : hasMultipleRollbackGroups + ? "Cannot rollback all files from multiple rollback baselines at once" + : canRollbackAll + ? "Rollback all files in this run" + : "No rollback checkpoint available for current file changes"; const activeUndo = checkpointRollbackUndo?.sessionId === sessionId ? checkpointRollbackUndo @@ -515,18 +526,23 @@ function ChangesView() { async function handleRollbackAll() { setConfirmingRollbackAll(false); - if (!gatewayAPI || !sessionId || rollbackAllDisabled) return; + if ( + !gatewayAPI || + !sessionId || + rollbackAllDisabled || + !rollbackAllCheckpointID || + rollbackAllPaths.length === 0 + ) + return; setRestoringCheckpoint(true); try { - for (const [checkpointID, paths] of rollbackGroups.entries()) { - await gatewayAPI.restoreCheckpoint({ - session_id: sessionId, - checkpoint_id: checkpointID, - mode: "baseline", - paths, - }); - } + await gatewayAPI.restoreCheckpoint({ + session_id: sessionId, + checkpoint_id: rollbackAllCheckpointID, + mode: "baseline", + paths: rollbackAllPaths, + }); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; showToast(`Restore failed: ${message}`, "error"); diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts index 9bf2b53e..64066373 100644 --- a/web/src/utils/eventBridge.test.ts +++ b/web/src/utils/eventBridge.test.ts @@ -1146,9 +1146,9 @@ describe("eventBridge", () => { expect(loadSession).not.toHaveBeenCalled(); expect(useUIStore.getState().isRestoringCheckpoint).toBe(false); expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); - expect(useUIStore.getState().fileChanges.map((entry) => entry.path)).toEqual( - ["src/b.txt"], - ); + expect( + useUIStore.getState().fileChanges.map((entry) => entry.path), + ).toEqual(["src/b.txt"]); }); it("baseline CheckpointRestored removes all paths from rollback all events", async () => { @@ -1506,6 +1506,64 @@ describe("eventBridge", () => { }); }); + it("CheckpointUndoRestore reloads session when rollback undo guard id is stale", async () => { + const loadSession = vi.fn(async () => ({ + payload: { + id: "sess-1", + agent_mode: "build", + messages: [{ role: "assistant", content: "after normal undo" }], + }, + })); + const api = createMockGatewayAPI({ loadSession }); + useSessionStore.setState({ currentSessionId: "sess-1" } as any); + useUIStore.setState({ + checkpointRollbackUndo: { + sessionId: "sess-1", + checkpointId: "cp-rollback", + guardCheckpointId: "guard-stale", + paths: ["src/a.txt"], + status: "idle", + }, + fileChanges: [ + { + id: "fc-1", + path: "src/a.txt", + status: "modified", + additions: 1, + deletions: 0, + }, + ], + } as any); + + handleGatewayEvent( + { + type: EventType.CheckpointUndoRestore, + payload: { + payload: { + runtime_event_type: EventType.CheckpointUndoRestore, + payload: { + session_id: "sess-1", + guard_checkpoint_id: "guard-normal", + }, + }, + }, + session_id: "sess-1", + run_id: "run-1", + }, + api, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(loadSession).toHaveBeenCalledWith("sess-1"); + expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); + expect(useUIStore.getState().fileChanges).toHaveLength(0); + expect(useUIStore.getState().toasts.at(-1)).toMatchObject({ + message: "Checkpoint restore undone", + type: "success", + }); + }); + it("VerificationStarted creates a verification ChatMessage", () => { const api = createMockGatewayAPI(); handleGatewayEvent( @@ -2068,9 +2126,9 @@ describe("eventBridge", () => { await Promise.resolve(); expect(useUIStore.getState().checkpointRollbackUndo).toBeNull(); - expect(useUIStore.getState().fileChanges.map((entry) => entry.path)).toEqual( - ["fresh.txt"], - ); + expect( + useUIStore.getState().fileChanges.map((entry) => entry.path), + ).toEqual(["fresh.txt"]); }); it("run-scoped checkpoint diff keeps rollback undo when no changes are returned", async () => { diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts index 40deb195..b90013b6 100644 --- a/web/src/utils/eventBridge.ts +++ b/web/src/utils/eventBridge.ts @@ -1139,7 +1139,8 @@ export function handleGatewayEvent( payload.session_id === useSessionStore.getState().currentSessionId ) { if (payload.mode === "baseline") { - const removedAllChanges = applyBaselineCheckpointRestoreEvent(payload); + const removedAllChanges = + applyBaselineCheckpointRestoreEvent(payload); if (removedAllChanges && payload.guard_checkpoint_id?.trim()) { useUIStore.getState().setCheckpointRollbackUndo({ sessionId: payload.session_id, @@ -1175,7 +1176,10 @@ export function handleGatewayEvent( useUIStore.getState().clearCheckpointRollbackUndo(); useUIStore.getState().clearFileChanges(); useUIStore.getState().setRestoringCheckpoint(false); - if (rollbackUndo?.sessionId === payload.session_id) { + if ( + rollbackUndo?.sessionId === payload.session_id && + rollbackUndo.guardCheckpointId === payload.guard_checkpoint_id + ) { uiStore.showToast("Rollback undo completed", "success"); break; } From a59f7404e41182dceedb02264709011af6499449 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sat, 16 May 2026 11:18:08 +0000 Subject: [PATCH 3/5] test(checkpoint): cover baseline guard edge cases Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/runtime/checkpoint_flow_test.go | 119 +++++++++++++++++++++++ web/src/test/setup.ts | 34 +++++++ 2 files changed, 153 insertions(+) diff --git a/internal/runtime/checkpoint_flow_test.go b/internal/runtime/checkpoint_flow_test.go index 47c5fbf4..034d02c7 100644 --- a/internal/runtime/checkpoint_flow_test.go +++ b/internal/runtime/checkpoint_flow_test.go @@ -178,6 +178,27 @@ func readCheckpointRestoredPayload(t *testing.T, events <-chan RuntimeEvent) Che } } +func countPerEditCheckpointMetaFiles(t *testing.T, root string) int { + t.Helper() + + count := 0 + if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if strings.HasPrefix(filepath.Base(path), "cp_") && strings.HasSuffix(path, ".json") { + count++ + } + return nil + }); err != nil { + t.Fatalf("WalkDir(%s) error = %v", root, err) + } + return count +} + func TestCreateStartOfTurnCheckpoint_PendingWrite(t *testing.T) { fixture := newRuntimeCheckpointFixture(t) fixture.captureFile(t, "main.go", []byte("package main\nconst v = 1\n")) @@ -622,6 +643,104 @@ func TestRestoreCheckpointBaselineWrapsRestoreBaselineError(t *testing.T) { } } +func TestRestoreCheckpointBaselineRejectsSessionMismatch(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) + } + + state := newRunState("run-baseline-session-mismatch", fixture.session) + if err := fixture.service.createStartOfTurnCheckpoint(context.Background(), &state); err != nil { + t.Fatalf("createStartOfTurnCheckpoint() error = %v", err) + } + records, err := fixture.checkpointStore.ListCheckpoints(context.Background(), fixture.session.ID, checkpoint.ListCheckpointOpts{}) + if err != nil { + t.Fatalf("ListCheckpoints() error = %v", err) + } + cpRecord := records[0] + if err := fixture.checkpointStore.UpdateCheckpointStatus(context.Background(), cpRecord.CheckpointID, agentsession.CheckpointStatusAvailable); err != nil { + t.Fatalf("UpdateCheckpointStatus() error = %v", err) + } + + _, _, err = fixture.service.restoreCheckpointBaseline(context.Background(), "other-session", cpRecord.CheckpointID, []string{"baseline.txt"}) + if err == nil || !strings.Contains(err.Error(), "session mismatch") { + t.Fatalf("restoreCheckpointBaseline() error = %v, want session mismatch", err) + } +} + +func TestRestoreCheckpointBaselineRejectsCheckpointWithoutCodeSnapshot(t *testing.T) { + fixture := newRuntimeCheckpointFixture(t) + record, err := fixture.checkpointStore.CreateCheckpoint(context.Background(), checkpoint.CreateCheckpointInput{ + Record: agentsession.CheckpointRecord{ + CheckpointID: "cp-no-code", + WorkspaceKey: agentsession.WorkspacePathKey(fixture.session.Workdir), + SessionID: fixture.session.ID, + RunID: "run-no-code", + Workdir: fixture.session.Workdir, + CreatedAt: time.Now(), + Reason: agentsession.CheckpointReasonManual, + Restorable: true, + }, + 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) + } + + _, _, err = fixture.service.restoreCheckpointBaseline(context.Background(), fixture.session.ID, record.CheckpointID, []string{"baseline.txt"}) + if err == nil || !strings.Contains(err.Error(), "has no code snapshot") { + t.Fatalf("restoreCheckpointBaseline() error = %v, want missing code snapshot", err) + } +} + +func TestRestoreCheckpointBaselineDeletesGuardSnapshotWhenGuardCheckpointCreateFails(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) + } + + state := newRunState("run-baseline-guard-create-fails", fixture.session) + if err := fixture.service.createStartOfTurnCheckpoint(context.Background(), &state); err != nil { + t.Fatalf("createStartOfTurnCheckpoint() error = %v", err) + } + records, err := fixture.checkpointStore.ListCheckpoints(context.Background(), fixture.session.ID, checkpoint.ListCheckpointOpts{}) + if err != nil { + t.Fatalf("ListCheckpoints() error = %v", err) + } + cpRecord := records[0] + if err := fixture.checkpointStore.UpdateCheckpointStatus(context.Background(), cpRecord.CheckpointID, agentsession.CheckpointStatusAvailable); err != nil { + t.Fatalf("UpdateCheckpointStatus() error = %v", err) + } + beforeCount := countPerEditCheckpointMetaFiles(t, fixture.projectDir) + + if err := fixture.sessionStore.Close(); err != nil { + t.Fatalf("Close(sessionStore) error = %v", err) + } + + _, _, err = fixture.service.restoreCheckpointBaseline(context.Background(), fixture.session.ID, cpRecord.CheckpointID, []string{"baseline.txt"}) + if err == nil || !strings.Contains(err.Error(), "create baseline guard") { + t.Fatalf("restoreCheckpointBaseline() error = %v, want create baseline guard", err) + } + afterCount := countPerEditCheckpointMetaFiles(t, fixture.projectDir) + if afterCount != beforeCount { + t.Fatalf("per-edit checkpoint meta count = %d, want %d after failed guard create", afterCount, beforeCount) + } +} + func TestRestoreCheckpointBaselineMarksGuardBrokenWhenRestoreFails(t *testing.T) { fixture := newRuntimeCheckpointFixture(t) target := filepath.Join(fixture.workdir, "baseline.txt") diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 17cdac27..68b096ef 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -2,6 +2,40 @@ import '@testing-library/jest-dom' import { cleanup } from '@testing-library/react' import { afterEach } from 'vitest' +function createMemoryStorage(): Storage { + const store = new Map() + return { + get length() { + return store.size + }, + clear() { + store.clear() + }, + getItem(key: string) { + return store.has(key) ? store.get(key)! : null + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null + }, + removeItem(key: string) { + store.delete(key) + }, + setItem(key: string, value: string) { + store.set(key, String(value)) + }, + } +} + +if ( + typeof globalThis.localStorage === 'undefined' || + typeof globalThis.localStorage?.getItem !== 'function' +) { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createMemoryStorage(), + }) +} + afterEach(() => { cleanup() }) From e6c3d02366e1219216a13c134843eeb84c7ebb35 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 17 May 2026 08:48:49 +0800 Subject: [PATCH 4/5] test(web): update checkpoint event coverage --- web/src/api/gateway.ts | 6 - web/src/api/protocol.ts | 17 -- .../chat/CheckpointInlineMark.test.tsx | 39 --- .../components/chat/CheckpointInlineMark.tsx | 281 ------------------ web/src/components/chat/MessageItem.tsx | 91 +----- web/src/components/chat/ToolCallCard.test.tsx | 8 +- web/src/components/chat/ToolCallCard.tsx | 6 - .../panels/FileChangePanel.test.tsx | 4 - web/src/stores/useChatStore.ts | 48 --- web/src/stores/useRuntimeInsightStore.ts | 5 - web/src/stores/useSessionStore.test.ts | 11 +- web/src/stores/useSessionStore.ts | 14 +- web/src/utils/eventBridge.test.ts | 13 +- web/src/utils/eventBridge.ts | 18 +- .../utils/findCheckpointBeforeMessage.test.ts | 78 ----- web/src/utils/findCheckpointBeforeMessage.ts | 24 -- 16 files changed, 23 insertions(+), 640 deletions(-) delete mode 100644 web/src/components/chat/CheckpointInlineMark.test.tsx delete mode 100644 web/src/components/chat/CheckpointInlineMark.tsx delete mode 100644 web/src/utils/findCheckpointBeforeMessage.test.ts delete mode 100644 web/src/utils/findCheckpointBeforeMessage.ts diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts index 59de4be9..acbff880 100644 --- a/web/src/api/gateway.ts +++ b/web/src/api/gateway.ts @@ -11,8 +11,6 @@ import { type ListSessionTodosResult, type GetRuntimeSnapshotParams, type GetRuntimeSnapshotResult, - type ListCheckpointsParams, - type ListCheckpointsResult, type RestoreCheckpointParams, type RestoreCheckpointResult, type UndoRestoreParams, @@ -129,10 +127,6 @@ export class GatewayAPI { ) } - async listCheckpoints(params: ListCheckpointsParams) { - return this.ws.call(Method.ListCheckpoints, params) - } - async restoreCheckpoint(params: RestoreCheckpointParams) { return this.ws.call(Method.RestoreCheckpoint, params) } diff --git a/web/src/api/protocol.ts b/web/src/api/protocol.ts index c34b08b8..d2cde4cd 100644 --- a/web/src/api/protocol.ts +++ b/web/src/api/protocol.ts @@ -17,7 +17,6 @@ export const Method = { LoadSession: "gateway.loadSession", ListSessionTodos: "session.todos.list", GetRuntimeSnapshot: "runtime.snapshot.get", - ListCheckpoints: "checkpoint.list", RestoreCheckpoint: "checkpoint.restore", UndoRestore: "checkpoint.undoRestore", CheckpointDiff: "checkpoint.diff", @@ -243,12 +242,6 @@ export interface GetRuntimeSnapshotParams { session_id: string; } -export interface ListCheckpointsParams { - session_id: string; - limit?: number; - restorable_only?: boolean; -} - export interface RestoreCheckpointParams { session_id: string; checkpoint_id: string; @@ -465,15 +458,6 @@ export interface AcceptanceDecidedPayload { continue_hint?: string; } -export interface CheckpointEntry { - checkpoint_id: string; - session_id: string; - reason: string; - status: string; - restorable: boolean; - created_at_ms: number; -} - export interface FileDiffs { added?: string[]; deleted?: string[]; @@ -530,7 +514,6 @@ export interface CheckpointUndoRestorePayload { session_id: string; } -export type ListCheckpointsResult = RPCResult; export type RestoreCheckpointResult = RPCResult; export type UndoRestoreResult = RPCResult; export type CheckpointDiffResult = RPCResult; diff --git a/web/src/components/chat/CheckpointInlineMark.test.tsx b/web/src/components/chat/CheckpointInlineMark.test.tsx deleted file mode 100644 index de4a5d26..00000000 --- a/web/src/components/chat/CheckpointInlineMark.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import CheckpointInlineMark from './CheckpointInlineMark' -import { useSessionStore } from '@/stores/useSessionStore' -import { useRuntimeInsightStore } from '@/stores/useRuntimeInsightStore' - -let gatewayAPI: any = null -vi.mock('@/context/RuntimeProvider', () => ({ - useGatewayAPI: () => gatewayAPI, -})) - -describe('CheckpointInlineMark', () => { - beforeEach(() => { - gatewayAPI = { - restoreCheckpoint: vi.fn().mockResolvedValue({ payload: {} }), - undoRestore: vi.fn().mockResolvedValue({ payload: {} }), - checkpointDiff: vi.fn().mockResolvedValue({ payload: { files: { added: [], modified: [], deleted: [] }, patch: '' } }), - } - useSessionStore.setState({ currentSessionId: 's1' } as any) - useRuntimeInsightStore.getState().reset() - vi.spyOn(window, 'confirm').mockReturnValue(true) - }) - - it('restores checkpoint from available state', async () => { - render() - fireEvent.click(screen.getByRole('button', { name: /cp_abcdef/i })) - await waitFor(() => expect(gatewayAPI.restoreCheckpoint).toHaveBeenCalledWith({ - session_id: 's1', - checkpoint_id: 'abcdef123456', - })) - }) - - it('renders restored state and can undo restore', async () => { - render() - fireEvent.click(screen.getByRole('button', { name: /已撤回/ })) - await waitFor(() => expect(gatewayAPI.undoRestore).toHaveBeenCalledWith('s1')) - }) -}) - diff --git a/web/src/components/chat/CheckpointInlineMark.tsx b/web/src/components/chat/CheckpointInlineMark.tsx deleted file mode 100644 index ce1f4932..00000000 --- a/web/src/components/chat/CheckpointInlineMark.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { useSessionStore } from '@/stores/useSessionStore' -import { useGatewayAPI } from '@/context/RuntimeProvider' -import { useRuntimeInsightStore } from '@/stores/useRuntimeInsightStore' -import { parseUnifiedPatch } from '@/utils/patchParser' -import { Undo2, Redo2, Loader2, Eye } from 'lucide-react' - -interface CheckpointInlineMarkProps { - checkpointId: string - status?: 'available' | 'restoring' | 'restored' -} - -export default function CheckpointInlineMark({ checkpointId, status = 'available' }: CheckpointInlineMarkProps) { - const gatewayAPI = useGatewayAPI() - const sessionId = useSessionStore((s) => s.currentSessionId) - const [localStatus, setLocalStatus] = useState(status) - const [showDiff, setShowDiff] = useState(false) - - useEffect(() => { setLocalStatus(status) }, [status]) - - if (localStatus === 'restored') { - return ( - - - - ) - } - - async function handleRestore() { - if (!gatewayAPI || !sessionId || localStatus !== 'available') return - const ok = window.confirm( - `Restore to checkpoint ${checkpointId.slice(0, 8)}?\nThis will roll back all later file changes.`, - ) - if (!ok) return - setLocalStatus('restoring') - try { - await gatewayAPI.restoreCheckpoint({ session_id: sessionId, checkpoint_id: checkpointId }) - setLocalStatus('restored') - } catch { - setLocalStatus('available') - } - } - - async function handleUndoRestore() { - if (!gatewayAPI || !sessionId) return - const ok = window.confirm('Undo the last restore? This will bring back the reverted state.') - if (!ok) return - try { - await gatewayAPI.undoRestore(sessionId) - } catch { /* event will sync state */ } - } - - async function handleToggleDiff() { - if (showDiff) { setShowDiff(false); return } - if (!gatewayAPI || !sessionId) return - const insightStore = useRuntimeInsightStore.getState() - insightStore.setCheckpointDiff(null) - setShowDiff(true) - try { - const result = await gatewayAPI.checkpointDiff({ - session_id: sessionId, - checkpoint_id: checkpointId, - }) - if (result?.payload) insightStore.setCheckpointDiff(result.payload) - } catch { - setShowDiff(false) - } - } - - const isRestoring = localStatus === 'restoring' - - return ( - - - - {showDiff && setShowDiff(false)} />} - - ) -} - -function CheckpointDiffPopover({ onClose }: { onClose: () => void }) { - const diff = useRuntimeInsightStore((s) => s.checkpointDiff) - const ref = useRef(null) - - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) onClose() - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [onClose]) - - const fileDiffs = diff?.patch ? parseUnifiedPatch(diff.patch) : {} - - return ( -
-
- - {diff ? `Changes (${(diff.files?.added?.length ?? 0) + (diff.files?.modified?.length ?? 0) + (diff.files?.deleted?.length ?? 0)} files)` : 'Loading...'} - - -
- {!diff ? ( -
- ) : Object.keys(fileDiffs).length === 0 ? ( -
No file changes
- ) : ( -
- {Object.entries(fileDiffs).map(([path, fd]) => ( -
-
{path} - - +{fd.additions} - -{fd.deletions} - -
-
- {fd.lines.map((line, i) => { - const ls = line.type === 'add' - ? { color: '#86efac', background: 'rgba(22,163,74,0.08)' } - : line.type === 'del' - ? { color: '#fca5a5', background: 'rgba(220,38,38,0.08)' } - : { color: 'var(--accent-hover)' } - const prefix = line.type === 'add' ? '+' : line.type === 'del' ? '-' : '' - return ( -
- {prefix} - {line.content} -
- ) - })} -
-
- ))} -
- )} -
- ) -} - -const styles: Record = { - wrapper: { - display: 'inline-flex', - alignItems: 'center', - gap: 2, - position: 'relative', - }, - badge: { - display: 'inline-flex', - alignItems: 'center', - gap: 3, - padding: '1px 5px', - borderRadius: 'var(--radius-sm)', - background: 'var(--bg-tertiary)', - color: 'var(--text-secondary)', - fontSize: 10, - fontFamily: 'var(--font-mono)', - border: 'none', - cursor: 'pointer', - transition: 'all 0.15s', - }, - restoredBadge: { - color: 'var(--text-tertiary)', - cursor: 'pointer', - }, - diffBtn: { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: 16, - height: 16, - padding: 0, - borderRadius: 'var(--radius-sm)', - background: 'transparent', - color: 'var(--text-tertiary)', - border: 'none', - cursor: 'pointer', - transition: 'color 0.15s', - }, - popover: { - position: 'absolute', - top: '100%', - right: 0, - marginTop: 4, - width: 380, - maxHeight: 400, - background: 'var(--bg-secondary)', - border: '1px solid var(--border-primary)', - borderRadius: 'var(--radius-md)', - boxShadow: '0 8px 24px rgba(0,0,0,0.35)', - zIndex: 100, - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - }, - popoverHeader: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '6px 10px', - borderBottom: '1px solid var(--border-primary)', - fontSize: 11, - color: 'var(--text-secondary)', - }, - popoverTitle: { fontWeight: 600 }, - popoverClose: { - background: 'none', - border: 'none', - color: 'var(--text-tertiary)', - cursor: 'pointer', - fontSize: 12, - padding: '0 2px', - }, - popoverLoading: { - padding: 20, - display: 'flex', - justifyContent: 'center', - color: 'var(--text-tertiary)', - }, - popoverEmpty: { - padding: 16, - textAlign: 'center', - color: 'var(--text-tertiary)', - fontSize: 11, - }, - popoverBody: { - overflowY: 'auto', - flex: 1, - }, - diffFileName: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '4px 10px', - fontFamily: 'var(--font-mono)', - fontSize: 10, - color: 'var(--text-primary)', - background: 'var(--bg-tertiary)', - }, - diffStats: { - display: 'inline-flex', - gap: 6, - fontFamily: 'var(--font-mono)', - fontSize: 10, - }, - diffBlock: { - background: 'var(--code-bg)', - padding: '4px 0', - }, - diffLine: { - display: 'flex', - minWidth: 'max-content', - padding: '1px 10px', - fontFamily: 'var(--font-mono)', - fontSize: 10, - lineHeight: 1.5, - whiteSpace: 'pre', - }, - diffPrefix: { - width: 14, - flexShrink: 0, - color: 'var(--text-tertiary)', - }, -} diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx index c535331b..55cf41c8 100644 --- a/web/src/components/chat/MessageItem.tsx +++ b/web/src/components/chat/MessageItem.tsx @@ -1,18 +1,11 @@ import { memo, useState } from 'react' -import { useChatStore, type ChatMessage } from '@/stores/useChatStore' -import { useComposerStore } from '@/stores/useComposerStore' -import { useSessionStore, loadSessionWithInsights, mapHistoryMessages, type BackendMessage } from '@/stores/useSessionStore' -import { useUIStore } from '@/stores/useUIStore' -import { useGatewayAPI } from '@/context/RuntimeProvider' -import { findCheckpointBeforeMessage } from '@/utils/findCheckpointBeforeMessage' -import { resetEventBridgeCursors } from '@/utils/eventBridge' +import { type ChatMessage } from '@/stores/useChatStore' import ToolCallCard from './ToolCallCard' import VerificationMessage from './VerificationMessage' import AcceptanceMessage from './AcceptanceMessage' import CodeBlock from './CodeBlock' import MarkdownContent from './MarkdownContent' -import ConfirmDialog from '@/components/ui/ConfirmDialog' -import { Bot, ChevronRight, Info, RotateCcw, Loader2 } from 'lucide-react' +import { Bot, ChevronRight, Info, Loader2 } from 'lucide-react' interface MessageItemProps { message: ChatMessage @@ -63,76 +56,11 @@ const MessageItem = memo(function MessageItem({ message, isLast = false, grouped }) function UserMessage({ message }: { message: ChatMessage }) { - const gatewayAPI = useGatewayAPI() - const checkpointId = useChatStore( - (s) => findCheckpointBeforeMessage(s.messages, message.id)?.checkpointId ?? null, - ) - const setComposerText = useComposerStore((s) => s.setComposerText) - const [confirming, setConfirming] = useState(false) - const [reverting, setReverting] = useState(false) - - async function handleConfirm() { - setConfirming(false) - if (!checkpointId || !gatewayAPI) return - const sessionId = useSessionStore.getState().currentSessionId - if (!sessionId) { - useUIStore.getState().showToast('No session bound; cannot revert', 'error') - return - } - setReverting(true) - try { - await gatewayAPI.restoreCheckpoint({ session_id: sessionId, checkpoint_id: checkpointId }) - setComposerText(message.content) - resetEventBridgeCursors() - // Reload session from backend to ensure consistency - const sessionFrame = await loadSessionWithInsights(gatewayAPI, sessionId) - const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string } - if (sessionData?.messages) { - useChatStore.getState().clearMessages() - const mapped = mapHistoryMessages(sessionData.messages) - useChatStore.getState().setMessages(mapped) - } - } catch (err) { - const msg = err instanceof Error ? err.message : 'Revert failed' - useUIStore.getState().showToast('Revert failed: ' + msg, 'error') - setReverting(false) - } - } - return (
- {checkpointId && ( - - )}
{message.content}
- {confirming && ( - setConfirming(false)} - /> - )}
) } @@ -254,21 +182,6 @@ const styles: Record = { 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")