refactor(desktop): split the edit deep-cut phases out of runEdit; move the deep-cut tests into a sibling

The repo code-health ratchet blocked the push on two counts:
- runEdit had cyclomatic complexity 31 against a target of 20.
- index.test.tsx grew past its main-branch cap.

The pure steps move to rewind.ts:
- planEditAfterConfirm: plan, confirm, session-ownership check and
  re-aim.
- retryKeepsDeepConfirm: the stale-retry consent rule.

The hook gains retryStaleEdit, which holds the existing #82462 retry
unchanged apart from the consent rule, and rollBackEdit.
isCompressedAwayError becomes module-level. runEdit is now at CC 20.

The usePromptActions test harness moves to prompt-actions-harness.tsx.
The #133716 hook tests move to index.deep-cut.test.tsx, plus a new test
for a later user turn arriving during the dialog, which goes red with
the "moved" outcome removed. index.test.tsx drops below its main-branch
size. No behaviour change otherwise.
This commit is contained in:
kshitijk4poor
2026-10-08 00:26:56 +05:30
committed by kshitij
parent 10cf193745
commit 0faf0938eb
5 changed files with 675 additions and 504 deletions

View File

@@ -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<typeof Harness>[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<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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<string | null> = { 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(
<Harness
activeSessionId={RUNTIME_SESSION_ID}
activeSessionIdRef={activeSessionIdRef}
onReady={h => (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<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'prompt.submit') {
submits.push(params ?? {})
}
return {} as never
})
let handle: HarnessHandle | undefined
await actRender(
<Harness
onReady={h => {
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<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'prompt.submit') {
submits.push(params ?? {})
}
return {} as never
})
let handle: HarnessHandle | undefined
await actRender(
<Harness
onReady={h => {
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<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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(
<Harness
onReady={h => {
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<string | null> = { current: RUNTIME_SESSION_ID }
const submits: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'prompt.submit') {
submits.push(params ?? {})
}
return {} as never
})
let handle: HarnessHandle | undefined
await actRender(
<Harness
activeSessionIdRef={activeSessionIdRef}
onReady={h => {
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)
})
})

View File

@@ -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> = {}): 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<typeof render>
await act(async () => {
result = render(ui)
})
return result!
}
interface HarnessHandle {
activeSessionIdRef: MutableRefObject<string | null>
cancelRun: () => Promise<void>
editMessage: (edited: Parameters<ReturnType<typeof usePromptActions>['editMessage']>[0]) => Promise<void>
reloadFromMessage: (parentId: null | string) => Promise<void>
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
redirectPrompt: (text: string) => Promise<boolean>
/** @deprecated Use `redirectPrompt`. */
steerPrompt: (text: string) => Promise<boolean>
submitTextRaw: (text: string, options?: SubmitTextOptions) => Promise<boolean>
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean>
}
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<string | null>
busyRef?: MutableRefObject<boolean>
getRoutedStoredSessionId?: () => null | string
getRuntimeIdForStoredSession?: (storedSessionId: string) => null | string
getRouteToken?: () => string
onUpdateState?: (
sessionId: string,
storedSessionId: null | string | undefined,
state: Record<string, unknown>
) => void
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
openMemoryGraph?: () => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
runtimeIdByStoredSessionIdRef?: MutableRefObject<Map<string, string>>
seedMessages?: unknown[]
seedStreamId?: null | string
seedTurnStartedAt?: null | number
selectedStoredSessionIdRef?: MutableRefObject<string | null>
storedSessionId?: null | string
activeSessionId?: null | string
createBackendSessionForSend?: (preview?: null | string) => Promise<null | string>
}) {
const localActiveSessionIdRef = useRef<string | null>(
activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId
)
const activeSessionIdRef = activeSessionIdRefProp ?? localActiveSessionIdRef
const selectedStoredSessionIdRef: MutableRefObject<string | null> = 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<Map<string, string>> = 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<string, unknown>
stateRef.current = next as never
onSeedState?.(next)
onUpdateState?.(sessionId, storedSessionId, next)
return next as never
}
})
useEffect(() => {
onReady({
activeSessionIdRef,
cancelRun: (...args: Parameters<typeof actions.cancelRun>) =>
act(async () => actions.cancelRun(...args)) as Promise<void>,
editMessage: (...args: Parameters<typeof actions.editMessage>) =>
act(async () => actions.editMessage(...args)) as Promise<void>,
reloadFromMessage: (...args: Parameters<typeof actions.reloadFromMessage>) =>
act(async () => actions.reloadFromMessage(...args)) as Promise<void>,
restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>
act(async () => actions.restoreToMessage(...args)) as Promise<void>,
redirectPrompt: (...args: Parameters<typeof actions.redirectPrompt>) =>
act(async () => actions.redirectPrompt(...args)) as Promise<boolean>,
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
submitTextRaw: actions.submitText,
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
}, [
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<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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<string | null> = { 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(
<Harness
activeSessionId={RUNTIME_SESSION_ID}
activeSessionIdRef={activeSessionIdRef}
onReady={h => (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<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'prompt.submit') {
submits.push(params ?? {})
}
return {} as never
})
let handle: HarnessHandle | undefined
await actRender(
<Harness
onReady={h => {
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<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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(
<Harness
onReady={h => {
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<string | null> = { current: RUNTIME_SESSION_ID }
const submits: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'prompt.submit') {
submits.push(params ?? {})
}
return {} as never
})
let handle: HarnessHandle | undefined
await actRender(
<Harness
activeSessionIdRef={activeSessionIdRef}
onReady={h => {
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()

View File

@@ -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<void> => {
// 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

View File

@@ -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<typeof render>
await act(async () => {
result = render(ui)
})
return result!
}
export interface HarnessHandle {
activeSessionIdRef: MutableRefObject<string | null>
cancelRun: () => Promise<void>
editMessage: (edited: Parameters<ReturnType<typeof usePromptActions>['editMessage']>[0]) => Promise<void>
reloadFromMessage: (parentId: null | string) => Promise<void>
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
redirectPrompt: (text: string) => Promise<boolean>
/** @deprecated Use `redirectPrompt`. */
steerPrompt: (text: string) => Promise<boolean>
submitTextRaw: (text: string, options?: SubmitTextOptions) => Promise<boolean>
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean>
}
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<string | null>
busyRef?: MutableRefObject<boolean>
getRoutedStoredSessionId?: () => null | string
getRuntimeIdForStoredSession?: (storedSessionId: string) => null | string
getRouteToken?: () => string
onUpdateState?: (
sessionId: string,
storedSessionId: null | string | undefined,
state: Record<string, unknown>
) => void
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
openMemoryGraph?: () => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
runtimeIdByStoredSessionIdRef?: MutableRefObject<Map<string, string>>
seedMessages?: unknown[]
seedStreamId?: null | string
seedTurnStartedAt?: null | number
selectedStoredSessionIdRef?: MutableRefObject<string | null>
storedSessionId?: null | string
activeSessionId?: null | string
createBackendSessionForSend?: (preview?: null | string) => Promise<null | string>
}) {
const localActiveSessionIdRef = useRef<string | null>(
activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId
)
const activeSessionIdRef = activeSessionIdRefProp ?? localActiveSessionIdRef
const selectedStoredSessionIdRef: MutableRefObject<string | null> = 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<Map<string, string>> = 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<string, unknown>
stateRef.current = next as never
onSeedState?.(next)
onUpdateState?.(sessionId, storedSessionId, next)
return next as never
}
})
useEffect(() => {
onReady({
activeSessionIdRef,
cancelRun: (...args: Parameters<typeof actions.cancelRun>) =>
act(async () => actions.cancelRun(...args)) as Promise<void>,
editMessage: (...args: Parameters<typeof actions.editMessage>) =>
act(async () => actions.editMessage(...args)) as Promise<void>,
reloadFromMessage: (...args: Parameters<typeof actions.reloadFromMessage>) =>
act(async () => actions.reloadFromMessage(...args)) as Promise<void>,
restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>
act(async () => actions.restoreToMessage(...args)) as Promise<void>,
redirectPrompt: (...args: Parameters<typeof actions.redirectPrompt>) =>
act(async () => actions.redirectPrompt(...args)) as Promise<boolean>,
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
submitTextRaw: actions.submitText,
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
}, [
actions.cancelRun,
actions.editMessage,
actions.reloadFromMessage,
actions.restoreToMessage,
actions.redirectPrompt,
actions.steerPrompt,
actions.submitText,
activeSessionIdRef,
onReady
])
return null
}

View File

@@ -771,6 +771,56 @@ export function revalidateEditPlan<P extends EditPlan>(
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<boolean>
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(