diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.deep-cut.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.deep-cut.test.tsx new file mode 100644 index 0000000000..e76169a88a --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.deep-cut.test.tsx @@ -0,0 +1,330 @@ +import { JsonRpcGatewayError } from '@hermes/shared' +import { cleanup } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { textPart } from '@/lib/chat-messages' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $confirmRequest, settleConfirm } from '@/store/confirm' +import { clearNotifications } from '@/store/notifications' +import { $busy, setMessages } from '@/store/session' +import { dropSessionState, publishSessionState } from '@/store/session-states' + +import { actRender, Harness, type HarnessHandle, RUNTIME_SESSION_ID } from './prompt-actions-harness' + +// Deep-cut confirms (#133716): regenerate/edit that would archive later user turns. + +vi.mock('@/hermes', () => ({ + getLatestSessionMessages: vi.fn(async () => ({ messages: [], session_id: 'session' })), + getProfiles: vi.fn(async () => ({ profiles: [] })), + getSession: vi.fn(), + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000, + setApiRequestProfile: vi.fn(), + transcribeAudio: vi.fn() +})) + +const RUNTIME_SESSION_B = 'rt-session-b-current' + +type GatewayMock = Parameters[0]['requestGateway'] & { mock: { calls: unknown[][] } } + +describe('usePromptActions deep-cut confirm across a session switch (#133716)', () => { + afterEach(() => { + cleanup() + clearNotifications() + setMessages([]) + $busy.set(false) + }) + + it('drops a 4033 confirm answered after the user switched sessions (#133716)', async () => { + const submits: Record[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'prompt.submit') { + submits.push(params ?? {}) + + if (!params?.confirm_deep_truncate) { + throw new JsonRpcGatewayError('truncation would archive later user turns', { code: 4033 }) + } + } + + return {} as never + }) as unknown as GatewayMock + + const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } + setMessages([ + { id: 'u1', parts: [textPart('prompt A')], role: 'user', timestamp: 0 }, + { id: 'a1', parts: [textPart('reply')], role: 'assistant', timestamp: 1 } + ] as never) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={{ current: null }} + /> + ) + + // Session A's tail regenerate is refused; the user switches to B, then accepts. + const stopConfirming = $confirmRequest.listen(request => { + if (request) { + activeSessionIdRef.current = RUNTIME_SESSION_B + settleConfirm(true) + } + }) + + await handle!.reloadFromMessage(null) + stopConfirming() + + expect(submits).toHaveLength(1) + expect(submits[0]).toMatchObject({ session_id: RUNTIME_SESSION_ID }) + }) +}) + +describe('usePromptActions deep edit confirmed while output streams (#133716)', () => { + afterEach(() => { + cleanup() + clearNotifications() + setMessages([]) + $busy.set(false) + }) + + it('a confirmed deep edit whose turn gained a later user turn during the dialog sends nothing', async () => { + const seed = [ + { id: 'u1', parts: [textPart('first')], role: 'user' as const, rowId: 11, timestamp: 0 }, + { id: 'a1', parts: [textPart('reply')], role: 'assistant' as const, timestamp: 1 }, + { id: 'u2', parts: [textPart('later')], role: 'user' as const, rowId: 13, timestamp: 2 } + ] + + setMessages(seed as never) + const submits: Record[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'prompt.submit') { + submits.push(params ?? {}) + } + + return {} as never + }) + + let handle: HarnessHandle | undefined + + await actRender( + { + handle = h + }} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={seed} + /> + ) + + const stopConfirming = $confirmRequest.listen(request => { + if (request) { + setMessages([ + ...seed, + { id: 'u3', parts: [textPart('unseen')], role: 'user', rowId: 15, timestamp: 3 } + ] as never) + settleConfirm(true) + } + }) + + await handle!.editMessage({ + content: [{ text: 'edited first', type: 'text' }], + parentId: null, + role: 'user', + sourceId: 'u1' + } as never) + stopConfirming() + + expect(submits).toEqual([]) + }) + + it('re-aims a confirmed deep edit at the grown transcript instead of dropping it', async () => { + const seed = [ + { id: 'u1', parts: [textPart('first')], role: 'user' as const, rowId: 11, timestamp: 0 }, + { id: 'a1', parts: [textPart('reply')], role: 'assistant' as const, timestamp: 1 }, + { id: 'u2', parts: [textPart('later')], role: 'user' as const, rowId: 13, timestamp: 2 }, + { id: 'a2', parts: [textPart('later reply')], role: 'assistant' as const, timestamp: 3 } + ] + + setMessages(seed as never) + const submits: Record[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'prompt.submit') { + submits.push(params ?? {}) + } + + return {} as never + }) + + let handle: HarnessHandle | undefined + + await actRender( + { + handle = h + }} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={seed} + /> + ) + + // A stream delta replaces the transcript array while the user reads the dialog. + const stopConfirming = $confirmRequest.listen(request => { + if (request) { + setMessages([...seed, { id: 'a2b', parts: [textPart('more')], role: 'assistant', timestamp: 4 }] as never) + settleConfirm(true) + } + }) + + await handle!.editMessage({ + content: [{ text: 'edited first', type: 'text' }], + parentId: null, + role: 'user', + sourceId: 'u1' + } as never) + stopConfirming() + + expect(submits).toHaveLength(1) + expect(submits[0]).toMatchObject({ confirm_deep_truncate: true, truncate_before_row_id: 11, text: 'edited first' }) + }) +}) + +describe('usePromptActions deep-edit consent scope (#133716)', () => { + afterEach(() => { + cleanup() + clearNotifications() + setMessages([]) + $busy.set(false) + dropSessionState(RUNTIME_SESSION_ID) + dropSessionState('other-runtime') + }) + + it('a stale-target retry whose refresh shows a new later turn does not reuse the deep confirm', async () => { + const seed = [ + { id: 'u1', parts: [textPart('first')], role: 'user', rowId: 11, timestamp: 0 }, + { id: 'a1', parts: [textPart('reply')], role: 'assistant', timestamp: 1 }, + { id: 'u2', parts: [textPart('second')], role: 'user', rowId: 13, timestamp: 2 } + ] + + const fresh = [ + { ...seed[0], rowId: 21 }, + seed[1], + { ...seed[2], rowId: 23 }, + { id: 'u3', parts: [textPart('arrived after consent')], role: 'user', rowId: 25, timestamp: 3 } + ] + + setMessages(seed as never) + const submits: Record[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'prompt.submit') { + submits.push(params ?? {}) + + if (submits.length === 1) { + throw new JsonRpcGatewayError('target user message is no longer in session history', { code: 4018 }) + } + } + + return {} as never + }) + + let handle: HarnessHandle | undefined + + await actRender( + { + handle = h + }} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + resumeStoredSession={async () => { + setMessages(fresh as never) + }} + seedMessages={seed} + storedSessionId="stored" + /> + ) + + const stopConfirming = $confirmRequest.listen(request => { + if (request) { + settleConfirm(true) + } + }) + + await handle!.editMessage({ + content: [{ text: 'edited first', type: 'text' }], + parentId: null, + role: 'user', + sourceId: 'u1' + } as never) + stopConfirming() + + expect(submits).toHaveLength(2) + expect(submits[1]).toMatchObject({ truncate_before_row_id: 21 }) + expect(submits[1]).not.toHaveProperty('confirm_deep_truncate') + }) + + it('a deep-edit confirm answered after a session switch sends nothing and leaves the new session idle', async () => { + const seed = [ + { id: 'u1', parts: [textPart('first')], role: 'user', rowId: 11, timestamp: 0 }, + { id: 'u2', parts: [textPart('second')], role: 'user', rowId: 13, timestamp: 1 } + ] + + const other = [{ id: 'other-user', parts: [textPart('other chat')], role: 'user', rowId: 99, timestamp: 2 }] + + publishSessionState(RUNTIME_SESSION_ID, createClientSessionState('stored', seed as never)) + publishSessionState('other-runtime', createClientSessionState('other-stored', other as never)) + setMessages(seed as never) + const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } + const submits: Record[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'prompt.submit') { + submits.push(params ?? {}) + } + + return {} as never + }) + + let handle: HarnessHandle | undefined + + await actRender( + { + handle = h + }} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={seed} + /> + ) + + const stopConfirming = $confirmRequest.listen(request => { + if (request) { + activeSessionIdRef.current = 'other-runtime' + setMessages(other as never) + settleConfirm(true) + } + }) + + await handle!.editMessage({ + content: [{ text: 'edited first', type: 'text' }], + parentId: null, + role: 'user', + sourceId: 'u1' + } as never) + stopConfirming() + + expect(submits).toEqual([]) + expect($busy.get()).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index a9c5b0c32b..a9d97def96 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1,7 +1,6 @@ import { JsonRpcGatewayError } from '@hermes/shared' import { act, cleanup, render, waitFor } from '@testing-library/react' import type { MutableRefObject } from 'react' -import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getLatestSessionMessages, getSession } from '@/hermes' @@ -34,11 +33,9 @@ import { dropSessionState, publishSessionState } from '@/store/session-states' import { $wakeWord, resetWakeWordState } from '@/store/wake-word' import type { SessionInfo } from '@/types/hermes' +import { actRender, Harness, type HarnessHandle, RUNTIME_SESSION_ID } from './prompt-actions-harness' import { clearSingleFlightSessionResumeState } from './single-flight-resume' import { SESSION_COMPRESS_TIMEOUT_MS } from './slash' -import type { SubmitTextOptions } from './utils' - -import { usePromptActions } from '.' // Suites in this file reuse the same stored-id constants. The module-level // single-flight resume map (and drift-recovery cache) would otherwise leak a @@ -71,7 +68,6 @@ vi.mock('@/store/gateway', async importOriginal => ({ // that mismatch is the bug: the REST renameSession endpoint resolves against // the stored sessions table and 404s on a runtime id. session.title accepts // the runtime id directly. -const RUNTIME_SESSION_ID = 'rt-abc123' // Every typed command also fires this (fire-and-forget); these tests assert the command's own traffic. const SLASH_METRIC = 'shared_metrics.slash_command' @@ -94,173 +90,6 @@ function sessionInfo(overrides: Partial = {}): SessionInfo { } } -// Wrap render() in act() so the Harness's useEffect (onReady callback + -// internal state from usePromptActions) flushes synchronously instead of -// spilling async state updates outside act(). -async function actRender(ui: React.ReactElement) { - let result: ReturnType - await act(async () => { - result = render(ui) - }) - - return result! -} - -interface HarnessHandle { - activeSessionIdRef: MutableRefObject - cancelRun: () => Promise - editMessage: (edited: Parameters['editMessage']>[0]) => Promise - reloadFromMessage: (parentId: null | string) => Promise - restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise - redirectPrompt: (text: string) => Promise - /** @deprecated Use `redirectPrompt`. */ - steerPrompt: (text: string) => Promise - submitTextRaw: (text: string, options?: SubmitTextOptions) => Promise - submitText: (text: string, options?: SubmitTextOptions) => Promise -} - -function Harness({ - activeSessionIdRef: activeSessionIdRefProp, - busyRef, - getRoutedStoredSessionId, - getRuntimeIdForStoredSession, - getRouteToken, - onUpdateState, - onReady, - onSeedState, - openMemoryGraph, - refreshSessions, - requestGateway, - resumeStoredSession, - runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRefProp, - seedMessages, - seedStreamId, - seedTurnStartedAt, - selectedStoredSessionIdRef: selectedStoredSessionIdRefProp, - storedSessionId, - activeSessionId, - createBackendSessionForSend -}: { - activeSessionIdRef?: MutableRefObject - busyRef?: MutableRefObject - getRoutedStoredSessionId?: () => null | string - getRuntimeIdForStoredSession?: (storedSessionId: string) => null | string - getRouteToken?: () => string - onUpdateState?: ( - sessionId: string, - storedSessionId: null | string | undefined, - state: Record - ) => void - onReady: (handle: HarnessHandle) => void - onSeedState?: (state: Record) => void - openMemoryGraph?: () => void - refreshSessions: () => Promise - requestGateway: (method: string, params?: Record, timeoutMs?: number) => Promise - resumeStoredSession?: (storedSessionId: string) => Promise | void - runtimeIdByStoredSessionIdRef?: MutableRefObject> - seedMessages?: unknown[] - seedStreamId?: null | string - seedTurnStartedAt?: null | number - selectedStoredSessionIdRef?: MutableRefObject - storedSessionId?: null | string - activeSessionId?: null | string - createBackendSessionForSend?: (preview?: null | string) => Promise -}) { - const localActiveSessionIdRef = useRef( - activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId - ) - - const activeSessionIdRef = activeSessionIdRefProp ?? localActiveSessionIdRef - - const selectedStoredSessionIdRef: MutableRefObject = selectedStoredSessionIdRefProp ?? { - current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId - } - - const defaultStoredSessionId = storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId - const defaultRuntimeSessionId = activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId - - const runtimeIdByStoredSessionIdRef: MutableRefObject> = runtimeIdByStoredSessionIdRefProp ?? { - current: - defaultStoredSessionId && defaultRuntimeSessionId - ? new Map([[defaultStoredSessionId, defaultRuntimeSessionId]]) - : new Map() - } - - const localBusyRef = busyRef ?? { current: false } - - const stateRef = useRef({ - messages: seedMessages ?? [], - busy: false, - awaitingResponse: false, - interrupted: true, - streamId: seedStreamId ?? null, - turnStartedAt: seedTurnStartedAt ?? null, - interimBoundaryPending: false - } as never) - - const actions = usePromptActions({ - activeSessionId: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId, - activeSessionIdRef, - branchCurrentSession: async () => true, - busyRef: localBusyRef, - createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID), - getRoutedStoredSessionId: getRoutedStoredSessionId ?? (() => null), - getRuntimeIdForStoredSession: getRuntimeIdForStoredSession ?? (() => null), - getRouteToken: getRouteToken ?? (() => 'token'), - handleSkinCommand: () => '', - openMemoryGraph: openMemoryGraph ?? (() => undefined), - refreshSessions, - requestGateway, - resumeStoredSession: resumeStoredSession ?? (() => undefined), - runtimeIdByStoredSessionIdRef, - selectedStoredSessionIdRef, - startFreshSessionDraft: () => undefined, - sttEnabled: false, - updateSessionState: (sessionId, updater, storedSessionId) => { - // Seed with interrupted:true so we can prove a fresh submit clears it. - const next = updater(stateRef.current) as unknown as Record - stateRef.current = next as never - onSeedState?.(next) - onUpdateState?.(sessionId, storedSessionId, next) - - return next as never - } - }) - - useEffect(() => { - onReady({ - activeSessionIdRef, - cancelRun: (...args: Parameters) => - act(async () => actions.cancelRun(...args)) as Promise, - editMessage: (...args: Parameters) => - act(async () => actions.editMessage(...args)) as Promise, - reloadFromMessage: (...args: Parameters) => - act(async () => actions.reloadFromMessage(...args)) as Promise, - restoreToMessage: (...args: Parameters) => - act(async () => actions.restoreToMessage(...args)) as Promise, - redirectPrompt: (...args: Parameters) => - act(async () => actions.redirectPrompt(...args)) as Promise, - steerPrompt: (...args: Parameters) => - act(async () => actions.steerPrompt(...args)) as Promise, - submitTextRaw: actions.submitText, - submitText: (...args: Parameters) => - act(async () => actions.submitText(...args)) as Promise - }) - }, [ - actions.cancelRun, - actions.editMessage, - actions.reloadFromMessage, - actions.restoreToMessage, - actions.redirectPrompt, - actions.steerPrompt, - actions.submitText, - activeSessionIdRef, - onReady - ]) - - return null -} - describe('usePromptActions /title', () => { beforeEach(() => { setSessions(() => [sessionInfo()]) @@ -5958,54 +5787,6 @@ describe('usePromptActions stale-closure session routing', () => { ) }) - it('drops a 4033 confirm answered after the user switched sessions (#133716)', async () => { - const submits: Record[] = [] - - const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'prompt.submit') { - submits.push(params ?? {}) - - if (!params?.confirm_deep_truncate) { - throw new JsonRpcGatewayError('truncation would archive later user turns', { code: 4033 }) - } - } - - return {} as never - }) as unknown as GatewayMock - - const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } - setMessages([ - { id: 'u1', parts: [textPart('prompt A')], role: 'user', timestamp: 0 }, - { id: 'a1', parts: [textPart('reply')], role: 'assistant', timestamp: 1 } - ] as never) - - let handle: HarnessHandle | null = null - await actRender( - (handle = h)} - refreshSessions={async () => undefined} - requestGateway={requestGateway} - selectedStoredSessionIdRef={{ current: null }} - /> - ) - - // Session A's tail regenerate is refused; the user switches to B, then accepts. - const stopConfirming = $confirmRequest.listen(request => { - if (request) { - activeSessionIdRef.current = RUNTIME_SESSION_B - settleConfirm(true) - } - }) - - await handle!.reloadFromMessage(null) - stopConfirming() - - expect(submits).toHaveLength(1) - expect(submits[0]).toMatchObject({ session_id: RUNTIME_SESSION_ID }) - }) - it('regenerates against the CURRENT session, not the stale closure session', async () => { const requestGateway = vi.fn(async () => ({}) as never) as unknown as GatewayMock @@ -6248,199 +6029,6 @@ describe('usePromptActions reloadFromMessage failed-submit rollback (#95745)', ( }) }) -describe('usePromptActions deep edit confirmed while output streams (#133716)', () => { - afterEach(() => { - cleanup() - clearNotifications() - setMessages([]) - $busy.set(false) - }) - - it('re-aims a confirmed deep edit at the grown transcript instead of dropping it', async () => { - const seed = [ - { id: 'u1', parts: [textPart('first')], role: 'user' as const, rowId: 11, timestamp: 0 }, - { id: 'a1', parts: [textPart('reply')], role: 'assistant' as const, timestamp: 1 }, - { id: 'u2', parts: [textPart('later')], role: 'user' as const, rowId: 13, timestamp: 2 }, - { id: 'a2', parts: [textPart('later reply')], role: 'assistant' as const, timestamp: 3 } - ] - - setMessages(seed as never) - const submits: Record[] = [] - - const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'prompt.submit') { - submits.push(params ?? {}) - } - - return {} as never - }) - - let handle: HarnessHandle | undefined - - await actRender( - { - handle = h - }} - refreshSessions={async () => undefined} - requestGateway={requestGateway} - seedMessages={seed} - /> - ) - - // A stream delta replaces the transcript array while the user reads the dialog. - const stopConfirming = $confirmRequest.listen(request => { - if (request) { - setMessages([...seed, { id: 'a2b', parts: [textPart('more')], role: 'assistant', timestamp: 4 }] as never) - settleConfirm(true) - } - }) - - await handle!.editMessage({ - content: [{ text: 'edited first', type: 'text' }], - parentId: null, - role: 'user', - sourceId: 'u1' - } as never) - stopConfirming() - - expect(submits).toHaveLength(1) - expect(submits[0]).toMatchObject({ confirm_deep_truncate: true, truncate_before_row_id: 11, text: 'edited first' }) - }) -}) - -describe('usePromptActions deep-edit consent scope (#133716)', () => { - afterEach(() => { - cleanup() - clearNotifications() - setMessages([]) - $busy.set(false) - dropSessionState(RUNTIME_SESSION_ID) - dropSessionState('other-runtime') - }) - - it('a stale-target retry whose refresh shows a new later turn does not reuse the deep confirm', async () => { - const seed = [ - { id: 'u1', parts: [textPart('first')], role: 'user', rowId: 11, timestamp: 0 }, - { id: 'a1', parts: [textPart('reply')], role: 'assistant', timestamp: 1 }, - { id: 'u2', parts: [textPart('second')], role: 'user', rowId: 13, timestamp: 2 } - ] - - const fresh = [ - { ...seed[0], rowId: 21 }, - seed[1], - { ...seed[2], rowId: 23 }, - { id: 'u3', parts: [textPart('arrived after consent')], role: 'user', rowId: 25, timestamp: 3 } - ] - - setMessages(seed as never) - const submits: Record[] = [] - - const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'prompt.submit') { - submits.push(params ?? {}) - - if (submits.length === 1) { - throw new JsonRpcGatewayError('target user message is no longer in session history', { code: 4018 }) - } - } - - return {} as never - }) - - let handle: HarnessHandle | undefined - - await actRender( - { - handle = h - }} - refreshSessions={async () => undefined} - requestGateway={requestGateway} - resumeStoredSession={async () => { - setMessages(fresh as never) - }} - seedMessages={seed} - storedSessionId="stored" - /> - ) - - const stopConfirming = $confirmRequest.listen(request => { - if (request) { - settleConfirm(true) - } - }) - - await handle!.editMessage({ - content: [{ text: 'edited first', type: 'text' }], - parentId: null, - role: 'user', - sourceId: 'u1' - } as never) - stopConfirming() - - expect(submits).toHaveLength(2) - expect(submits[1]).toMatchObject({ truncate_before_row_id: 21 }) - expect(submits[1]).not.toHaveProperty('confirm_deep_truncate') - }) - - it('a deep-edit confirm answered after a session switch sends nothing and leaves the new session idle', async () => { - const seed = [ - { id: 'u1', parts: [textPart('first')], role: 'user', rowId: 11, timestamp: 0 }, - { id: 'u2', parts: [textPart('second')], role: 'user', rowId: 13, timestamp: 1 } - ] - - const other = [{ id: 'other-user', parts: [textPart('other chat')], role: 'user', rowId: 99, timestamp: 2 }] - - publishSessionState(RUNTIME_SESSION_ID, createClientSessionState('stored', seed as never)) - publishSessionState('other-runtime', createClientSessionState('other-stored', other as never)) - setMessages(seed as never) - const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } - const submits: Record[] = [] - - const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'prompt.submit') { - submits.push(params ?? {}) - } - - return {} as never - }) - - let handle: HarnessHandle | undefined - - await actRender( - { - handle = h - }} - refreshSessions={async () => undefined} - requestGateway={requestGateway} - seedMessages={seed} - /> - ) - - const stopConfirming = $confirmRequest.listen(request => { - if (request) { - activeSessionIdRef.current = 'other-runtime' - setMessages(other as never) - settleConfirm(true) - } - }) - - await handle!.editMessage({ - content: [{ text: 'edited first', type: 'text' }], - parentId: null, - role: 'user', - sourceId: 'u1' - } as never) - stopConfirming() - - expect(submits).toEqual([]) - expect($busy.get()).toBe(false) - }) -}) - describe('usePromptActions live-owner refusal (#106217)', () => { afterEach(() => { cleanup() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 9a496c71ae..ce687cfdf9 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -63,14 +63,14 @@ import { applyRewindOptimistic, deepCutConfirmRequest, durableRowIdsForRebind, + type EditPlan, finalizeStoppedMessages, - planConfirmedEdit, - laterVisibleUserTurns, planConfirmedReload, planEdit, + planEditAfterConfirm, planRestore, rebindSurvivorRowIds, - revalidateEditPlan, + retryKeepsDeepConfirm, runRewindSubmit, type SurvivorUserRowIds } from './rewind' @@ -256,6 +256,16 @@ const currentMessages = (sessionId: string) => $sessionStates.get()[sessionId]?. const isStaleAfterConfirm = (sessionId: string, planned: ChatMessage[]) => Boolean($sessionStates.get()[sessionId]?.busy) || currentMessages(sessionId) !== planned +// 4018 whose target sits before the live segment (compressed away): no retry can reach it. +const isCompressedAwayError = (err: unknown) => { + const segmentOrdinal = + err instanceof JsonRpcGatewayError && err.code === 4018 && err.data && typeof err.data === 'object' + ? (err.data as { segment_ordinal?: unknown }).segment_ordinal + : undefined + + return typeof segmentOrdinal === 'number' && segmentOrdinal < 0 +} + const isStaleTargetError = (err: unknown) => /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) @@ -1207,6 +1217,81 @@ export function usePromptActions({ ] ) + // Stale target after compression/resume drift (the cached rowId and ordinal address the + // pre-compression segment): reload server history, recompute the full edit plan against the + // refreshed transcript, and retry the real edit once. Do NOT plain-resubmit without a truncation + // address — that drops rewind semantics and silently appends the edit as a new turn (#82462). + // Returns 'sent', or the error to surface and whether that submit carried the deep confirm. + const retryStaleEdit = useCallback( + async ( + sessionId: string, + edited: AppendMessage, + plan: EditPlan & { confirmDeepTruncate: boolean }, + messages: ChatMessage[] + ): Promise<'sent' | { surfaced?: unknown; surfacedConfirmed?: boolean; unavailable: boolean }> => { + let surfacedConfirmed: boolean | undefined + + try { + const storedId = selectedStoredSessionIdRef.current + + if (storedId) { + await resumeStoredSession(storedId) + } + + const refreshed = $messages.get() + const retryPlan = planEdit(refreshed, edited) + + if (!retryPlan || retryPlan.isFailedTurn) { + return { unavailable: true } + } + + surfacedConfirmed = retryKeepsDeepConfirm(plan, messages, retryPlan, refreshed) + + const survivorRowIds = await submitRewindPrompt( + sessionId, + retryPlan.text, + retryPlan.truncateOrdinal, + retryPlan.truncateMessageId, + false, + retryPlan.truncateRowId, + retryPlan.sourceText, + durableRowIdsForRebind(refreshed), + surfacedConfirmed + ) + + applySurvivorRowIds(sessionId, survivorRowIds) + + return 'sent' + } catch (retryErr) { + return { + surfaced: retryErr, + surfacedConfirmed, + unavailable: isCompressedAwayError(retryErr) || isStaleTargetError(retryErr) + } + } + }, + [applySurvivorRowIds, resumeStoredSession, selectedStoredSessionIdRef, submitRewindPrompt] + ) + + // Roll an optimistic edit/truncation back to the original history so the UI stays in sync with + // what's persisted instead of stranding a partial timeline. + const rollBackEdit = useCallback( + (sessionId: string, messages: ChatMessage[]) => { + setMutableRef(busyRef, false) + setBusy(false) + setAwaitingResponse(false) + updateSessionState(sessionId, state => ({ + ...state, + busy: false, + awaitingResponse: false, + turnLive: false, + turnStartedAt: null, + messages + })) + }, + [busyRef, updateSessionState] + ) + const runEdit = useCallback( async (edited: AppendMessage, answeredFor?: ChatMessage[]): Promise => { // Ref, not the closure-captured prop — an edit rewinds and resubmits, so @@ -1217,32 +1302,31 @@ export function usePromptActions({ const planning = (sessionId ? $sessionStates.get()[sessionId]?.messages : null) ?? $messages.get() const confirmDeep = () => confirm(deepCutConfirmRequest(t.assistant.thread)) - const forceDeep = answeredFor !== undefined && answeredFor === planning - const planned = sessionId - ? await planConfirmedEdit(planning, edited, forceDeep ? async () => true : confirmDeep) - : null - - const forced = planned && forceDeep ? { ...planned, confirmDeepTruncate: true } : planned - - // A confirm is a wait of user length: an answer given after a session switch is not for this one. - if (!sessionId || !forced || activeSessionIdRef.current !== sessionId) { + if (!sessionId) { return } - // Edit interrupts a live turn on purpose, so busy is fine; after a confirm wait the plan is - // re-aimed at the current transcript (a stream may have grown it) and dropped, with a notice, - // only if the edited turn itself moved. - const messages = forced.confirmDeepTruncate ? currentMessages(sessionId) : planning - const plan = messages === planning ? forced : revalidateEditPlan(forced, planning, messages, edited) + const planned = await planEditAfterConfirm({ + answeredFor, + confirmDeep, + current: () => currentMessages(sessionId), + edited, + planning, + stillOwned: () => activeSessionIdRef.current === sessionId + }) - if (!plan) { + if (planned === 'moved') { // The edited turn moved while the confirm was open: nothing was sent. notify({ kind: 'warning', message: copy.editFailed }) + } + if (!planned || planned === 'moved') { return } + const { messages, plan } = planned + // Sending an edit is a revert: rewind to this prompt and re-run with the // new text (submitRewindPrompt interrupts a live turn first). Same as // restore, so drop the abandoned timeline's todos/background rows before @@ -1263,22 +1347,6 @@ export function usePromptActions({ setAwaitingResponse(true) updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage)) - const isCompressedAwayError = (err: unknown) => { - if (!(err instanceof JsonRpcGatewayError) || err.code !== 4018) { - return false - } - - const data = err.data - - if (!data || typeof data !== 'object') { - return false - } - - const segmentOrdinal = (data as { segment_ordinal?: unknown }).segment_ordinal - - return typeof segmentOrdinal === 'number' && segmentOrdinal < 0 - } - try { const survivorRowIds = await submitRewindPrompt( sessionId, @@ -1299,71 +1367,26 @@ export function usePromptActions({ // Whether the submit that produced *surfaced* carried a deep-cut confirm. let surfacedConfirmed = plan.confirmDeepTruncate - // Stale target after compression/resume drift (the cached rowId and - // ordinal address the pre-compression segment): reload server history, - // recompute the full edit plan against the refreshed transcript, and - // retry the real edit once. Do NOT plain-resubmit without a truncation - // address — that drops rewind semantics and silently appends the edit - // as a new turn (#82462). + // Stale target after compression/resume drift: reload, re-plan, retry once (#82462). if (!plan.isFailedTurn && !unavailable && isStaleTargetError(err)) { - try { - const storedId = selectedStoredSessionIdRef.current + const retried = await retryStaleEdit(sessionId, edited, plan, messages) - if (storedId) { - await resumeStoredSession(storedId) - } + if (retried === 'sent') { + return + } - const refreshed = $messages.get() - const retryPlan = planEdit(refreshed, edited) - // planEdit, not planConfirmedEdit: the retry only re-addresses the same turn after a history - // refresh. The user's deep-cut answer covers the later turns they saw; if the refresh shows - // more, it goes unconfirmed and the server gate decides (a 4033 asks again below). - const retryConfirmed = - plan.confirmDeepTruncate && - retryPlan !== null && - laterVisibleUserTurns(refreshed, retryPlan.sourceIndex) === - laterVisibleUserTurns(messages, plan.sourceIndex) + unavailable = retried.unavailable - if (retryPlan && !retryPlan.isFailedTurn) { - surfacedConfirmed = retryConfirmed - const survivorRowIds = await submitRewindPrompt( - sessionId, - retryPlan.text, - retryPlan.truncateOrdinal, - retryPlan.truncateMessageId, - false, - retryPlan.truncateRowId, - retryPlan.sourceText, - durableRowIdsForRebind(refreshed), - retryConfirmed - ) + if (retried.surfaced !== undefined) { + surfaced = retried.surfaced + } - applySurvivorRowIds(sessionId, survivorRowIds) - - return - } - - unavailable = true - } catch (retryErr) { - surfaced = retryErr - unavailable = isCompressedAwayError(retryErr) || isStaleTargetError(retryErr) + if (retried.surfacedConfirmed !== undefined) { + surfacedConfirmed = retried.surfacedConfirmed } } - // Roll the optimistic edit/truncation back to the original history so the - // UI stays in sync with what's persisted instead of stranding a partial - // timeline. - setMutableRef(busyRef, false) - setBusy(false) - setAwaitingResponse(false) - updateSessionState(sessionId, state => ({ - ...state, - busy: false, - awaitingResponse: false, - turnLive: false, - turnStartedAt: null, - messages - })) + rollBackEdit(sessionId, messages) if (!isDeepTruncateRefusal(surfaced) || surfacedConfirmed) { notifyError(surfaced, unavailable ? copy.editTurnUnavailable : copy.editFailed) @@ -1383,6 +1406,8 @@ export function usePromptActions({ copy.editTurnUnavailable, resumeStoredSession, selectedStoredSessionIdRef, + retryStaleEdit, + rollBackEdit, submitRewindPrompt, t, updateSessionState diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/prompt-actions-harness.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/prompt-actions-harness.tsx new file mode 100644 index 0000000000..4692136712 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/prompt-actions-harness.tsx @@ -0,0 +1,178 @@ +// Shared usePromptActions test harness (index.test.tsx and the topical siblings). +import { act, render } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { useEffect, useRef } from 'react' + +import type { SubmitTextOptions } from './utils' + +import { usePromptActions } from '.' + +// The active id the desktop holds is the *runtime* session id from session.create. +export const RUNTIME_SESSION_ID = 'rt-abc123' + +// Wrap render() in act() so the Harness's useEffect (onReady callback + +// internal state from usePromptActions) flushes synchronously instead of +// spilling async state updates outside act(). +export async function actRender(ui: React.ReactElement) { + let result: ReturnType + await act(async () => { + result = render(ui) + }) + + return result! +} + +export interface HarnessHandle { + activeSessionIdRef: MutableRefObject + cancelRun: () => Promise + editMessage: (edited: Parameters['editMessage']>[0]) => Promise + reloadFromMessage: (parentId: null | string) => Promise + restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise + redirectPrompt: (text: string) => Promise + /** @deprecated Use `redirectPrompt`. */ + steerPrompt: (text: string) => Promise + submitTextRaw: (text: string, options?: SubmitTextOptions) => Promise + submitText: (text: string, options?: SubmitTextOptions) => Promise +} + +export function Harness({ + activeSessionIdRef: activeSessionIdRefProp, + busyRef, + getRoutedStoredSessionId, + getRuntimeIdForStoredSession, + getRouteToken, + onUpdateState, + onReady, + onSeedState, + openMemoryGraph, + refreshSessions, + requestGateway, + resumeStoredSession, + runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRefProp, + seedMessages, + seedStreamId, + seedTurnStartedAt, + selectedStoredSessionIdRef: selectedStoredSessionIdRefProp, + storedSessionId, + activeSessionId, + createBackendSessionForSend +}: { + activeSessionIdRef?: MutableRefObject + busyRef?: MutableRefObject + getRoutedStoredSessionId?: () => null | string + getRuntimeIdForStoredSession?: (storedSessionId: string) => null | string + getRouteToken?: () => string + onUpdateState?: ( + sessionId: string, + storedSessionId: null | string | undefined, + state: Record + ) => void + onReady: (handle: HarnessHandle) => void + onSeedState?: (state: Record) => void + openMemoryGraph?: () => void + refreshSessions: () => Promise + requestGateway: (method: string, params?: Record, timeoutMs?: number) => Promise + resumeStoredSession?: (storedSessionId: string) => Promise | void + runtimeIdByStoredSessionIdRef?: MutableRefObject> + seedMessages?: unknown[] + seedStreamId?: null | string + seedTurnStartedAt?: null | number + selectedStoredSessionIdRef?: MutableRefObject + storedSessionId?: null | string + activeSessionId?: null | string + createBackendSessionForSend?: (preview?: null | string) => Promise +}) { + const localActiveSessionIdRef = useRef( + activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId + ) + + const activeSessionIdRef = activeSessionIdRefProp ?? localActiveSessionIdRef + + const selectedStoredSessionIdRef: MutableRefObject = selectedStoredSessionIdRefProp ?? { + current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId + } + + const defaultStoredSessionId = storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId + const defaultRuntimeSessionId = activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId + + const runtimeIdByStoredSessionIdRef: MutableRefObject> = runtimeIdByStoredSessionIdRefProp ?? { + current: + defaultStoredSessionId && defaultRuntimeSessionId + ? new Map([[defaultStoredSessionId, defaultRuntimeSessionId]]) + : new Map() + } + + const localBusyRef = busyRef ?? { current: false } + + const stateRef = useRef({ + messages: seedMessages ?? [], + busy: false, + awaitingResponse: false, + interrupted: true, + streamId: seedStreamId ?? null, + turnStartedAt: seedTurnStartedAt ?? null, + interimBoundaryPending: false + } as never) + + const actions = usePromptActions({ + activeSessionId: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId, + activeSessionIdRef, + branchCurrentSession: async () => true, + busyRef: localBusyRef, + createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID), + getRoutedStoredSessionId: getRoutedStoredSessionId ?? (() => null), + getRuntimeIdForStoredSession: getRuntimeIdForStoredSession ?? (() => null), + getRouteToken: getRouteToken ?? (() => 'token'), + handleSkinCommand: () => '', + openMemoryGraph: openMemoryGraph ?? (() => undefined), + refreshSessions, + requestGateway, + resumeStoredSession: resumeStoredSession ?? (() => undefined), + runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef, + startFreshSessionDraft: () => undefined, + sttEnabled: false, + updateSessionState: (sessionId, updater, storedSessionId) => { + // Seed with interrupted:true so we can prove a fresh submit clears it. + const next = updater(stateRef.current) as unknown as Record + stateRef.current = next as never + onSeedState?.(next) + onUpdateState?.(sessionId, storedSessionId, next) + + return next as never + } + }) + + useEffect(() => { + onReady({ + activeSessionIdRef, + cancelRun: (...args: Parameters) => + act(async () => actions.cancelRun(...args)) as Promise, + editMessage: (...args: Parameters) => + act(async () => actions.editMessage(...args)) as Promise, + reloadFromMessage: (...args: Parameters) => + act(async () => actions.reloadFromMessage(...args)) as Promise, + restoreToMessage: (...args: Parameters) => + act(async () => actions.restoreToMessage(...args)) as Promise, + redirectPrompt: (...args: Parameters) => + act(async () => actions.redirectPrompt(...args)) as Promise, + steerPrompt: (...args: Parameters) => + act(async () => actions.steerPrompt(...args)) as Promise, + submitTextRaw: actions.submitText, + submitText: (...args: Parameters) => + act(async () => actions.submitText(...args)) as Promise + }) + }, [ + actions.cancelRun, + actions.editMessage, + actions.reloadFromMessage, + actions.restoreToMessage, + actions.redirectPrompt, + actions.steerPrompt, + actions.submitText, + activeSessionIdRef, + onReady + ]) + + return null +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts index 1f02bec431..ecf390dc9e 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts @@ -771,6 +771,56 @@ export function revalidateEditPlan

