1import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
2import {
3 AuthFilesystemReadScope,
4 CheckpointRef,
5 EnvironmentId,
6 MessageId,
7 ThreadId,
8 RunId,
9 type AuthSessionState,
10} from "@t3tools/contracts";
11import { beforeEach, expect, it, vi } from "vite-plus/test";
12
13const state = vi.hoisted(() => ({
14 session: null as Pick<AuthSessionState, "authenticated" | "scopes"> | null,
15 sessionError: null as string | null,
16 sessionAtom: {},
17 effects: [] as Array<() => void>,
18 checkpoints: [] as ReadonlyArray<ThreadCheckpointSummary>,
19}));
20
21vi.mock("@t3tools/client-runtime/state/thread-checkpoints", () => ({
22 deriveThreadCheckpointSummaries: () => state.checkpoints,
23}));
24vi.mock("react", () => ({
25 useCallback: <A>(callback: A) => callback,
26 useEffect: (effect: () => void) => state.effects.push(effect),
27 useMemo: <A>(factory: () => A) => factory(),
28}));
29vi.mock("../../state/session", () => ({
30 environmentSession: { sessionStateAtom: () => state.sessionAtom },
31}));
32vi.mock("../../state/presentation", () => ({
33 useEnvironmentPresentation: () => ({
34 isReady: true,
35 presentation: { connection: { phase: "connected", error: null } },
36 }),
37}));
38vi.mock("../../state/use-thread-detail", () => ({
39 useSelectedThreadProjection: () => ({ projection: {} }),
40}));
41vi.mock("../../state/use-selected-thread-worktree", () => ({
42 useSelectedThreadWorktree: () => ({ selectedThreadCwd: "/repo" }),
43}));
44vi.mock("../../state/query", () => ({
45 useEnvironmentQuery: (atom: unknown) => ({
46 data: atom === state.sessionAtom ? state.session : null,
47 error: atom === state.sessionAtom ? state.sessionError : null,
48 isPending: atom === state.sessionAtom && state.session === null,
49 refresh: vi.fn(),
50 }),
51}));
52vi.mock("../../state/queries", () => ({
53 useCheckpointDiff: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }),
54}));
55vi.mock("../../state/review", () => ({
56 reviewEnvironment: { diffPreview: vi.fn() },
57}));
58vi.mock("./reviewState", () => ({
59 setReviewAsyncError: vi.fn(),
60 setReviewGitSections: vi.fn(),
61 setReviewSelectedSectionId: vi.fn(),
62 setReviewTurnDiff: vi.fn(),
63 setReviewTurnDiffLoading: vi.fn(),
64}));
65
66import { setReviewSelectedSectionId, type ReviewCacheForThread } from "./reviewState";
67import { useReviewSections } from "./useReviewSections";
68
69beforeEach(() => {
70 vi.clearAllMocks();
71 state.effects = [];
72 state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
73 state.sessionError = null;
74 state.checkpoints = [
75 {
76 runId: RunId.make("turn-1"),
77 checkpointTurnCount: 1,
78 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread/1"),
79 status: "ready",
80 files: [],
81 assistantMessageId: MessageId.make("message-1"),
82 completedAt: "2026-04-01T00:00:00.000Z",
83 },
84 ];
85});
86
87const checkpointDiff = "diff --git a/checkpoint.ts b/checkpoint.ts";
88const localDiff = "diff --git a/local.ts b/local.ts";
89function makeReviewCache(
90 kind: "working-tree" | "branch-range" = "working-tree",
91): ReviewCacheForThread {
92 return {
93 threadKey: "environment:thread",
94 gitSections: [
95 {
96 id: kind,
97 kind,
98 title: "Dirty worktree",
99 baseRef: "HEAD",
100 headRef: null,
101 diff: localDiff,
102 diffHash: "cached-local",
103 truncated: false,
104 },
105 ],
106 turnDiffById: { "turn:1": checkpointDiff },
107 selectedSectionId: `git:${kind}`,
108 asyncState: { loadingTurnIds: {}, error: null },
109 expandedFileIdsBySection: {},
110 revealedLargeFileIdsBySection: {},
111 viewedFileIdsBySection: {},
112 };
113}
114
115function makeInput(reviewCache = makeReviewCache()) {
116 return {
117 environmentId: EnvironmentId.make("environment"),
118 threadId: ThreadId.make("thread"),
119 reviewCache,
120 };
121}
122
123function renderSections(input: ReturnType<typeof makeInput>) {
124 const result = useReviewSections(input);
125 const effects = state.effects.splice(0);
126 effects.forEach((effect) => effect());
127 return result;
128}
129
130it("hides cached local diffs after file access is lost while retaining checkpoint diffs", () => {
131 const input = makeInput();
132 expect(renderSections(input).selectedSection?.diff).toBe(localDiff);
133
134 state.session = { authenticated: true, scopes: [] };
135 const denied = renderSections(input);
136 expect(denied.reviewSections.map((section) => section.id)).toEqual(["turn:1"]);
137 expect(denied.selectedSection?.diff).toBe(checkpointDiff);
138
139 expect(setReviewSelectedSectionId).toHaveBeenCalledWith("environment:thread", "turn:1");
140});
141
142it.each(["working-tree", "branch-range"] as const)(
143 "preserves a cached %s selection while an expired grant reloads",
144 (kind) => {
145 const input = makeInput(makeReviewCache(kind));
146 expect(renderSections(input).selectedSection?.diff).toBe(localDiff);
147
148 state.session = null;
149 const pending = renderSections(input);
150 expect(pending.selectedSection).toEqual(
151 expect.objectContaining({ id: `git:${kind}`, diff: null, isLoading: true }),
152 );
153 expect(pending.loadingGitDiffs).toBe(true);
154 expect(pending.reviewSections.find((section) => section.id === "turn:1")?.diff).toBe(
155 checkpointDiff,
156 );
157 expect(setReviewSelectedSectionId).not.toHaveBeenCalled();
158
159 state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
160 expect(renderSections(input).selectedSection).toEqual(
161 expect.objectContaining({ id: `git:${kind}`, diff: localDiff, isLoading: false }),
162 );
163 expect(setReviewSelectedSectionId).not.toHaveBeenCalled();
164 },
165);
166
167it("falls back to a checkpoint when a pending grant resolves without file access", () => {
168 const input = makeInput();
169 state.session = null;
170 renderSections(input);
171 expect(setReviewSelectedSectionId).not.toHaveBeenCalled();
172
173 state.session = { authenticated: true, scopes: [] };
174 const denied = renderSections(input);
175 expect(denied.reviewSections.map((section) => section.id)).toEqual(["turn:1"]);
176 expect(denied.selectedSection?.diff).toBe(checkpointDiff);
177 expect(setReviewSelectedSectionId).toHaveBeenCalledWith("environment:thread", "turn:1");
178});
179
180it("reports a failed access check when no checkpoint can replace the local review", () => {
181 state.checkpoints = [];
182 const input = makeInput();
183 expect(renderSections(input).selectedSection?.diff).toBe(localDiff);
184
185 state.sessionError = "The session request timed out.";
186 const unavailable = renderSections(input);
187 expect(unavailable.selectedSection).toBeNull();
188 expect(unavailable.reviewSections).toEqual([]);
189 expect(unavailable.error).toBe(state.sessionError);
190});
191
192it("reports denied file access when no checkpoint can replace the local review", () => {
193 state.checkpoints = [];
194 const input = makeInput();
195 expect(renderSections(input).selectedSection?.diff).toBe(localDiff);
196
197 state.session = { authenticated: true, scopes: [] };
198 const denied = renderSections(input);
199 expect(denied.selectedSection).toBeNull();
200 expect(denied.reviewSections).toEqual([]);
201 expect(denied.loadingGitDiffs).toBe(false);
202 expect(denied.error).toBe("This connection cannot read local diffs.");
203});
204
205it("keeps a cached checkpoint available when the filesystem access check fails", () => {
206 state.sessionError = "The session request timed out.";
207 const checkpoint = renderSections(makeInput());
208 expect(checkpoint.selectedSection?.diff).toBe(checkpointDiff);
209 expect(checkpoint.error).toBeNull();
210});