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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user