fix(desktop): follow first-send draft assignment on rejection

This commit is contained in:
BearHuddleston
2026-09-20 04:30:05 -05:00
committed by brooklyn!
parent 8fc6f60562
commit bc01055e93
8 changed files with 541 additions and 49 deletions

View File

@@ -0,0 +1,501 @@
import { useStore } from '@nanostores/react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { startTransition, Suspense, useLayoutEffect, useRef } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { createSessionRpcDispatcher } from '@/app/contrib/session-rpc-dispatcher'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from '@/app/routes'
import { usePromptActions } from '@/app/session/hooks/use-prompt-actions'
import { useSessionActions } from '@/app/session/hooks/use-session-actions'
import { useSessionStateCache } from '@/app/session/hooks/use-session-state-cache'
import {
announceNewSessionDraftKey,
clearSessionDraft,
mainComposerScope,
stashSessionDraft,
takeSessionDraft
} from '@/store/composer'
import { requestGatewayForAgent, requestGatewayForProfile } from '@/store/gateway'
import { $newChatRoute } from '@/store/profile'
import {
$activeSessionId,
$selectedStoredSessionId,
_resetSessionOwnerHintsForTests,
setActiveSessionId,
setAwaitingResponse,
setBusy,
setMessages,
setSelectedStoredSessionId,
setSessions,
setYoloActive
} from '@/store/session'
import { _resetSessionOwnerHoldsForTests } from '@/store/session-states'
import { composerPlainText } from '../rich-editor'
import type { ChatBarProps } from '../types'
import { useComposerDraft } from './use-composer-draft'
import { useComposerSubmit } from './use-composer-submit'
// Substitute the assistant-ui adapter and transport, not the draft DOM,
// subscription, persistence, creation, submit actions, cache or dispatcher.
// One test injects onSubmit to exercise a direct rejected promise too.
const composer = vi.hoisted(() => {
let text = ''
const listeners = new Set<() => void>()
const runtime = {
getState: () => ({ text }),
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
}
const api = {
setText: (value: string) => {
text = value
listeners.forEach(listener => listener())
}
}
return { aui: { composer: () => api }, runtime }
})
vi.mock('@assistant-ui/react', () => ({
useAui: () => composer.aui,
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) =>
selector({ composer: composer.runtime.getState() }),
useComposerRuntime: () => composer.runtime
}))
vi.mock('@/store/gateway', async original => ({
...(await original<Record<string, unknown>>()),
requestGatewayForAgent: vi.fn(),
requestGatewayForProfile: vi.fn(),
retainGatewayForAgent: vi.fn(async () => () => {}),
retainGatewayForSessionTurn: vi.fn(async () => () => {})
}))
vi.mock('@/store/profile', async original => ({
...(await original<Record<string, unknown>>()),
ensureGatewayAgent: vi.fn(async () => undefined),
ensureGatewayProfile: vi.fn(async () => undefined)
}))
let handles: {
cache: ReturnType<typeof useSessionStateCache>
draft: ReturnType<typeof useComposerDraft>
submit: ReturnType<typeof useComposerSubmit>
}
let route: string | null = 'stored-B'
const busyRef = { current: false }
const getRouteToken = () => `${route ? sessionRoute(route) : NEW_CHAT_ROUTE}::`
interface HarnessProps {
pendingScope?: string
suspend?: Promise<void>
onSubmit?: ChatBarProps['onSubmit']
}
function PendingNavigation({ suspend }: Pick<HarnessProps, 'suspend'>) {
if (suspend) {
throw suspend
}
return null
}
function Harness({ pendingScope, suspend, onSubmit }: HarnessProps) {
const active = useStore($activeSessionId)
const selected = useStore($selectedStoredSessionId)
const attachments = useStore(mainComposerScope.$attachments)
const queueEditRef = useRef(null)
const creatingSessionRef = useRef(false)
const cache = useSessionStateCache({
activeSessionId: active,
selectedStoredSessionId: selected,
busyRef,
setBusy,
setAwaitingResponse,
setMessages
})
const request = createSessionRpcDispatcher({
ambientRequest: async () => {
throw new Error('unexpected ambient request')
},
runtimeIdByStoredSessionIdRef: cache.runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef: cache.sessionStateByRuntimeIdRef
})
const sessionActions = useSessionActions({
activeSessionId: active,
selectedStoredSessionId: selected,
...cache,
busyRef,
creatingSessionRef,
getRoutedStoredSessionId: () => route,
getRouteToken,
navigate: to => {
route = routeSessionId(String(to))
},
requestGateway: request
})
const actions = usePromptActions({
activeSessionId: active,
...cache,
busyRef,
branchCurrentSession: async () => false,
createBackendSessionForSend: sessionActions.createBackendSessionForSend,
getRoutedStoredSessionId: () => route,
getRouteToken,
handleSkinCommand: () => '',
openMemoryGraph: () => {},
refreshSessions: async () => {},
requestGateway: request,
resumeStoredSession: async () => {
throw new Error('unexpected resume')
},
startFreshSessionDraft: () => {},
sttEnabled: false
})
const activeQueueSessionKey = pendingScope ?? selected
const draft = useComposerDraft({
activeQueueSessionKey,
focusKey: active,
inputDisabled: false,
queueEditRef,
sessionId: active
})
const submit = useComposerSubmit({
...draft,
activeQueueSessionKey,
attachments,
busy: false,
compacting: false,
disabled: false,
inputDisabled: false,
drainNextQueued: async () => false,
exitQueuedEdit: () => false,
onCancel: () => {},
onSteer: actions.redirectPrompt,
onSteerHidden: actions.injectHiddenPrompt,
onSubmit: onSubmit ?? actions.submitText,
queueCurrentDraft: () => false,
queueEdit: null,
queuedPrompts: [],
sessionId: active
})
// Keep the test's actions bound to the committed tree during a transition.
useLayoutEffect(() => {
handles = { cache, draft, submit }
})
return (
<>
<div contentEditable data-testid="editor" ref={draft.editorRef} />
<PendingNavigation suspend={suspend} />
</>
)
}
function view(props: HarnessProps = {}) {
return (
<Suspense fallback={<div>Pending navigation</div>}>
<Harness {...props} />
</Suspense>
)
}
function navigate(session: 'A' | 'B' | null) {
route = session ? `stored-${session}` : null
setSelectedStoredSessionId(route)
setActiveSessionId(session ? `rt-${session}` : null)
busyRef.current = false
setBusy(false)
}
function seed(props: HarnessProps = {}, initial: 'B' | null = 'B') {
setSessions(
(initial ? ['A', 'B'] : ['A']).map(id => ({
id: `stored-${id}`,
profile: 'default',
source: 'desktop',
message_count: 1,
ended_at: null,
input_tokens: 0,
output_tokens: 0,
is_active: true,
last_active: 1,
started_at: 1,
model: null,
preview: null,
title: null,
tool_call_count: 0
}))
)
navigate(initial)
const rendered = render(view(props))
act(() => {
handles.cache.ensureSessionState('rt-A', 'stored-A')
if (initial) {
handles.cache.ensureSessionState('rt-B', 'stored-B')
}
})
return rendered
}
const editorText = () => composerPlainText(handles.draft.editorRef.current!).trim()
const flushDraft = () => act(() => window.dispatchEvent(new Event('pagehide')))
afterEach(() => {
cleanup()
mainComposerScope.clear()
clearSessionDraft('stored-A')
clearSessionDraft('stored-B')
clearSessionDraft(null)
_resetSessionOwnerHintsForTests({ storage: true })
_resetSessionOwnerHoldsForTests()
setYoloActive(false)
announceNewSessionDraftKey(null)
$newChatRoute.set(null)
window.localStorage.clear()
vi.resetAllMocks()
setActiveSessionId(null)
setSelectedStoredSessionId(null)
setSessions([])
setBusy(false)
setAwaitingResponse(false)
setMessages([])
})
it('keeps a late rejected B submit out of A’s draft and next ordinary Send (#66661)', async () => {
let rejectSubmit!: (error: Error) => void
vi.mocked(requestGatewayForProfile).mockImplementation(async (_profile, method) => {
if (method !== 'prompt.submit') {
throw new Error(`unexpected ${method}`)
}
return await new Promise((_resolve, reject) => {
rejectSubmit = reject
})
})
stashSessionDraft('stored-A', 'A’s own next question', [])
stashSessionDraft('stored-B', 'B’s research report', [])
seed()
act(() => handles.submit.submitDraft())
await waitFor(() =>
expect(requestGatewayForProfile).toHaveBeenCalledWith(
'default',
'prompt.submit',
expect.objectContaining({ session_id: 'rt-B', text: 'B’s research report' }),
expect.anything(),
undefined
)
)
act(() => navigate('A'))
expect(editorText()).toBe('A’s own next question')
// The real action converts a transport rejection into accepted === false.
await act(async () => {
rejectSubmit(new Error('fixture transport rejected B submit'))
})
expect(editorText()).toBe('A’s own next question')
flushDraft()
expect(takeSessionDraft('stored-A').text.trim()).toBe('A’s own next question')
expect(takeSessionDraft('stored-B').text.trim()).toBe('B’s research report')
vi.mocked(requestGatewayForProfile).mockResolvedValue({ status: 'streaming' })
act(() => handles.submit.submitDraft())
await waitFor(() => expect(requestGatewayForProfile).toHaveBeenCalledTimes(2))
expect(
vi.mocked(requestGatewayForProfile).mock.calls.map(([, method, params]) => ({
method,
session: params?.session_id,
text: params?.text
}))
).toEqual([
{ method: 'prompt.submit', session: 'rt-B', text: 'B’s research report' },
{ method: 'prompt.submit', session: 'rt-A', text: 'A’s own next question' }
])
act(() => navigate('B'))
expect(editorText()).toBe('B’s research report')
})
it('restores text and attachments by the loaded draft owner, not an uncommitted navigation render', async () => {
let rejectSubmit!: (error: Error) => void
const onSubmit = vi.fn<ChatBarProps['onSubmit']>(
() =>
new Promise<boolean>((_resolve, reject) => {
rejectSubmit = reject
})
)
const attachmentA = { id: 'url-a', kind: 'url' as const, label: 'A reference' }
const attachmentB = { id: 'url-b', kind: 'url' as const, label: 'B reference' }
stashSessionDraft('stored-A', 'draft A', [attachmentA])
stashSessionDraft('stored-B', 'draft B', [attachmentB])
const rendered = seed({ onSubmit })
act(() => handles.submit.submitDraft())
expect(onSubmit).toHaveBeenCalledWith(expect.stringContaining('draft B'), {
attachments: [attachmentB],
composerScope: 'stored-B'
})
act(() => navigate('A'))
// React starts rendering B again but cannot commit it. The render-time
// queue ref now says B; the actual editor and draft-swap owner remain A.
const suspended = new Promise<void>(() => {})
await act(async () => {
startTransition(() => rendered.rerender(view({ onSubmit, pendingScope: 'stored-B', suspend: suspended })))
})
expect(handles.draft.activeQueueSessionKeyRef.current).toBe('stored-B')
expect(editorText()).toBe('draft A')
await act(async () => {
rejectSubmit(new Error('fixture rejected onSubmit promise'))
})
expect(editorText()).toBe('draft A')
expect(mainComposerScope.$attachments.get()).toEqual([attachmentA])
flushDraft()
expect(takeSessionDraft('stored-A').text.trim()).toBe('draft A')
expect(takeSessionDraft('stored-A').attachments).toEqual([attachmentA])
expect(takeSessionDraft('stored-B').text.trim()).toBe('draft B')
expect(takeSessionDraft('stored-B').attachments).toEqual([attachmentB])
// Abandon the pending render, then really switch. Ordinary draft restore
// recovers B; a rejection while B stays loaded must still repaint it.
rendered.rerender(view({ onSubmit }))
act(() => navigate('B'))
expect(editorText()).toBe('draft B')
expect(mainComposerScope.$attachments.get()).toEqual([attachmentB])
await act(async () => {
startTransition(() => rendered.rerender(view({ onSubmit, pendingScope: 'stored-A', suspend: suspended })))
})
expect(handles.draft.activeQueueSessionKeyRef.current).toBe('stored-A')
onSubmit.mockResolvedValueOnce(false)
await act(async () => handles.submit.submitDraft())
expect(onSubmit).toHaveBeenLastCalledWith(expect.stringContaining('draft B'), {
attachments: [attachmentB],
composerScope: 'stored-B'
})
expect(editorText()).toBe('draft B')
expect(mainComposerScope.$attachments.get()).toEqual([attachmentB])
expect(takeSessionDraft('stored-B').text.trim()).toBe('draft B')
})
it.each([
{ destination: 'B', beforeCreateReturns: false },
{ destination: 'A', beforeCreateReturns: false },
{ destination: null, beforeCreateReturns: false },
{ destination: 'A', beforeCreateReturns: true }
] as const)(
'restores a rejected real first send to its created scope ($destination, before create returns: $beforeCreateReturns)',
async ({ destination, beforeCreateReturns }) => {
const attachment = { id: 'url-first', kind: 'url' as const, label: 'first reference' }
stashSessionDraft(null, 'my first question', [attachment])
stashSessionDraft('stored-A', 'A’s own draft', [])
$newChatRoute.set({ connectionId: 'connection-first-send', profile: 'default' })
setYoloActive(beforeCreateReturns)
let rejectRequest!: (error: Error) => void
vi.mocked(requestGatewayForAgent).mockImplementation(async (_connection, _profile, method) => {
if (method === 'session.create') {
return { session_id: 'rt-B', stored_session_id: 'stored-B', info: {} }
}
if (method === (beforeCreateReturns ? 'config.set' : 'prompt.submit')) {
return new Promise((_resolve, reject) => {
rejectRequest = reject
})
}
throw new Error(`unexpected ${method}`)
})
seed({}, null)
act(() => handles.submit.submitDraft())
await waitFor(() => expect(rejectRequest).toBeTypeOf('function'))
expect(route).toBe('stored-B')
expect(handles.draft.draftScopeRef.current).toBe('stored-B')
expect(editorText()).toBe('')
expect(mainComposerScope.$attachments.get()).toEqual([])
expect(vi.mocked(requestGatewayForAgent).mock.calls.map(([, , method]) => method)).toEqual([
'session.create',
beforeCreateReturns ? 'config.set' : 'prompt.submit'
])
if (destination !== 'B') {
// A different stored chat OR a new pre-session draft is not the owner
// of this submit, even though this submit originally captured null.
stashSessionDraft(null, 'another fresh draft', [])
act(() => navigate(destination))
}
// Creation can await an armed-YOLO config write after it assigns the
// stored key. Its eventual drift abort must still restore to that key.
await act(async () => rejectRequest(new Error('fixture transport rejected first send')))
expect(editorText()).toBe(
destination === 'B' ? 'my first question' : destination === 'A' ? 'A’s own draft' : 'another fresh draft'
)
expect(mainComposerScope.$attachments.get()).toEqual(destination === 'B' ? [attachment] : [])
flushDraft()
expect(takeSessionDraft('stored-B').text.trim()).toBe('my first question')
expect(takeSessionDraft('stored-B').attachments).toEqual([attachment])
expect(takeSessionDraft('stored-A').text.trim()).toBe('A’s own draft')
expect(takeSessionDraft(null).text.trim()).toBe(destination === 'B' ? '' : 'another fresh draft')
act(() => navigate('B'))
expect(editorText()).toBe('my first question')
expect(mainComposerScope.$attachments.get()).toEqual([attachment])
}
)
it('does not treat arbitrary navigation during a real create as assignment of the rejected draft', async () => {
stashSessionDraft(null, 'unsent first question', [])
stashSessionDraft('stored-A', 'A’s own draft', [])
$newChatRoute.set({ connectionId: 'connection-aborted-create', profile: 'default' })
let completeCreate!: (value: unknown) => void
vi.mocked(requestGatewayForAgent).mockImplementation(async (_connection, _profile, method) => {
if (method === 'session.create') {
return new Promise(resolve => {
completeCreate = resolve
})
}
if (method === 'session.close') {
return {}
}
throw new Error(`unexpected ${method}`)
})
seed({}, null)
act(() => handles.submit.submitDraft())
await waitFor(() => expect(completeCreate).toBeTypeOf('function'))
act(() => navigate('A'))
await act(async () => completeCreate({ session_id: 'rt-B', stored_session_id: 'stored-B', info: {} }))
expect(vi.mocked(requestGatewayForAgent).mock.calls.map(([, , method]) => method)).toEqual([
'session.create',
'session.close'
])
expect(route).toBe('stored-A')
expect(editorText()).toBe('A’s own draft')
flushDraft()
expect(takeSessionDraft('stored-A').text.trim()).toBe('A’s own draft')
expect(takeSessionDraft('stored-B').text.trim()).toBe('')
expect(takeSessionDraft(null).text.trim()).toBe('unsent first question')
act(() => navigate(null))
expect(editorText()).toBe('unsent first question')
})