( return { ...plan, ...fresh, confirmDeepTruncate: (plan as P & { confirmDeepTruncate?: boolean }).confirmDeepTruncate } } +/** + * Edit's plan step around the deep-cut confirm. Returns the plan and the transcript it is aimed at, + * null when there is nothing to send (declined, nothing changed, or the confirm was answered after + * the session switched away), or 'moved' when the edited turn moved during the confirm. + * *answeredFor* is the transcript a 4033 confirm already covered: the re-run forces the deep cut + * only while the transcript is still exactly that one. + */ +export async function planEditAfterConfirm(opts: { + answeredFor?: ChatMessage[] + confirmDeep: () => Promise + current: () => ChatMessage[] + edited: AppendMessage + planning: ChatMessage[] + stillOwned: () => boolean +}): Promise<'moved' | null | { messages: ChatMessage[]; plan: EditPlan & { confirmDeepTruncate: boolean } }> { + const { answeredFor, confirmDeep, current, edited, planning, stillOwned } = opts + const forceDeep = answeredFor !== undefined && answeredFor === planning + const planned = await planConfirmedEdit(planning, edited, forceDeep ? async () => true : confirmDeep) + const forced = planned && forceDeep ? { ...planned, confirmDeepTruncate: true } : planned + + if (!forced || !stillOwned()) { + return null + } + + // Edit interrupts a live turn on purpose, so busy is fine; after a confirm wait the plan is + // re-aimed at the current transcript (a stream may have grown it). + const messages = forced.confirmDeepTruncate ? current() : planning + const plan = messages === planning ? forced : revalidateEditPlan(forced, planning, messages, edited) + + return plan ? { messages, plan } : 'moved' +} + +/** + * A stale-target retry re-addresses the same turn after a history refresh. The user's deep-cut + * answer covers the later turns they saw; if the refresh shows more, the retry goes unconfirmed and + * the server gate decides. + */ +export function retryKeepsDeepConfirm( + plan: EditPlan & { confirmDeepTruncate: boolean }, + planned: ChatMessage[], + retryPlan: EditPlan | null, + refreshed: ChatMessage[] +): boolean { + return ( + plan.confirmDeepTruncate && + retryPlan !== null && + laterVisibleUserTurns(refreshed, retryPlan.sourceIndex) === laterVisibleUserTurns(planned, plan.sourceIndex) + ) +} + /** Optimistic rewind-to state for restore/edit: drop everything after the * source turn (edit swaps in the edited message; restore keeps the original). */ export function applyRewindOptimistic(