diff --git a/cli/src/codex/conversationHistory.test.ts b/cli/src/codex/conversationHistory.test.ts index 8bd6ee4f..c3041afe 100644 --- a/cli/src/codex/conversationHistory.test.ts +++ b/cli/src/codex/conversationHistory.test.ts @@ -4,7 +4,7 @@ import { CodexConversationHistory } from './conversationHistory' function createClient(overrides?: { fork?: (params: Record) => Promise<{ thread: { id: string } }> rollback?: (params: { threadId: string; numTurns: number }) => Promise - read?: () => Promise<{ thread: { id: string; turns: Array> } }> + read?: () => Promise<{ thread: { id: string; turns?: Array> } }> }) { return { supportsMethod: async () => true, @@ -90,6 +90,346 @@ describe('CodexConversationHistory', () => { expect(rollback).toHaveBeenCalledTimes(1) }) + it('returns a safe-Fork code when the selected message starts a steered turn', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { + id: 'turn-b', + items: [ + { type: 'userMessage', clientId: 'local-b' }, + { type: 'userMessage', clientId: 'local-steer' } + ] + } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + await history.probeCapabilities() + + const result = await history.rewind('local-b') + + expect(result).toMatchObject({ + success: false, + outcome: 'rejected', + code: 'ambiguous_native_boundary_fork_safe' + }) + if (result.success) throw new Error('Expected rewind to be rejected') + expect(result.error).toContain('ambiguous') + expect(rollback).not.toHaveBeenCalled() + }) + + it('does not offer the Fork fallback when historical fork is unsupported', async () => { + const rollback = vi.fn(async () => ({})) + const supportsMethod = vi.fn(async (method: string) => method === 'thread/rollback') + const history = new CodexConversationHistory(() => ({ + ...createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { + id: 'turn-b', + items: [ + { type: 'userMessage', clientId: 'local-b' }, + { type: 'userMessage', clientId: 'local-steer' } + ] + } + ] + } + }) + }), + supportsMethod + }) as never) + history.setThreadId('thread-1') + await history.probeCapabilities() + + const result = await history.rewind('local-b') + + expect(result).toMatchObject({ + success: false, + outcome: 'rejected', + code: 'ambiguous_native_boundary' + }) + expect(history.getCapabilityStates()).toMatchObject({ + forkAtMessage: 'unsupported', + rewindToMessage: 'supported' + }) + expect(rollback).not.toHaveBeenCalled() + }) + + it('does not offer the Fork fallback when an earlier user message has no client id', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { + id: 'turn-b', + items: [ + { type: 'userMessage' }, + { type: 'userMessage', clientId: 'local-b' } + ] + } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + await history.probeCapabilities() + + const result = await history.rewind('local-b') + + expect(result).toMatchObject({ + success: false, + outcome: 'rejected', + code: 'ambiguous_native_boundary' + }) + expect(rollback).not.toHaveBeenCalled() + }) + + it('does not offer the Fork fallback for the second message in a steered turn', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { + id: 'turn-b', + items: [ + { type: 'userMessage', clientId: 'local-b' }, + { type: 'userMessage', clientId: 'local-steer' } + ] + } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + history.restoreTurns({ localSteer: 'turn-b' }) + + const result = await history.rewind('local-steer') + + expect(result).toMatchObject({ + success: false, + outcome: 'rejected', + code: 'ambiguous_native_boundary' + }) + expect(rollback).not.toHaveBeenCalled() + }) + + it('rejects rewind before native mutation when compaction is present', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { + id: 'turn-b', + items: [ + { type: 'userMessage', clientId: 'local-b' }, + { type: 'contextCompaction' } + ] + } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + + const result = await history.rewind('local-b') + + expect(result).toMatchObject({ success: false, outcome: 'rejected' }) + if (result.success) throw new Error('Expected rewind to be rejected') + expect(result.error).toContain('ambiguous') + expect(rollback).not.toHaveBeenCalled() + }) + + it('rejects rewind before native mutation when native items are incomplete', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { id: 'turn-b' } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + history.restoreTurns({ localB: 'turn-b' }) + + const result = await history.rewind('localB') + + expect(result).toMatchObject({ success: false, outcome: 'rejected' }) + if (result.success) throw new Error('Expected rewind to be rejected') + expect(result.error).toContain('ambiguous') + expect(rollback).not.toHaveBeenCalled() + }) + + it('rejects rewind before native mutation when a user message has no client id', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { id: 'turn-b', items: [{ type: 'userMessage' }] } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + history.restoreTurns({ localB: 'turn-b' }) + + const result = await history.rewind('localB') + + expect(result).toMatchObject({ success: false, outcome: 'rejected' }) + if (result.success) throw new Error('Expected rewind to be rejected') + expect(result.error).toContain('ambiguous') + expect(rollback).not.toHaveBeenCalled() + }) + + it('rejects rewind before native mutation when a native turn has no id', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { items: [{ type: 'assistantMessage' }] }, + { id: 'turn-c', items: [{ type: 'userMessage', clientId: 'local-c' }] } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + + const result = await history.rewind('local-a') + + expect(result).toMatchObject({ success: false, outcome: 'rejected' }) + if (result.success) throw new Error('Expected rewind to be rejected') + expect(result.error).toContain('ambiguous') + expect(rollback).not.toHaveBeenCalled() + }) + + it('returns a deterministic rejection when the selected native turn cannot be resolved', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ thread: { id: 'thread-1' } }) + }) as never) + history.setThreadId('thread-1') + history.restoreTurns({ localB: 'turn-b' }) + + const result = await history.rewind('localB') + + expect(result).toMatchObject({ + success: false, + code: 'ambiguous_native_boundary', + outcome: 'rejected' + }) + if (result.success) throw new Error('Expected rewind to be rejected') + expect(result.error).toContain('ambiguous') + expect(rollback).not.toHaveBeenCalled() + }) + + it('rejects rewind before native mutation when a native item is malformed', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { id: 'turn-b', items: [{}] } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + + const result = await history.rewind('local-a') + + expect(result).toMatchObject({ success: false, outcome: 'rejected' }) + if (result.success) throw new Error('Expected rewind to be rejected') + expect(result.error).toContain('ambiguous') + expect(rollback).not.toHaveBeenCalled() + }) + + it('rejects rewind before native mutation when native turn ids are duplicated', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-b' }] } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + + const result = await history.rewind('local-a') + + expect(result).toMatchObject({ success: false, outcome: 'rejected' }) + if (result.success) throw new Error('Expected rewind to be rejected') + expect(result.error).toContain('ambiguous') + expect(rollback).not.toHaveBeenCalled() + }) + + it('rejects rewind before native mutation when client ids are duplicated', async () => { + const rollback = vi.fn(async () => ({})) + const history = new CodexConversationHistory(() => createClient({ + rollback, + read: async () => ({ + thread: { + id: 'thread-1', + turns: [ + { id: 'turn-a', items: [{ type: 'userMessage', clientId: 'local-a' }] }, + { id: 'turn-b', items: [{ type: 'userMessage', clientId: 'local-a' }] } + ] + } + }) + }) as never) + history.setThreadId('thread-1') + + const result = await history.rewind('local-a') + + expect(result).toMatchObject({ success: false, outcome: 'rejected' }) + if (result.success) throw new Error('Expected rewind to be rejected') + expect(result.error).toContain('ambiguous') + expect(rollback).not.toHaveBeenCalled() + }) + it('marks rewind unsupported on method-not-found without affecting fork', async () => { const rollback = vi.fn(async () => { throw new Error('thread/rollback is unsupported') diff --git a/cli/src/codex/conversationHistory.ts b/cli/src/codex/conversationHistory.ts index 1cc9580a..72d2b9b0 100644 --- a/cli/src/codex/conversationHistory.ts +++ b/cli/src/codex/conversationHistory.ts @@ -29,11 +29,17 @@ function isMethodNotFound(error: unknown): boolean { } type TurnInfo = { - id: string + id: string | null status?: string clientIds: string[] + userMessageCount: number + hasCompleteItems: boolean + hasContextCompaction: boolean } +const AMBIGUOUS_REWIND_ERROR = + 'Rewind is unavailable for this Codex history because native turn boundaries are ambiguous (steering, compaction, or incomplete history)' + export class CodexConversationHistory { private states: ConversationHistoryCapabilityStates = { ...CODEX_CONVERSATION_HISTORY_INITIAL } private threadId: string | null = null @@ -127,9 +133,13 @@ export class CodexConversationHistory { // Prefer stable inclusive lastTurnId of the previous turn. The first // turn has no predecessor, so fall back to experimental beforeTurnId // (exclusive) for that single boundary. + const previousTurnId = selectedIndex > 0 ? turns[selectedIndex - 1]?.id : null + if (selectedIndex > 0 && !previousTurnId) { + throw new Error(AMBIGUOUS_REWIND_ERROR) + } const boundary = selectedIndex === 0 ? { beforeTurnId: selectedTurnId } - : { lastTurnId: turns[selectedIndex - 1]!.id } + : { lastTurnId: previousTurnId! } try { const response = await client.forkThread({ threadId, @@ -179,12 +189,57 @@ export class CodexConversationHistory { } const turns = await this.listTurns() - const turnId = await this.resolveTurnId(messageLocalId, turns) - const index = turns.findIndex((turn) => turn.id === turnId) - if (index < 0) throw new Error('Selected turn not found') + const turnId = await this.resolveTurnId(messageLocalId, turns).catch(() => null) + const index = turnId ? turns.findIndex((turn) => turn.id === turnId) : -1 + if (index < 0) { + return { + success: false, + error: AMBIGUOUS_REWIND_ERROR, + code: 'ambiguous_native_boundary', + outcome: 'rejected' + } + } if (turns[index]?.status === 'inProgress' || turns[index]?.status === 'in_progress') { throw new Error('Cannot rewind an in-progress turn') } + + // thread/rollback takes a raw native-turn count, while the Web transcript + // exposes user-message boundaries. Only use that count when every native + // turn is known to contain exactly one identified user message. Steering, + // compaction, and incomplete item data can otherwise leave the model + // context out of sync with the transcript after a successful rollback. + const seenTurnIds = new Set() + const seenClientIds = new Set() + const hasAmbiguousBoundary = turns.some((turn) => { + const clientId = turn.clientIds[0] + if ( + !turn.id || + !turn.hasCompleteItems || + turn.userMessageCount !== 1 || + turn.clientIds.length !== 1 || + !clientId || + turn.hasContextCompaction || + seenTurnIds.has(turn.id) || + seenClientIds.has(clientId) + ) { + return true + } + seenTurnIds.add(turn.id) + seenClientIds.add(clientId) + return false + }) + if (hasAmbiguousBoundary) { + return { + success: false, + error: AMBIGUOUS_REWIND_ERROR, + code: this.states.forkAtMessage === 'supported' + && this.isForkFallbackSafe(messageLocalId, turns, index) + ? 'ambiguous_native_boundary_fork_safe' + : 'ambiguous_native_boundary', + outcome: 'rejected' + } + } + const numTurns = turns.length - index if (numTurns <= 0) throw new Error('Invalid rewind count') @@ -216,7 +271,7 @@ export class CodexConversationHistory { const list = turns ?? await this.listTurns() for (const turn of list) { - if (turn.clientIds.includes(localId)) { + if (turn.id && turn.clientIds.includes(localId)) { this.turnByLocalId.set(localId, turn.id) return turn.id } @@ -224,6 +279,61 @@ export class CodexConversationHistory { throw new Error(`No native history point for message ${localId}`) } + private isForkFallbackSafe(messageLocalId: string, turns: TurnInfo[], index: number): boolean { + const selected = turns[index] + if ( + !selected + || !selected.id + || !selected.hasCompleteItems + || selected.hasContextCompaction + || selected.userMessageCount !== selected.clientIds.length + || selected.clientIds[0] !== messageLocalId + ) { + return false + } + + if (turns.flatMap((turn) => turn.clientIds).filter((clientId) => clientId === messageLocalId).length !== 1) { + return false + } + if (turns.filter((turn) => turn.id === selected.id).length !== 1) { + return false + } + + const previousId = index > 0 ? turns[index - 1]?.id : null + if (index > 0 && !previousId) { + return false + } + if (previousId && turns.filter((turn) => turn.id === previousId).length !== 1) { + return false + } + + // The child transcript excludes the selected HAPI message. Native Fork + // excludes the whole selected native turn, so the selected message must + // be the first user item in that turn. Also require the retained prefix + // to be unambiguous; otherwise the child could inherit a different + // projection even though this particular boundary is exact. + const retainedTurnIds = new Set() + const retainedClientIds = new Set() + for (const turn of turns.slice(0, index)) { + const clientId = turn.clientIds[0] + if ( + !turn.id + || !turn.hasCompleteItems + || turn.hasContextCompaction + || turn.userMessageCount !== 1 + || turn.clientIds.length !== 1 + || !clientId + || retainedTurnIds.has(turn.id) + || retainedClientIds.has(clientId) + ) { + return false + } + retainedTurnIds.add(turn.id) + retainedClientIds.add(clientId) + } + return true + } + private async listTurns(): Promise { const client = this.getClient() const threadId = this.threadId @@ -233,16 +343,46 @@ export class CodexConversationHistory { const response = await client.readThread({ threadId, includeTurns: true }) const thread = asRecord(response.thread) const turns = Array.isArray(thread?.turns) ? thread.turns : [] - return turns.flatMap((entry) => { + return turns.flatMap((entry): TurnInfo[] => { const record = asRecord(entry) const id = asString(record?.id) - if (!id) return [] + if (!record || !id) { + return [{ + id: null, + status: asString(record?.status) ?? undefined, + clientIds: [], + userMessageCount: 0, + hasCompleteItems: false, + hasContextCompaction: false + }] + } const clientIds: string[] = [] - const items = Array.isArray(record?.items) ? record.items : [] + const items = Array.isArray(record?.items) ? record.items : null + if (!items) { + return [{ + id, + status: asString(record?.status) ?? undefined, + clientIds, + userMessageCount: 0, + hasCompleteItems: false, + hasContextCompaction: false + }] + } + let userMessageCount = 0 + let hasCompleteItems = true + let hasContextCompaction = false for (const item of items) { const itemRecord = asRecord(item) const type = asString(itemRecord?.type) ?? asString(itemRecord?.itemType) + if (!itemRecord || !type) { + hasCompleteItems = false + continue + } + if (type === 'contextCompaction' || type === 'context_compaction') { + hasContextCompaction = true + } if (type === 'userMessage' || type === 'user_message') { + userMessageCount += 1 const clientId = asString(itemRecord?.clientId) ?? asString(itemRecord?.client_id) if (clientId) clientIds.push(clientId) } @@ -250,7 +390,10 @@ export class CodexConversationHistory { return [{ id, status: asString(record?.status) ?? undefined, - clientIds + clientIds, + userMessageCount, + hasCompleteItems, + hasContextCompaction }] }) } catch (error) { @@ -258,7 +401,10 @@ export class CodexConversationHistory { // Fall back to in-memory mapping only return Array.from(this.turnByLocalId.entries()).map(([localId, id]) => ({ id, - clientIds: [localId] + clientIds: [localId], + userMessageCount: 1, + hasCompleteItems: false, + hasContextCompaction: false })) } } diff --git a/hub/src/sync/conversationHistoryPi.test.ts b/hub/src/sync/conversationHistoryPi.test.ts index c27aafa2..bfb55717 100644 --- a/hub/src/sync/conversationHistoryPi.test.ts +++ b/hub/src/sync/conversationHistoryPi.test.ts @@ -22,12 +22,13 @@ describe('Pi conversation-history hub integration', () => { store.messages.markMessagesInvoked(session.id, ['local-boundary'], Date.now()) ;(engine as any).rpcGateway.rewindConversation = async () => ({ success: false, - error: 'Pi rewind was cancelled', - outcome: 'cancelled', + error: 'Codex native boundary is ambiguous', + code: 'ambiguous_native_boundary', + outcome: 'rejected', }) await expect(engine.rewindConversation(session.id, 'default', 'local-boundary')).resolves.toEqual({ - type: 'error', message: 'Pi rewind was cancelled', + type: 'error', message: 'Codex native boundary is ambiguous', code: 'ambiguous_native_boundary', }) expect(engine.getSession(session.id)?.metadata?.conversationHistoryDiverged).not.toBe(true) } finally { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index baaf403a..8a7ef17e 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -12,7 +12,7 @@ import { cliBinaryUpdatedOnDisk, isMachineCapabilitySkewed, } from '@hapi/protocol/runnerCapabilities' -import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes' +import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, RewindConversationErrorCode, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { SteerQueuedMessageResponse } from '@hapi/protocol/schemas' import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' @@ -1582,7 +1582,7 @@ export class SyncEngine { sessionId: string, namespace: string, messageLocalId: string - ): Promise<{ type: 'success' } | { type: 'error'; message: string; hydrateFailed?: boolean }> { + ): Promise<{ type: 'success' } | { type: 'error'; message: string; code?: RewindConversationErrorCode; hydrateFailed?: boolean }> { if (this.historyActionsInFlight.has(sessionId)) { return { type: 'error', message: 'Conversation history action already in progress' } } @@ -1598,7 +1598,7 @@ export class SyncEngine { sessionId: string, namespace: string, messageLocalId: string - ): Promise<{ type: 'success' } | { type: 'error'; message: string; hydrateFailed?: boolean }> { + ): Promise<{ type: 'success' } | { type: 'error'; message: string; code?: RewindConversationErrorCode; hydrateFailed?: boolean }> { const access = this.resolveSessionAccess(sessionId, namespace) if (!access.ok) { return { type: 'error', message: access.reason === 'not-found' ? 'Session not found' : 'Access denied' } @@ -1634,7 +1634,11 @@ export class SyncEngine { } if (rpcResult?.success !== true) { - return { type: 'error', message: rpcResult?.error ?? 'Native rewind failed' } + return { + type: 'error', + message: rpcResult?.error ?? 'Native rewind failed', + ...(rpcResult?.success === false && rpcResult.code ? { code: rpcResult.code } : {}) + } } try { diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index d690e052..48d94625 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -1492,6 +1492,37 @@ describe('sessions routes', () => { expect(calls).toEqual([{ sessionId: 'session-1', messageLocalId: 'local-2' }]) }) + it('returns a structured ambiguous-boundary code for deterministic rewind rejection', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + capabilities: { conversationHistory: { rewindToMessage: true } } + } + }) + const { app } = createApp(session, { + rewindConversation: async () => ({ + type: 'error', + message: 'Rewind is unavailable for this Codex history', + code: 'ambiguous_native_boundary_fork_safe' + }) + }) + + const response = await app.request('/api/sessions/session-1/rewind', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ messageLocalId: 'local-2' }) + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Rewind is unavailable for this Codex history', + code: 'ambiguous_native_boundary_fork_safe', + hydrateFailed: false + }) + }) + it('rejects rewind without messageLocalId', async () => { const { app } = createApp(createSession()) const response = await app.request('/api/sessions/session-1/rewind', { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 44238c9e..d1dc8207 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -424,6 +424,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (result.type === 'error') { return c.json({ error: result.message, + code: result.code, hydrateFailed: result.hydrateFailed === true }, result.hydrateFailed ? 500 : 409) } diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index f0c0d132..50fd75b3 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -575,6 +575,10 @@ export type ForkConversationRpcResult = { forkSession?: boolean } +export type RewindConversationErrorCode = + | 'ambiguous_native_boundary' + | 'ambiguous_native_boundary_fork_safe' + export type RewindConversationRpcResult = { success: true /** Truncate HAPI transcript at/after this localId, then accept rehydrated history. */ @@ -588,6 +592,7 @@ export type RewindConversationRpcResult = { } | { success: false error: string + code?: RewindConversationErrorCode /** Native state is unchanged, cancelled, or was restored exactly. */ outcome: 'rejected' | 'cancelled' | 'source_restored' } diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 15ff856f..c30080d8 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -57,6 +57,25 @@ describe('ApiClient error mapping', () => { } }) + it('preserves the structured ambiguous-boundary code for Rewind fallbacks', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'Rewind is unavailable for this Codex history', + code: 'ambiguous_native_boundary_fork_safe', + hydrateFailed: false + }), + { status: 409, statusText: 'Conflict' } + ) + ) + + const api = new ApiClient('test-token') + await expect(api.rewindConversation('session-1', 'local-1')).rejects.toMatchObject({ + status: 409, + code: 'ambiguous_native_boundary_fork_safe' + }) + }) + it('passes the 422 missing-metadata body through unchanged so the UI can show the missing fields', async () => { fetchMock.mockResolvedValueOnce( new Response( diff --git a/web/src/components/SessionChat.test.ts b/web/src/components/SessionChat.test.ts index a4e54a12..cc653a3b 100644 --- a/web/src/components/SessionChat.test.ts +++ b/web/src/components/SessionChat.test.ts @@ -11,10 +11,31 @@ import { resolveLatestCompletedBoundaryIdForView, shouldAutoClearPendingSchedule, shouldRouteToScratchlist, + isRewindForkFallbackError, } from './SessionChat' +import { ApiError } from '@/api/client' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' import type { AttachmentMetadata, DecryptedMessage } from '@/types/api' +describe('isRewindForkFallbackError', () => { + it('recognizes the structured safe-Fork boundary code', () => { + expect(isRewindForkFallbackError(new ApiError( + 'native boundary is ambiguous', + 409, + 'ambiguous_native_boundary_fork_safe' + ))).toBe(true) + }) + + it('does not classify unsafe or message-only errors as fallback candidates', () => { + expect(isRewindForkFallbackError(new ApiError( + 'native boundary is ambiguous', + 409, + 'ambiguous_native_boundary' + ))).toBe(false) + expect(isRewindForkFallbackError(new Error('ambiguous native boundary'))).toBe(false) + }) +}) + describe('applyModelChangeWithReasoningRollback', () => { it('restores the previous effort when the model switch fails after clearing it', async () => { const modelError = new Error('model switch failed') diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 500a4b7c..94b64050 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -4,7 +4,7 @@ import { useNavigate } from '@tanstack/react-router' import { PRESERVE_SESSION_SIDEBAR_SCROLL } from '@/lib/sessionNavigation' import { AssistantRuntimeProvider, useAui, useAuiState } from '@assistant-ui/react' import { DragDropZone } from '@/components/AssistantChat/DragDropZone' -import type { ApiClient } from '@/api/client' +import { ApiError, type ApiClient } from '@/api/client' import type { AttachmentMetadata, CodexCollaborationMode, @@ -110,11 +110,16 @@ import { usePiModels } from '@/hooks/queries/usePiModels' import { useOpencodeReasoningEffortOptions } from '@/hooks/queries/useOpencodeReasoningEffortOptions' import { useVoiceOptional } from '@/lib/voice-context' import { AgentTerminalView } from '@/components/AgentTerminal/AgentTerminalView' +import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' import { isRemoteTerminalSupported } from '@/utils/terminalSupport' type SessionModelSelection = { provider: string; modelId: string } | string | null +export function isRewindForkFallbackError(error: unknown): boolean { + return error instanceof ApiError && error.code === 'ambiguous_native_boundary_fork_safe' +} + export function resolvePiContextWindow( models: PiModelSummary[] | undefined, selectedModel: { provider: string; modelId: string } | null | undefined, @@ -569,6 +574,7 @@ function SessionChatInner(props: SessionChatProps) { const { codexExplorationCollapsed } = useCodexExplorationCollapse() const navigate = useNavigate() const [historyActionPending, setHistoryActionPending] = useState(false) + const [rewindForkFallback, setRewindForkFallback] = useState(null) const onForkConversation = useCallback(async (messageLocalId?: string) => { setHistoryActionPending(true) @@ -589,10 +595,22 @@ function SessionChatInner(props: SessionChatProps) { try { await props.api.rewindConversation(props.session.id, messageLocalId) props.onRefresh() + } catch (error) { + if (isRewindForkFallbackError(error)) { + setRewindForkFallback(messageLocalId) + return + } + throw error } finally { setHistoryActionPending(false) } }, [props.api, props.onRefresh, props.session.id]) + + const onRewindForkFallback = useCallback(async () => { + if (!rewindForkFallback) return + await onForkConversation(rewindForkFallback) + setRewindForkFallback(null) + }, [onForkConversation, rewindForkFallback]) const sessionInactive = !props.session.active const inactiveCanResume = inactiveSessionCanResume( props.session, @@ -2086,6 +2104,19 @@ function SessionChatInner(props: SessionChatProps) { onReadyChange={setVoiceBackendReady} /> )} + + { + if (!historyActionPending) setRewindForkFallback(null) + }} + title={t('message.rewind.fallbackTitle')} + description={t('message.rewind.fallbackDescription')} + confirmLabel={t('message.rewind.fallbackFork')} + confirmingLabel={t('message.rewind.fallbackForking')} + isPending={historyActionPending} + onConfirm={onRewindForkFallback} + /> ) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 2cccf284..7d717af6 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -19,6 +19,10 @@ export default { 'message.rewind.confirmTitle': 'Rewind conversation', 'message.rewind.confirmDescription': 'Rewind this session to this point?\nLater conversation history will be permanently removed. Files will not be changed.', 'message.rewind.confirming': 'Rewinding…', + 'message.rewind.fallbackTitle': 'Rewind unavailable', + 'message.rewind.fallbackDescription': 'HAPI did not change this session because its native Codex boundary is ambiguous.\nCreate a new session before this point instead? The current session will not be changed.', + 'message.rewind.fallbackFork': 'Fork here', + 'message.rewind.fallbackForking': 'Forking…', 'message.shareTurn': 'Share turn as image', 'shareTurn.title': 'Share turn as image', 'shareTurn.badge': 'Shared turn', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2c2aea59..d2a9544e 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -19,6 +19,10 @@ export default { 'message.rewind.confirmTitle': '回退对话', 'message.rewind.confirmDescription': '将此会话回退到此处?\n之后的对话历史将永久移除。文件不会被修改。', 'message.rewind.confirming': '回退中…', + 'message.rewind.fallbackTitle': '暂时无法回退', + 'message.rewind.fallbackDescription': '由于 Codex 原生对话边界存在歧义,HAPI 没有修改当前会话。\n是否改为在此处创建新会话?当前会话不会被修改。', + 'message.rewind.fallbackFork': '在此处分叉', + 'message.rewind.fallbackForking': '分叉中…', 'message.shareTurn': '将本轮对话分享为图片', 'shareTurn.title': '将本轮对话分享为图片', 'shareTurn.badge': '分享会话',