diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index b018a59b..07c33f30 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -45,6 +45,11 @@ import { normalizeTeamGetDataOptions, } from '../team/teamDataRequestKeys'; import { selectTeamDataForName } from '../team/teamDataSelectors'; +import { + mapReviewError, + mapSendMessageError, + shouldInvalidateCachedTeamDataForError, +} from '../team/teamErrorPolicies'; import { captureTeamLocalStateEpoch, clearAllTeamLocalStateEpochs, @@ -1202,24 +1207,6 @@ function preserveKnownTaskChangePresence( return changed ? mergedTasks : nextTasks; } -function mapSendMessageError(error: unknown): string { - const message = - error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; - if (message.includes('Failed to verify inbox write')) { - return 'Message was written but not verified (race). Please try again.'; - } - return message || 'Failed to send message'; -} - -function mapReviewError(error: unknown): string { - const message = - error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; - if (message.includes('Task status update verification failed')) { - return 'Failed to update task status (possible agent conflict).'; - } - return message || 'Failed to perform review action'; -} - export interface GlobalTaskDetailState { teamName: string; taskId: string; @@ -1943,15 +1930,6 @@ function isVisibleInActiveTeamSurface( }); } -function shouldInvalidateCachedTeamDataForError(teamName: string, message: string): boolean { - return ( - message === 'TEAM_DRAFT' || - message.includes('TEAM_DRAFT') || - message === `Team not found: ${teamName}` || - message === 'Team config not found' - ); -} - export interface TeamSlice { teams: TeamSummary[]; /** O(1) lookup to avoid array scans in render-hot paths */ diff --git a/src/renderer/store/team/teamErrorPolicies.ts b/src/renderer/store/team/teamErrorPolicies.ts new file mode 100644 index 00000000..51695967 --- /dev/null +++ b/src/renderer/store/team/teamErrorPolicies.ts @@ -0,0 +1,33 @@ +import { IpcError } from '@renderer/utils/unwrapIpc'; + +function getErrorMessage(error: unknown): string { + return error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; +} + +export function mapSendMessageError(error: unknown): string { + const message = getErrorMessage(error); + if (message.includes('Failed to verify inbox write')) { + return 'Message was written but not verified (race). Please try again.'; + } + return message || 'Failed to send message'; +} + +export function mapReviewError(error: unknown): string { + const message = getErrorMessage(error); + if (message.includes('Task status update verification failed')) { + return 'Failed to update task status (possible agent conflict).'; + } + return message || 'Failed to perform review action'; +} + +export function shouldInvalidateCachedTeamDataForError( + teamName: string, + message: string +): boolean { + return ( + message === 'TEAM_DRAFT' || + message.includes('TEAM_DRAFT') || + message === `Team not found: ${teamName}` || + message === 'Team config not found' + ); +} diff --git a/test/renderer/store/teamErrorPolicies.test.ts b/test/renderer/store/teamErrorPolicies.test.ts new file mode 100644 index 00000000..df8c670f --- /dev/null +++ b/test/renderer/store/teamErrorPolicies.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + mapReviewError, + mapSendMessageError, + shouldInvalidateCachedTeamDataForError, +} from '../../../src/renderer/store/team/teamErrorPolicies'; +import { IpcError } from '../../../src/renderer/utils/unwrapIpc'; + +describe('teamErrorPolicies', () => { + it('maps send-message verification races to the user-facing retry copy', () => { + expect(mapSendMessageError(new Error('Failed to verify inbox write for message-1'))).toBe( + 'Message was written but not verified (race). Please try again.' + ); + expect( + mapSendMessageError( + new IpcError('team:sendMessage', 'Failed to verify inbox write after timeout') + ) + ).toBe('Message was written but not verified (race). Please try again.'); + }); + + it('maps send-message errors to original messages or fallback copy', () => { + expect(mapSendMessageError(new Error('Transport failed'))).toBe('Transport failed'); + expect(mapSendMessageError('plain failure')).toBe('Failed to send message'); + expect(mapSendMessageError(null)).toBe('Failed to send message'); + }); + + it('maps review verification conflicts to the user-facing conflict copy', () => { + expect(mapReviewError(new Error('Task status update verification failed for task-1'))).toBe( + 'Failed to update task status (possible agent conflict).' + ); + expect( + mapReviewError( + new IpcError('team:updateKanban', 'Task status update verification failed after retry') + ) + ).toBe('Failed to update task status (possible agent conflict).'); + }); + + it('maps review errors to original messages or fallback copy', () => { + expect(mapReviewError(new Error('Review failed'))).toBe('Review failed'); + expect(mapReviewError({ message: 'ignored non-error shape' })).toBe( + 'Failed to perform review action' + ); + expect(mapReviewError(undefined)).toBe('Failed to perform review action'); + }); + + it('invalidates cached team data for draft and missing-team errors', () => { + expect(shouldInvalidateCachedTeamDataForError('my-team', 'TEAM_DRAFT')).toBe(true); + expect( + shouldInvalidateCachedTeamDataForError('my-team', 'Cannot read team: TEAM_DRAFT') + ).toBe(true); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: my-team')).toBe(true); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config not found')).toBe(true); + }); + + it('does not invalidate cached team data for unrelated or other-team errors', () => { + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Network timeout')).toBe(false); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team not found: other-team')).toBe( + false + ); + expect(shouldInvalidateCachedTeamDataForError('my-team', 'Team config missing')).toBe(false); + }); +});