View File

@@ -132,7 +132,6 @@ function renderSubmitHook({
return {
clearDraft,
draftScopeRef,
hook,
onCancel,
onSteer,
@@ -486,46 +485,6 @@ describe('useComposerSubmit busy-turn routing', () => {
expect(onSubmit).toHaveBeenCalledWith('hello', expect.objectContaining({ composerScope: 'stored-session' }))
)
})
it('keeps a rejected submit out of the composer after switching sessions', async () => {
let resolveSubmit!: (accepted: boolean) => void
const pendingSubmit = new Promise<boolean>(resolve => {
resolveSubmit = resolve
})
const { draftScopeRef, hook, loadIntoComposer, onSubmit, stashAt } = renderSubmitHook()
onSubmit.mockImplementationOnce(() => pendingSubmit)
act(() => {
hook.result.current.dispatchSubmit('draft from session A')
})
draftScopeRef.current = 'stored-session-b'
act(() => {
resolveSubmit(false)
})
await waitFor(() =>
expect(stashAt).toHaveBeenCalledWith('stored-session', 'draft from session A', [])
)
expect(loadIntoComposer).not.toHaveBeenCalled()
})
it('repaints a rejected submit while its composer remains loaded', async () => {
const { hook, loadIntoComposer, onSubmit, stashAt } = renderSubmitHook()
onSubmit.mockResolvedValueOnce(false)
act(() => {
hook.result.current.dispatchSubmit('draft from this session')
})
await waitFor(() =>
expect(stashAt).toHaveBeenCalledWith('stored-session', 'draft from this session', [])
)
expect(loadIntoComposer).toHaveBeenCalledWith('draft from this session', [])
})
})
describe('useComposerSubmit with a clarify parked on the session', () => {

View File

@@ -92,12 +92,24 @@ export function useComposerSubmit({
// must not publish an old session's text into the newly focused one.
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[], displayKind?: 'hidden') => {
const submittedScope = draftScopeRef.current
let restoreScope = submittedScope
const submittedAttachments = attachments ?? []
const restore = () => {
stashAt(submittedScope, text, submittedAttachments)
// Only this operation's explicit session.create handoff may re-home a
// pre-session submit. A null → stored render can also be user navigation.
const assignment =
submittedScope === null
? {
onComposerScopeAssigned: (scope: string) => {
restoreScope = scope
}
}
: {}
if (draftScopeRef.current === submittedScope) {
const restore = () => {
stashAt(restoreScope, text, submittedAttachments)
if (draftScopeRef.current === restoreScope) {
loadIntoComposer(text, submittedAttachments)
}
}
@@ -108,8 +120,13 @@ export function useComposerSubmit({
void Promise.resolve(
attachments
? onSubmit(text, { attachments, composerScope: submittedScope, ...(displayKind ? { displayKind } : {}) })
: onSubmit(text, { composerScope: submittedScope, ...(displayKind ? { displayKind } : {}) })
? onSubmit(text, {
attachments,
composerScope: submittedScope,
...assignment,
...(displayKind ? { displayKind } : {})
})
: onSubmit(text, { composerScope: submittedScope, ...assignment, ...(displayKind ? { displayKind } : {}) })
)
.then(accepted => void (accepted === false ? rejected() : clearSessionDraft(submittedScope)))
.catch(rejected)

View File

@@ -54,6 +54,7 @@ import type {
ImageAttachResponse,
SessionRedirectResponse
} from '../../../types'
import type { CreateBackendSessionForSend } from '../use-session-actions/create-overrides'
import {
appendMidTurnUserMessage,
@@ -218,7 +219,7 @@ interface PromptActionsOptions {
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
createBackendSessionForSend: CreateBackendSessionForSend
getRoutedStoredSessionId: () => null | string
getRuntimeIdForStoredSession: (storedSessionId: string) => null | string
getRouteToken: () => string

View File

@@ -45,6 +45,7 @@ import {
} from '../../../contrib/hooks/use-background-sync'
import type { ClientSessionState } from '../../../types'
import { sessionContextDrift } from '../session-context-drift'
import type { CreateBackendSessionForSend } from '../use-session-actions/create-overrides'
import { resolveSessionProfile } from '../use-session-actions/utils'
import { registerRecoveredRuntime, singleFlightSessionResume, takeRecoveredRuntime } from './single-flight-resume'
@@ -67,7 +68,7 @@ interface SubmitPromptDeps {
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
copy: Translations['desktop']
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
createBackendSessionForSend: CreateBackendSessionForSend
getRoutedStoredSessionId: () => null | string
getRuntimeIdForStoredSession: (storedSessionId: string) => null | string
getRouteToken: () => string
@@ -731,7 +732,9 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend(bubbleText)
sessionId = await createBackendSessionForSend(bubbleText, undefined, {
onComposerScopeAssigned: options?.onComposerScopeAssigned
})
} catch (err) {
dropOptimistic(null)
releaseBusy()

View File

@@ -703,6 +703,8 @@ export interface SubmitTextOptions {
* (queue drain, steer, external submit requests): the check is a no-op
* without it. */
composerScope?: string | null
/** This submit's fresh draft acquired a stored key. Never fired for navigation. */
onComposerScopeAssigned?: (scope: string) => void
/** What the transcript shows for this send, when it differs from the text
* the agent receives. A `/skill` invocation expands into the whole skill
* body — model-facing scaffolding the UI must never render — so the slash

View File

@@ -10,10 +10,18 @@
* no caller needs one.
*/
export interface SessionCreateOverrides {
/** Renderer-only handoff, fired at the stored-id assignment before navigation. */
onComposerScopeAssigned?: (scope: string) => void
reasoningEffort?: string
title?: string
}
export type CreateBackendSessionForSend = (
preview?: string | null,
seedMessages?: SessionSeedMessage[],
createOverrides?: SessionCreateOverrides
) => Promise<string | null>
export interface SessionSeedMessage {
content: string
display_kind?: 'hidden'

View File

@@ -820,6 +820,7 @@ export function useSessionActions({
// Anything still parked under the pre-session draft bucket belongs
// to this chat now (#114122); the composer moves it on scope swap.
announceNewSessionDraftKey(stored)
createOverrides?.onComposerScopeAssigned?.(stored)
navigate(sessionRoute(stored), { replace: true })
// Other windows (e.g. the main window when this is the pop-out) can't
// see this session until they re-pull the shared list.