import { beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { createChangeReviewSlice } from '../../../src/renderer/store/slices/changeReviewSlice'; import { buildTaskChangePresenceKey } from '../../../src/renderer/utils/taskChangeRequest'; const hoisted = vi.hoisted(() => ({ getTaskChanges: vi.fn(), getAgentChanges: vi.fn(), getChangeStats: vi.fn(), getFileContent: vi.fn(), applyDecisions: vi.fn(), saveEditedFile: vi.fn(), loadDecisions: vi.fn(), saveDecisions: vi.fn(), clearDecisions: vi.fn(), checkConflict: vi.fn(), rejectHunks: vi.fn(), rejectFile: vi.fn(), previewReject: vi.fn(), })); vi.mock('@renderer/api', () => ({ api: { review: { getTaskChanges: hoisted.getTaskChanges, getAgentChanges: hoisted.getAgentChanges, getChangeStats: hoisted.getChangeStats, getFileContent: hoisted.getFileContent, applyDecisions: hoisted.applyDecisions, saveEditedFile: hoisted.saveEditedFile, loadDecisions: hoisted.loadDecisions, saveDecisions: hoisted.saveDecisions, clearDecisions: hoisted.clearDecisions, checkConflict: hoisted.checkConflict, rejectHunks: hoisted.rejectHunks, rejectFile: hoisted.rejectFile, previewReject: hoisted.previewReject, }, }, })); function createSliceStore() { return create()((set, get, store) => ({ ...createChangeReviewSlice(set as never, get as never, store as never), setSelectedTeamTaskChangePresence: vi.fn(), })); } function deferred() { let resolve!: (value: T) => void; let reject!: (error?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } async function flushAsyncWork(): Promise { await Promise.resolve(); await Promise.resolve(); } function makeSnippet( overrides: Partial<{ toolUseId: string; filePath: string; toolName: string; type: 'edit' | 'multi-edit' | 'write-new' | 'write-update'; oldString: string; newString: string; replaceAll: boolean; timestamp: string; isError: boolean; contextHash: string; }> = {} ) { return { toolUseId: 'tool-1', filePath: '/repo/file.ts', toolName: 'Edit', type: 'edit' as const, oldString: 'before', newString: 'after', replaceAll: false, timestamp: '2026-03-01T10:00:00.000Z', isError: false, ...overrides, }; } function makeFile(filePath = '/repo/file.ts', snippetOverrides = {}) { return { filePath, relativePath: filePath.split('/').pop() ?? 'file.ts', snippets: [makeSnippet({ filePath, ...snippetOverrides })], linesAdded: 1, linesRemoved: 1, isNewFile: false, }; } function makeAgentChangeSet(filePath = '/repo/file.ts', snippetOverrides = {}) { const file = makeFile(filePath, snippetOverrides); return { memberName: 'alice', teamName: 'team-a', files: [file], totalFiles: 1, totalLinesAdded: file.linesAdded, totalLinesRemoved: file.linesRemoved, }; } function makeTaskChangeSet(taskId = 'task-1', filePath = '/repo/file.ts', snippetOverrides = {}) { const file = makeFile(filePath, snippetOverrides); return { teamName: 'team-a', taskId, files: [file], totalFiles: 1, totalLinesAdded: file.linesAdded, totalLinesRemoved: file.linesRemoved, confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId, memberName: 'alice', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [filePath], confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, }, warnings: [], }; } const OPTIONS_A = { owner: 'alice', status: 'completed', intervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], since: '2026-03-01T09:58:00.000Z', stateBucket: 'completed' as const, }; const OPTIONS_B = { owner: 'bob', status: 'completed', intervals: [{ startedAt: '2026-03-01T11:00:00.000Z' }], since: '2026-03-01T10:58:00.000Z', stateBucket: 'completed' as const, }; const REVIEW_OPTIONS = { owner: 'alice', status: 'completed', intervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], since: '2026-03-01T09:58:00.000Z', stateBucket: 'review' as const, }; describe('changeReviewSlice task changes', () => { beforeEach(() => { vi.clearAllMocks(); }); it('does not cache errors as negative task-change results', async () => { const store = createSliceStore(); hoisted.getTaskChanges.mockRejectedValue(new Error('transient')); await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_A); await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_A); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); }); it('negative-caches confirmed empty results per request signature', async () => { const store = createSliceStore(); hoisted.getTaskChanges.mockResolvedValue({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName: 'team-a', taskId: '1', confidence: 'high', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: '1', memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 1, label: 'high', reason: 'Confirmed empty summary' }, }, warnings: [], }); await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_A); await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_A); await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_B); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); expect( store.getState().taskChangePresenceByKey[buildTaskChangePresenceKey('team-a', '1', OPTIONS_A)] ).toBe('no_changes'); }); it('updates selected team task changePresence after a positive summary check', async () => { const store = createSliceStore(); hoisted.getTaskChanges.mockResolvedValue(makeTaskChangeSet('presence-hit')); await store.getState().checkTaskHasChanges('team-a', 'presence-hit', OPTIONS_A); expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( 'team-a', 'presence-hit', 'has_changes' ); }); it('updates selected team task changePresence to no_changes only for confirmed empty summaries', async () => { const store = createSliceStore(); hoisted.getTaskChanges.mockResolvedValue({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName: 'team-a', taskId: 'presence-empty', confidence: 'high', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: 'presence-empty', memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 1, label: 'high', reason: 'test fixture' }, }, warnings: [], }); await store.getState().checkTaskHasChanges('team-a', 'presence-empty', OPTIONS_A); expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( 'team-a', 'presence-empty', 'no_changes' ); }); it('keeps changePresence unknown for fallback empty summaries', async () => { const store = createSliceStore(); hoisted.getTaskChanges.mockResolvedValue({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName: 'team-a', taskId: 'presence-unknown', confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: 'presence-unknown', memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, }, warnings: [], }); await store.getState().checkTaskHasChanges('team-a', 'presence-unknown', OPTIONS_A); expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith( 'team-a', 'presence-unknown', 'no_changes' ); }); it('treats diagnostic-only multi-scope summaries as unknown and rechecks after invalidation', async () => { const store = createSliceStore(); const teamName = 'team-a'; const taskId = 'presence-warning'; const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A); hoisted.getTaskChanges.mockResolvedValue({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName, taskId, confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId, memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'Ambiguous scope skipped' }, }, warnings: ['Ledger skipped attribution because multiple task scopes were active.'], provenance: { sourceKind: 'ledger', sourceFingerprint: 'ledger-warning-only', }, }); await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith( teamName, taskId, 'needs_attention' ); expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined(); store.getState().invalidateTaskChangePresence([cacheKey]); await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); }); it('treats unclassified warning-only summaries as needs_attention', async () => { const store = createSliceStore(); const teamName = 'team-a'; const taskId = 'presence-unclassified-warning'; const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A); hoisted.getTaskChanges.mockResolvedValue({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName, taskId, confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId, memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'Unknown warning' }, }, warnings: ['Unexpected ledger warning.'], provenance: { sourceKind: 'ledger', sourceFingerprint: 'ledger-warning-only', }, }); await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( teamName, taskId, 'needs_attention' ); expect(store.getState().taskChangePresenceByKey[cacheKey]).toBe('needs_attention'); }); it('background revalidates cached needs_attention presence', async () => { const store = createSliceStore(); const teamName = 'team-a'; const taskId = 'cached-needs-attention'; const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A); store.setState({ selectedTeamName: teamName, selectedTeamData: { tasks: [{ id: taskId, changePresence: 'needs_attention' }], }, taskChangePresenceByKey: { [cacheKey]: 'needs_attention' }, }); hoisted.getTaskChanges.mockResolvedValue({ teamName, taskId, files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId, memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'Multi-scope notice only' }, }, warnings: ['Task change ledger skipped attribution because multiple task scopes were active.'], }); await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); await flushAsyncWork(); expect(hoisted.getTaskChanges).toHaveBeenCalledWith(teamName, taskId, { ...OPTIONS_A, summaryOnly: true, forceFresh: true, }); expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined(); expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( teamName, taskId, 'needs_attention' ); expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( teamName, taskId, 'unknown' ); }); it('does not raise needs_attention for active interval summaries with no observed file edits yet', async () => { const store = createSliceStore(); const teamName = 'team-a'; const taskId = 'presence-active-no-edits'; const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A); hoisted.getTaskChanges.mockResolvedValue({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName, taskId, confidence: 'medium', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId, memberName: 'echo', startLine: 0, endLine: 0, startTimestamp: '2026-03-01T12:00:00.000Z', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 2, label: 'medium', reason: 'Scoped by persisted task workIntervals (timestamp-based)', }, }, warnings: ['No file edits found within persisted workIntervals.'], }); await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith( teamName, taskId, 'needs_attention' ); expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined(); }); it('downgrades stale known presence to unknown for fallback empty summaries', async () => { const store = createSliceStore(); store.setState({ selectedTeamName: 'team-a', selectedTeamData: { tasks: [{ id: 'presence-stale', changePresence: 'has_changes' }], }, }); hoisted.getTaskChanges.mockResolvedValue({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName: 'team-a', taskId: 'presence-stale', confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: 'presence-stale', memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, }, warnings: [], }); await store.getState().checkTaskHasChanges('team-a', 'presence-stale', OPTIONS_A); expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( 'team-a', 'presence-stale', 'unknown' ); }); it('bypasses stale negative cache when selected team task presence is unknown', async () => { const store = createSliceStore(); hoisted.getTaskChanges.mockResolvedValue({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName: 'team-a', taskId: 'presence-bypass', confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: 'presence-bypass', memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, }, warnings: [], }); await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A); store.setState({ selectedTeamName: 'team-a', selectedTeamData: { tasks: [{ id: 'presence-bypass', changePresence: 'unknown' }], }, }); await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); }); it('ignores stale fetchTaskChanges responses when a newer task request wins', async () => { const store = createSliceStore(); const first = deferred(); const second = deferred(); hoisted.getTaskChanges.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); const firstFetch = store.getState().fetchTaskChanges('team-a', '1', OPTIONS_A); const secondFetch = store.getState().fetchTaskChanges('team-a', '2', OPTIONS_B); second.resolve({ teamName: 'team-a', taskId: '2', files: [{ filePath: '/repo/new.ts', relativePath: 'new.ts', snippets: [], linesAdded: 1, linesRemoved: 0, isNewFile: true }], totalFiles: 1, totalLinesAdded: 1, totalLinesRemoved: 0, confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: '2', memberName: 'bob', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: ['/repo/new.ts'], confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, }, warnings: [], }); await secondFetch; first.resolve({ teamName: 'team-a', taskId: '1', files: [{ filePath: '/repo/old.ts', relativePath: 'old.ts', snippets: [], linesAdded: 1, linesRemoved: 0, isNewFile: true }], totalFiles: 1, totalLinesAdded: 1, totalLinesRemoved: 0, confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: '1', memberName: 'alice', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: ['/repo/old.ts'], confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, }, warnings: [], }); await firstFetch; expect(store.getState().activeChangeSet?.taskId).toBe('2'); expect(store.getState().selectedReviewFilePath).toBe('/repo/new.ts'); }); it('does not treat review-state summaries as permanently cacheable', async () => { const store = createSliceStore(); hoisted.getTaskChanges.mockResolvedValue({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName: 'team-a', taskId: '1', confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: '1', memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, }, warnings: [], }); await store.getState().checkTaskHasChanges('team-a', '1', REVIEW_OPTIONS); // Expire the 30s negative-cache TTL so the second call actually hits the API vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31_000); await store.getState().checkTaskHasChanges('team-a', '1', REVIEW_OPTIONS); vi.mocked(Date.now).mockRestore(); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); }); it('re-warms terminal summaries after an earlier empty result', async () => { const store = createSliceStore(); const teamName = 'team-warm'; const taskId = 'late-log-task'; hoisted.getTaskChanges .mockResolvedValueOnce({ files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, teamName, taskId, confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: '1', memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, }, warnings: [], }) .mockResolvedValueOnce({ teamName, taskId, files: [ { filePath: '/repo/new.ts', relativePath: 'new.ts', snippets: [], linesAdded: 1, linesRemoved: 0, isNewFile: true, }, ], totalFiles: 1, totalLinesAdded: 1, totalLinesRemoved: 0, confidence: 'fallback', computedAt: '2026-03-01T12:01:00.000Z', scope: { taskId: '1', memberName: 'alice', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: ['/repo/new.ts'], confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, }, warnings: [], }) .mockResolvedValueOnce({ teamName, taskId, files: [ { filePath: '/repo/new.ts', relativePath: 'new.ts', snippets: [], linesAdded: 1, linesRemoved: 0, isNewFile: true, }, ], totalFiles: 1, totalLinesAdded: 1, totalLinesRemoved: 0, confidence: 'fallback', computedAt: '2026-03-01T12:01:01.000Z', scope: { taskId: '1', memberName: 'alice', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: ['/repo/new.ts'], confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, }, warnings: [], }); await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); await store .getState() .warmTaskChangeSummaries([{ teamName, taskId, options: OPTIONS_A }]); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(3); expect( store.getState().taskChangePresenceByKey[ buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A) ] ).toBe('has_changes'); }); it('warms task summaries with bounded concurrency', async () => { const store = createSliceStore(); const pending = Array.from({ length: 6 }, () => deferred()); let callIndex = 0; hoisted.getTaskChanges.mockImplementation(() => pending[callIndex++].promise); const requests = Array.from({ length: 6 }, (_, index) => ({ teamName: 'team-a', taskId: `task-${index}`, options: { owner: 'alice', status: 'completed', intervals: [{ startedAt: `2026-03-01T1${index}:00:00.000Z` }], since: `2026-03-01T0${index}:58:00.000Z`, stateBucket: 'completed' as const, }, })); const warmPromise = store.getState().warmTaskChangeSummaries(requests); await flushAsyncWork(); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(4); for (let index = 0; index < 4; index++) { pending[index].resolve({ teamName: 'team-a', taskId: `task-${index}`, files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, confidence: 'fallback', computedAt: '2026-12-01T12:00:00.000Z', scope: { taskId: `task-${index}`, memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, }, warnings: [], }); } await flushAsyncWork(); expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(6); for (let index = 4; index < 6; index++) { pending[index].resolve({ teamName: 'team-a', taskId: `task-${index}`, files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, confidence: 'fallback', computedAt: '2026-12-01T12:00:00.000Z', scope: { taskId: `task-${index}`, memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, }, warnings: [], }); } await warmPromise; }); it('clears stale no_changes warm cache entries for diagnostic-only summaries', async () => { const store = createSliceStore(); const teamName = 'team-a'; const taskId = 'warm-diagnostic-only'; const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A); store.setState({ taskChangePresenceByKey: { [cacheKey]: 'no_changes' } }); hoisted.getTaskChanges.mockResolvedValue({ teamName, taskId, files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, confidence: 'fallback', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId, memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'Multi-scope notice only' }, }, warnings: ['Task change ledger skipped attribution because multiple task scopes were active.'], }); await store.getState().warmTaskChangeSummaries([{ teamName, taskId, options: OPTIONS_A }]); expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined(); }); it('clears optimistic terminal presence after background forceFresh revalidation', async () => { const store = createSliceStore(); const teamName = 'team-revalidate'; const taskId = 'persisted-hit'; hoisted.getTaskChanges .mockResolvedValueOnce({ teamName, taskId, files: [ { filePath: '/repo/persisted.ts', relativePath: 'persisted.ts', snippets: [], linesAdded: 1, linesRemoved: 0, isNewFile: true, }, ], totalFiles: 1, totalLinesAdded: 1, totalLinesRemoved: 0, confidence: 'medium', computedAt: '2026-03-01T12:00:00.000Z', scope: { taskId: '1', memberName: 'alice', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: ['/repo/persisted.ts'], confidence: { tier: 2, label: 'medium', reason: 'Persisted summary' }, }, warnings: [], }) .mockResolvedValueOnce({ teamName, taskId, files: [], totalFiles: 0, totalLinesAdded: 0, totalLinesRemoved: 0, confidence: 'fallback', computedAt: '2026-03-01T12:01:00.000Z', scope: { taskId: '1', memberName: '', startLine: 0, endLine: 0, startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [], confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, }, warnings: [], }); await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); await flushAsyncWork(); expect(hoisted.getTaskChanges).toHaveBeenNthCalledWith(1, teamName, taskId, { ...OPTIONS_A, summaryOnly: true, }); expect(hoisted.getTaskChanges).toHaveBeenNthCalledWith(2, teamName, taskId, { ...OPTIONS_A, summaryOnly: true, forceFresh: true, }); expect( store.getState().taskChangePresenceByKey[ buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A) ] ).toBeUndefined(); expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( teamName, taskId, 'unknown' ); }); it('clears resolved file content state when fetchAgentChanges installs a new change set', async () => { const store = createSliceStore(); const data = makeAgentChangeSet('/repo/new.ts'); hoisted.getAgentChanges.mockResolvedValue(data); store.setState({ hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileContents: { '/repo/file.ts': { ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }, }, fileContentsLoading: { '/repo/file.ts': true }, fileChunkCounts: { '/repo/file.ts': 3 }, hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } }, changeSetEpoch: 4, fileContentVersionByPath: { '/repo/file.ts': 2 }, }); await store.getState().fetchAgentChanges('team-a', 'alice'); expect(store.getState().activeChangeSet).toEqual(data); expect(store.getState().selectedReviewFilePath).toBe('/repo/new.ts'); expect(store.getState().fileContents).toEqual({}); expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().hunkDecisions).toEqual({}); expect(store.getState().changeSetEpoch).toBe(5); expect(store.getState().fileContentVersionByPath).toEqual({}); }); it('clears resolved file content state when fetchTaskChanges installs a new change set', async () => { const store = createSliceStore(); const data = makeTaskChangeSet('task-2', '/repo/task.ts'); hoisted.getTaskChanges.mockResolvedValue(data); store.setState({ hunkDecisions: { '/repo/file.ts:0': 'accepted' }, fileContents: { '/repo/file.ts': { ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }, }, fileContentsLoading: { '/repo/file.ts': true }, fileChunkCounts: { '/repo/file.ts': 2 }, hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } }, changeSetEpoch: 1, fileContentVersionByPath: { '/repo/file.ts': 7 }, }); await store.getState().fetchTaskChanges('team-a', 'task-2', OPTIONS_A); expect(store.getState().activeChangeSet).toEqual(data); expect(store.getState().activeTaskChangeRequestOptions).toEqual(OPTIONS_A); expect(store.getState().selectedReviewFilePath).toBe('/repo/task.ts'); expect(store.getState().fileContents).toEqual({}); expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().hunkDecisions).toEqual({}); expect(store.getState().changeSetEpoch).toBe(2); expect(store.getState().fileContentVersionByPath).toEqual({}); }); it('re-fetches visible file content after change-set replacement instead of silently reusing stale content', async () => { const store = createSliceStore(); const refreshed = makeAgentChangeSet('/repo/file.ts', { newString: 'after-v2' }); hoisted.getAgentChanges.mockResolvedValueOnce(refreshed); hoisted.getFileContent.mockResolvedValueOnce({ ...makeFile('/repo/file.ts', { newString: 'after-v2' }), originalFullContent: 'before', modifiedFullContent: 'after-v2', contentSource: 'snippet-reconstruction', }); store.setState({ activeChangeSet: makeAgentChangeSet('/repo/file.ts'), fileContents: { '/repo/file.ts': { ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }, }, fileContentsLoading: {}, changeSetEpoch: 0, fileContentVersionByPath: {}, }); await store.getState().fetchAgentChanges('team-a', 'alice'); expect(store.getState().fileContents).toEqual({}); await store.getState().fetchFileContent('team-a', 'alice', '/repo/file.ts'); expect(hoisted.getFileContent).toHaveBeenCalledTimes(1); expect(hoisted.getFileContent).toHaveBeenCalledWith( 'team-a', 'alice', '/repo/file.ts', refreshed.files[0]?.snippets ?? [] ); expect(store.getState().fileContents['/repo/file.ts']?.modifiedFullContent).toBe('after-v2'); }); it('uses canonical relative Windows file paths when fetching content by slash/case variant', async () => { const store = createSliceStore(); const filePath = 'SRC\\File.ts'; const data = makeAgentChangeSet(filePath); hoisted.getFileContent.mockResolvedValueOnce({ ...makeFile(filePath), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }); store.setState({ activeChangeSet: data, changeSetEpoch: 0, fileContentVersionByPath: {}, }); await store.getState().fetchFileContent('team-a', 'alice', 'src/file.ts'); expect(hoisted.getFileContent).toHaveBeenCalledWith( 'team-a', 'alice', filePath, data.files[0]?.snippets ?? [] ); expect(store.getState().fileContents[filePath]?.modifiedFullContent).toBe('after'); expect(store.getState().fileContents['src/file.ts']).toBeUndefined(); }); it('ignores stale fetchFileContent responses after change-set replacement', async () => { const store = createSliceStore(); const pending = deferred(); hoisted.getFileContent.mockReturnValueOnce(pending.promise); hoisted.getAgentChanges.mockResolvedValueOnce(makeAgentChangeSet('/repo/next.ts')); store.setState({ activeChangeSet: makeAgentChangeSet('/repo/file.ts'), hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } }, changeSetEpoch: 0, fileContentVersionByPath: {}, }); const fetchPromise = store.getState().fetchFileContent('team-a', 'alice', '/repo/file.ts'); await flushAsyncWork(); await store.getState().fetchAgentChanges('team-a', 'alice'); pending.resolve({ ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }); await fetchPromise; await flushAsyncWork(); expect(store.getState().selectedReviewFilePath).toBe('/repo/next.ts'); expect(store.getState().fileContents).toEqual({}); expect(store.getState().fileContentsLoading).toEqual({}); }); it('ignores stale fetchFileContent responses after per-file invalidation', async () => { const store = createSliceStore(); const pending = deferred(); hoisted.getFileContent.mockReturnValueOnce(pending.promise); store.setState({ activeChangeSet: makeAgentChangeSet('/repo/file.ts'), changeSetEpoch: 0, fileContentVersionByPath: {}, }); const fetchPromise = store.getState().fetchFileContent('team-a', 'alice', '/repo/file.ts'); await flushAsyncWork(); store.getState().clearReviewStateForFile('/repo/file.ts'); pending.resolve({ ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }); await fetchPromise; await flushAsyncWork(); expect(store.getState().fileContents).toEqual({}); expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); it('normalizes persisted legacy file-path review decisions onto changeKey entries', async () => { const store = createSliceStore(); const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; const ledgerFile = { ...makeFile('/repo/new.ts'), changeKey, }; store.setState({ activeChangeSet: { ...makeTaskChangeSet('task-ledger', '/repo/new.ts'), files: [ledgerFile], totalFiles: 1, totalLinesAdded: ledgerFile.linesAdded, totalLinesRemoved: ledgerFile.linesRemoved, }, }); hoisted.loadDecisions.mockResolvedValueOnce({ hunkDecisions: { '/repo/new.ts:0': 'rejected' }, fileDecisions: { '/repo/new.ts': 'rejected' }, hunkContextHashesByFile: { '/repo/new.ts': { 0: 'ctx-rename' } }, }); await store.getState().loadDecisionsFromDisk('team-a', 'task-task-ledger', 'scope-token'); expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' }); expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' }); expect(store.getState().hunkContextHashesByFile).toEqual({ [changeKey]: { 0: 'ctx-rename' }, }); }); it('stores fresh decisions under changeKey for grouped ledger files', () => { const store = createSliceStore(); const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; const ledgerFile = { ...makeFile('/repo/new.ts'), changeKey, }; store.setState({ activeChangeSet: { ...makeAgentChangeSet('/repo/new.ts'), files: [ledgerFile], totalFiles: 1, totalLinesAdded: ledgerFile.linesAdded, totalLinesRemoved: ledgerFile.linesRemoved, }, fileChunkCounts: { '/repo/new.ts': 1 }, }); const originalIndex = store.getState().setHunkDecision('/repo/new.ts', 0, 'rejected'); store.getState().setFileDecision('/repo/new.ts', 'rejected'); expect(originalIndex).toBe(0); expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' }); expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' }); }); it('stores grouped copy decisions under the copy changeKey', () => { const store = createSliceStore(); const changeKey = 'copy:/repo/base.ts->/repo/copy.ts'; const ledgerFile = { ...makeFile('/repo/copy.ts'), changeKey, }; store.setState({ activeChangeSet: { ...makeAgentChangeSet('/repo/copy.ts'), files: [ledgerFile], totalFiles: 1, totalLinesAdded: ledgerFile.linesAdded, totalLinesRemoved: ledgerFile.linesRemoved, }, fileChunkCounts: { '/repo/copy.ts': 1 }, }); const originalIndex = store.getState().setHunkDecision('/repo/copy.ts', 0, 'accepted'); store.getState().setFileDecision('/repo/copy.ts', 'accepted'); expect(originalIndex).toBe(0); expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'accepted' }); expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'accepted' }); }); it('invalidates resolved file content without clearing draft or review decisions', async () => { const store = createSliceStore(); store.setState({ activeChangeSet: makeAgentChangeSet('/repo/file.ts'), hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' }, fileChunkCounts: { '/repo/file.ts': 2 }, hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } }, fileContents: { '/repo/file.ts': { ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }, }, fileContentsLoading: { '/repo/file.ts': true }, editedContents: { '/repo/file.ts': 'draft' }, reviewExternalChangesByFile: { '/repo/file.ts': { type: 'change' } }, fileContentVersionByPath: {}, }); store.getState().invalidateResolvedFileContent('/repo/file.ts'); expect(store.getState().fileContents).toEqual({}); expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().editedContents).toEqual({ '/repo/file.ts': 'draft' }); expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'rejected' }); expect(store.getState().fileDecisions).toEqual({ '/repo/file.ts': 'rejected' }); expect(store.getState().reviewExternalChangesByFile).toEqual({ '/repo/file.ts': { type: 'change' }, }); expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); it('invalidates review-key hunk hashes for grouped ledger files without clearing decisions', () => { const store = createSliceStore(); const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; const ledgerFile = { ...makeFile('/repo/new.ts'), changeKey, }; store.setState({ activeChangeSet: { ...makeAgentChangeSet('/repo/new.ts'), files: [ledgerFile], totalFiles: 1, totalLinesAdded: ledgerFile.linesAdded, totalLinesRemoved: ledgerFile.linesRemoved, }, hunkDecisions: { [`${changeKey}:0`]: 'rejected' }, fileDecisions: { [changeKey]: 'rejected' }, fileChunkCounts: { '/repo/new.ts': 2 }, hunkContextHashesByFile: { [changeKey]: { 0: 'ctx-rename' } }, fileContents: { '/repo/new.ts': { ...ledgerFile, originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'ledger-exact', }, }, fileContentsLoading: { '/repo/new.ts': true }, editedContents: { '/repo/new.ts': 'draft' }, reviewExternalChangesByFile: { '/repo/new.ts': { type: 'change' } }, fileContentVersionByPath: {}, }); store.getState().invalidateResolvedFileContent('/repo/new.ts'); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' }); expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' }); expect(store.getState().editedContents).toEqual({ '/repo/new.ts': 'draft' }); expect(store.getState().fileContentVersionByPath['/repo/new.ts']).toBe(1); }); it('reloadReviewFileFromDisk clears the draft but preserves review decisions', async () => { const store = createSliceStore(); store.setState({ activeChangeSet: makeAgentChangeSet('/repo/file.ts'), hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' }, fileChunkCounts: { '/repo/file.ts': 2 }, hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } }, fileContents: { '/repo/file.ts': { ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }, }, editedContents: { '/repo/file.ts': 'draft' }, reviewExternalChangesByFile: { '/repo/file.ts': { type: 'unlink' } }, fileContentVersionByPath: {}, }); store.getState().reloadReviewFileFromDisk('/repo/file.ts'); expect(store.getState().fileContents).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().editedContents).toEqual({}); expect(store.getState().reviewExternalChangesByFile).toEqual({}); expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'rejected' }); expect(store.getState().fileDecisions).toEqual({ '/repo/file.ts': 'rejected' }); expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); it('ignores stale fetchFileContent responses after removing a review file', async () => { const store = createSliceStore(); const pending = deferred(); hoisted.getFileContent.mockReturnValueOnce(pending.promise); store.setState({ activeChangeSet: makeAgentChangeSet('/repo/file.ts'), changeSetEpoch: 0, fileContentVersionByPath: {}, }); const fetchPromise = store.getState().fetchFileContent('team-a', 'alice', '/repo/file.ts'); await flushAsyncWork(); store.getState().removeReviewFile('/repo/file.ts'); pending.resolve({ ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }); await fetchPromise; await flushAsyncWork(); expect(store.getState().activeChangeSet?.files).toEqual([]); expect(store.getState().fileContents).toEqual({}); expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); it('removes relative Windows review files by slash/case variant without leaving stale state', async () => { const store = createSliceStore(); const filePath = 'SRC\\File.ts'; store.setState({ activeChangeSet: makeAgentChangeSet(filePath), selectedReviewFilePath: filePath, hunkDecisions: { [`${filePath}:0`]: 'rejected' }, fileDecisions: { [filePath]: 'rejected' }, fileContents: { [filePath]: { ...makeFile(filePath), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }, }, fileContentsLoading: { [filePath]: true }, fileContentVersionByPath: {}, }); store.getState().removeReviewFile('src/file.ts'); expect(store.getState().activeChangeSet?.files).toEqual([]); expect(store.getState().selectedReviewFilePath).toBeNull(); expect(store.getState().fileContents).toEqual({}); expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileContentVersionByPath[filePath]).toBe(1); }); it('clears path-equivalent loading aliases when removing the canonical review file', async () => { const store = createSliceStore(); const filePath = 'SRC\\File.ts'; store.setState({ activeChangeSet: makeAgentChangeSet(filePath), fileContentsLoading: { [filePath]: true, 'src/file.ts': true }, fileContentVersionByPath: { 'src/file.ts': 0 }, }); store.getState().removeReviewFile(filePath); expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().fileContentVersionByPath[filePath]).toBe(1); expect(store.getState().fileContentVersionByPath['src/file.ts']).toBe(1); }); it('keeps restored file content when a stale fetch resolves after remove and re-add', async () => { const store = createSliceStore(); const pending = deferred(); hoisted.getFileContent.mockReturnValueOnce(pending.promise); store.setState({ activeChangeSet: makeAgentChangeSet('/repo/file.ts'), changeSetEpoch: 0, fileContentVersionByPath: {}, }); const fetchPromise = store.getState().fetchFileContent('team-a', 'alice', '/repo/file.ts'); await flushAsyncWork(); store.getState().removeReviewFile('/repo/file.ts'); store.getState().addReviewFile(makeFile('/repo/file.ts'), { index: 0, content: { ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'restored', contentSource: 'snippet-reconstruction', }, }); pending.resolve({ ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'stale', contentSource: 'snippet-reconstruction', }); await fetchPromise; await flushAsyncWork(); expect(store.getState().activeChangeSet?.files).toHaveLength(1); expect(store.getState().fileContents['/repo/file.ts']?.modifiedFullContent).toBe('restored'); expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); it('ignores stale fetchFileContent responses that resolve after saveEditedFile', async () => { const store = createSliceStore(); const fetchPending = deferred(); const savePending = deferred(); hoisted.getFileContent.mockReturnValueOnce(fetchPending.promise); hoisted.saveEditedFile.mockReturnValueOnce(savePending.promise); store.setState({ activeChangeSet: makeAgentChangeSet('/repo/file.ts'), fileContents: { '/repo/file.ts': { ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'draft-before-save', contentSource: 'snippet-reconstruction', }, }, fileContentsLoading: { '/repo/file.ts': true }, fileChunkCounts: { '/repo/file.ts': 3 }, hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } }, editedContents: { '/repo/file.ts': 'saved-content' }, changeSetEpoch: 0, fileContentVersionByPath: {}, }); const fetchPromise = store.getState().fetchFileContent('team-a', 'alice', '/repo/file.ts'); await flushAsyncWork(); const savePromise = store.getState().saveEditedFile('/repo/file.ts'); await flushAsyncWork(); savePending.resolve(); await savePromise; fetchPending.resolve({ ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'stale-after-save', contentSource: 'snippet-reconstruction', }); await fetchPromise; await flushAsyncWork(); expect(store.getState().editedContents).toEqual({}); expect(store.getState().fileContents['/repo/file.ts']?.modifiedFullContent).toBe('saved-content'); expect(store.getState().fileContentsLoading['/repo/file.ts']).toBe(false); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); }); it('clears review-key hunk hashes after saveEditedFile for grouped ledger files', async () => { const store = createSliceStore(); const changeKey = 'rename:/repo/old.ts->/repo/new.ts'; const ledgerFile = { ...makeFile('/repo/new.ts'), changeKey, }; hoisted.saveEditedFile.mockResolvedValueOnce(undefined); store.setState({ activeChangeSet: { ...makeAgentChangeSet('/repo/new.ts'), files: [ledgerFile], totalFiles: 1, totalLinesAdded: ledgerFile.linesAdded, totalLinesRemoved: ledgerFile.linesRemoved, }, fileContents: { '/repo/new.ts': { ...ledgerFile, originalFullContent: 'before', modifiedFullContent: 'draft-before-save', contentSource: 'ledger-exact', }, }, fileChunkCounts: { '/repo/new.ts': 2 }, hunkContextHashesByFile: { [changeKey]: { 0: 'ctx-rename' } }, editedContents: { '/repo/new.ts': 'saved-content' }, fileContentVersionByPath: {}, }); await store.getState().saveEditedFile('/repo/new.ts'); expect(hoisted.saveEditedFile).toHaveBeenCalledWith('/repo/new.ts', 'saved-content', undefined); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().fileContents['/repo/new.ts']?.modifiedFullContent).toBe('saved-content'); }); it('saves edited content through canonical Windows ledger paths and clears aliases', async () => { const store = createSliceStore(); const canonicalPath = 'SRC\\File.ts'; const aliasPath = 'src/file.ts'; const ledgerFile = makeFile(canonicalPath); hoisted.saveEditedFile.mockResolvedValueOnce(undefined); store.setState({ activeChangeSet: { ...makeAgentChangeSet(canonicalPath), files: [ledgerFile], totalFiles: 1, }, fileContents: { [aliasPath]: { ...ledgerFile, filePath: aliasPath, originalFullContent: 'before', modifiedFullContent: 'draft-before-save', contentSource: 'ledger-exact', }, }, fileChunkCounts: { [aliasPath]: 2, [canonicalPath]: 2 }, hunkContextHashesByFile: { [aliasPath]: { 0: 'ctx-alias' }, [canonicalPath]: { 0: 'ctx-canonical' }, }, reviewExternalChangesByFile: { [aliasPath]: { type: 'change' } }, editedContents: { [aliasPath]: 'saved-content' }, fileContentVersionByPath: {}, }); await store.getState().saveEditedFile(aliasPath, '/repo'); expect(hoisted.saveEditedFile).toHaveBeenCalledWith(canonicalPath, 'saved-content', '/repo'); expect(store.getState().editedContents).toEqual({}); expect(store.getState().fileChunkCounts).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().reviewExternalChangesByFile).toEqual({}); expect(store.getState().fileContents[aliasPath]).toBeUndefined(); expect(store.getState().fileContents[canonicalPath]?.filePath).toBe(canonicalPath); expect(store.getState().fileContents[canonicalPath]?.modifiedFullContent).toBe('saved-content'); expect(store.getState().fileContentVersionByPath[aliasPath]).toBe(1); expect(store.getState().fileContentVersionByPath[canonicalPath]).toBe(1); }); it('does not canonicalize POSIX paths that differ only by case when saving edits', async () => { const store = createSliceStore(); const canonicalPath = 'SRC/File.ts'; const requestedPath = 'src/file.ts'; hoisted.saveEditedFile.mockResolvedValueOnce(undefined); store.setState({ activeChangeSet: makeAgentChangeSet(canonicalPath), fileContents: { [requestedPath]: { ...makeFile(requestedPath), originalFullContent: 'before', modifiedFullContent: 'draft-before-save', contentSource: 'snippet-reconstruction', }, }, editedContents: { [requestedPath]: 'saved-content' }, fileContentVersionByPath: {}, }); await store.getState().saveEditedFile(requestedPath, '/repo'); expect(hoisted.saveEditedFile).toHaveBeenCalledWith(requestedPath, 'saved-content', '/repo'); expect(store.getState().fileContents[requestedPath]?.modifiedFullContent).toBe('saved-content'); expect(store.getState().fileContents[canonicalPath]).toBeUndefined(); }); it('forces re-review when snippets change even if file paths stay the same', async () => { const store = createSliceStore(); const current = makeAgentChangeSet('/repo/file.ts', { newString: 'after' }); const fresh = makeAgentChangeSet('/repo/file.ts', { newString: 'after-v2' }); hoisted.getAgentChanges.mockResolvedValueOnce(fresh); store.setState({ activeChangeSet: current, hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' }, fileChunkCounts: { '/repo/file.ts': 1 }, reviewUndoStack: [{ hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' } }], hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } }, fileContents: { '/repo/file.ts': { ...makeFile('/repo/file.ts'), originalFullContent: 'before', modifiedFullContent: 'after', contentSource: 'snippet-reconstruction', }, }, fileContentsLoading: { '/repo/file.ts': false }, editedContents: { '/repo/file.ts': 'draft' }, changeSetEpoch: 2, fileContentVersionByPath: { '/repo/file.ts': 3 }, }); await store.getState().applyReview('team-a', undefined, 'alice'); expect(hoisted.applyDecisions).not.toHaveBeenCalled(); expect(store.getState().activeChangeSet).toEqual(fresh); expect(store.getState().applyError).toBe( 'Changes have been updated since you started reviewing. Please re-review.' ); expect(store.getState().hunkDecisions).toEqual({}); expect(store.getState().fileDecisions).toEqual({}); expect(store.getState().reviewUndoStack).toEqual([]); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().fileContents).toEqual({}); expect(store.getState().fileContentsLoading).toEqual({}); expect(store.getState().editedContents).toEqual({}); expect(store.getState().changeSetEpoch).toBe(3); expect(store.getState().fileContentVersionByPath).toEqual({}); }); it('forces re-review when snippet order changes even if file paths stay the same', async () => { const store = createSliceStore(); const first = makeSnippet({ toolUseId: 'tool-1', filePath: '/repo/file.ts', oldString: 'a', newString: 'b', timestamp: '2026-03-01T10:00:00.000Z', }); const second = makeSnippet({ toolUseId: 'tool-2', filePath: '/repo/file.ts', oldString: 'c', newString: 'd', timestamp: '2026-03-01T10:01:00.000Z', }); const current = { memberName: 'alice', teamName: 'team-a', files: [ { ...makeFile('/repo/file.ts'), snippets: [first, second], }, ], totalFiles: 1, totalLinesAdded: 1, totalLinesRemoved: 1, }; const fresh = { ...current, files: [ { ...current.files[0], snippets: [second, first], }, ], }; hoisted.getAgentChanges.mockResolvedValueOnce(fresh); store.setState({ activeChangeSet: current, hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' }, changeSetEpoch: 0, fileContentVersionByPath: {}, }); await store.getState().applyReview('team-a', undefined, 'alice'); expect(hoisted.applyDecisions).not.toHaveBeenCalled(); expect(store.getState().activeChangeSet).toEqual(fresh); expect(store.getState().applyError).toBe( 'Changes have been updated since you started reviewing. Please re-review.' ); }); it('does not force re-review when only top-level file order changes', async () => { const store = createSliceStore(); const firstFile = makeFile('/repo/a.ts', { newString: 'after-a' }); const secondFile = makeFile('/repo/b.ts', { newString: 'after-b' }); const current = { memberName: 'alice', teamName: 'team-a', files: [firstFile, secondFile], totalFiles: 2, totalLinesAdded: firstFile.linesAdded + secondFile.linesAdded, totalLinesRemoved: firstFile.linesRemoved + secondFile.linesRemoved, }; const fresh = { ...current, files: [secondFile, firstFile], }; hoisted.getAgentChanges.mockResolvedValueOnce(fresh); hoisted.applyDecisions.mockResolvedValueOnce({ applied: 0, skipped: 0, conflicts: 0, errors: [], }); store.setState({ activeChangeSet: current, hunkDecisions: { '/repo/a.ts:0': 'rejected' }, fileDecisions: { '/repo/a.ts': 'rejected' }, changeSetEpoch: 0, fileContentVersionByPath: {}, }); await store.getState().applyReview('team-a', undefined, 'alice'); expect(store.getState().applyError).toBeNull(); expect(hoisted.applyDecisions).toHaveBeenCalledTimes(1); expect(hoisted.applyDecisions).toHaveBeenCalledWith({ teamName: 'team-a', taskId: undefined, memberName: 'alice', decisions: [ expect.objectContaining({ filePath: '/repo/a.ts', }), ], }); expect(store.getState().activeChangeSet).toEqual(current); }); it('does not force re-review when ledger provenance stays stable despite warning changes', async () => { const store = createSliceStore(); const current = { ...makeTaskChangeSet('task-ledger', '/repo/file.ts'), provenance: { sourceKind: 'ledger', sourceFingerprint: 'projected-fp-stable', }, warnings: [], }; const fresh = { ...current, computedAt: '2026-03-01T13:00:00.000Z', warnings: ['raw journal warning changed'], }; hoisted.getTaskChanges.mockResolvedValueOnce(fresh); hoisted.applyDecisions.mockResolvedValueOnce({ applied: 1, skipped: 0, conflicts: 0, errors: [], }); store.setState({ activeChangeSet: current, hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' }, fileChunkCounts: { '/repo/file.ts': 1 }, changeSetEpoch: 0, fileContentVersionByPath: {}, }); await store.getState().applyReview('team-a', 'task-ledger'); expect(store.getState().applyError).toBeNull(); expect(hoisted.applyDecisions).toHaveBeenCalledTimes(1); expect(store.getState().activeChangeSet).toEqual(current); }); it('forces re-review when ledger projected provenance changes with the same file paths', async () => { const store = createSliceStore(); const current = { ...makeTaskChangeSet('task-ledger', '/repo/file.ts'), provenance: { sourceKind: 'ledger', sourceFingerprint: 'projected-fp-v1', }, }; const fresh = { ...current, provenance: { sourceKind: 'ledger', sourceFingerprint: 'projected-fp-v2', }, }; hoisted.getTaskChanges.mockResolvedValueOnce(fresh); store.setState({ activeChangeSet: current, hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' }, fileChunkCounts: { '/repo/file.ts': 1 }, reviewUndoStack: [{ hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' } }], changeSetEpoch: 2, fileContentVersionByPath: { '/repo/file.ts': 3 }, }); await store.getState().applyReview('team-a', 'task-ledger'); expect(hoisted.applyDecisions).not.toHaveBeenCalled(); expect(store.getState().activeChangeSet).toEqual(fresh); expect(store.getState().applyError).toBe( 'Changes have been updated since you started reviewing. Please re-review.' ); expect(store.getState().hunkDecisions).toEqual({}); expect(store.getState().fileDecisions).toEqual({}); expect(store.getState().reviewUndoStack).toEqual([]); expect(store.getState().fileContentVersionByPath).toEqual({}); }); it('clears metadata-only decisions when ledger evidence upgrades to full text for the same changeKey', async () => { const store = createSliceStore(); const changeKey = 'path:/repo/file.ts'; const currentFile = { ...makeFile('/repo/file.ts'), changeKey, snippets: [], ledgerSummary: { latestOperation: 'modify', contentAvailability: 'metadata-only', reviewability: 'metadata-only', }, }; const freshFile = { ...makeFile('/repo/file.ts'), changeKey, ledgerSummary: { latestOperation: 'modify', contentAvailability: 'full-text', reviewability: 'full-text', beforeState: { exists: true, sha256: 'before-hash', sizeBytes: 6 }, afterState: { exists: true, sha256: 'after-hash', sizeBytes: 5 }, }, }; const current = { ...makeTaskChangeSet('task-ledger', '/repo/file.ts'), files: [currentFile], provenance: { sourceKind: 'ledger', sourceFingerprint: 'metadata-only-projection', }, }; const fresh = { ...current, files: [freshFile], provenance: { sourceKind: 'ledger', sourceFingerprint: 'snapshot-full-text-projection', }, }; hoisted.getTaskChanges.mockResolvedValueOnce(fresh); store.setState({ activeChangeSet: current, hunkDecisions: { [`${changeKey}:0`]: 'rejected' }, fileDecisions: { [changeKey]: 'rejected' }, hunkContextHashesByFile: { [changeKey]: { 0: 'metadata-only-context' } }, fileChunkCounts: { [changeKey]: 1 }, reviewUndoStack: [ { hunkDecisions: { [`${changeKey}:0`]: 'rejected' }, fileDecisions: { [changeKey]: 'rejected' }, }, ], changeSetEpoch: 4, fileContentVersionByPath: { '/repo/file.ts': 2 }, }); await store.getState().applyReview('team-a', 'task-ledger'); expect(hoisted.applyDecisions).not.toHaveBeenCalled(); expect(store.getState().activeChangeSet).toEqual(fresh); expect(store.getState().fileDecisions).toEqual({}); expect(store.getState().hunkDecisions).toEqual({}); expect(store.getState().hunkContextHashesByFile).toEqual({}); expect(store.getState().reviewUndoStack).toEqual([]); expect(store.getState().fileContentVersionByPath).toEqual({}); }); });