diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index a06ee1294c08..268897570761 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1711,6 +1711,157 @@ describe('usePromptActions sleep/wake session recovery', () => { }) }) +describe('usePromptActions new-chat attachment drift (#65733)', () => { + const CREATED_RUNTIME_ID = 'rt-new-chat' + const CREATED_STORED_ID = 'stored-new-chat' + + afterEach(() => { + cleanup() + $connection.set(null) + vi.restoreAllMocks() + }) + + function imageAttachment(): ComposerAttachment { + return { + id: 'image:pic.png', + kind: 'image', + label: 'pic.png', + path: '/Users/alice/Pictures/pic.png' + } + } + + it('submits a new chat with an image — the late route re-home landing during upload is not user drift', async () => { + // Remote mode + a brand-new chat + an image attachment is the exact #65733 + // repro. createBackendSessionForSend re-homes the session refs synchronously + // and (in the real app) calls navigate(), but that navigate only flips the + // route token on the NEXT render — which here lands WHILE image.attach_bytes + // is in flight. The pre-fix drift check read that self re-home as a user + // session switch and silently aborted: no prompt.submit, nothing on disk. + $connection.set({ mode: 'remote' } as never) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl: vi.fn(async () => 'data:image/png;base64,aGVsbG8=') } + }) + + const activeSessionIdRef: MutableRefObject = { current: null } + const selectedStoredSessionIdRef: MutableRefObject = { current: null } + + // The pre-create route; the created chat's route only lands on a later + // render, simulated by flipping this while the upload is awaited. + let routeToken = 'route-new-draft' + const getRouteToken = () => routeToken + + // Mirror the real create: session refs retarget synchronously before it + // returns. The route token is deliberately NOT flipped here — that is the + // deferred navigate() that lands during the upload below. + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = CREATED_RUNTIME_ID + selectedStoredSessionIdRef.current = CREATED_STORED_ID + + return CREATED_RUNTIME_ID + }) + + const calls: { method: string; params?: Record }[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'image.attach_bytes') { + // The create's navigate() re-home lands here, mid-upload. + routeToken = 'route-created-session' + + return { attached: true, path: '/remote/work/.hermes/desktop-attachments/pic.png' } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={null} + /> + ) + + const ok = await handle!.submitText('look at this', { attachments: [imageAttachment()] }) + + // The submit must reach the gateway against the chat create minted, not + // bounce back to the composer. + expect(ok).toBe(true) + expect(createBackendSessionForSend).toHaveBeenCalledTimes(1) + + const submit = calls.find(c => c.method === 'prompt.submit') + expect(submit).toBeDefined() + expect(submit?.params).toMatchObject({ session_id: CREATED_RUNTIME_ID }) + expect(calls.map(c => c.method)).toEqual(['image.attach_bytes', 'prompt.submit']) + }) + + it('still aborts when the user genuinely switches away during the image upload', async () => { + // The fix must not blind the drift guard: a REAL switch mid-upload still + // retargets the session refs synchronously, so the created-session drift + // check catches it and the send is dropped rather than misrouted. + $connection.set({ mode: 'remote' } as never) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl: vi.fn(async () => 'data:image/png;base64,aGVsbG8=') } + }) + + const activeSessionIdRef: MutableRefObject = { current: null } + const selectedStoredSessionIdRef: MutableRefObject = { current: null } + + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = CREATED_RUNTIME_ID + selectedStoredSessionIdRef.current = CREATED_STORED_ID + + return CREATED_RUNTIME_ID + }) + + const calls: string[] = [] + + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + + if (method === 'image.attach_bytes') { + // User switches to another chat while the upload is in flight: every + // switch path retargets these refs synchronously. + activeSessionIdRef.current = 'rt-other-chat' + selectedStoredSessionIdRef.current = 'stored-other-chat' + + return { attached: true, path: '/remote/work/.hermes/desktop-attachments/pic.png' } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={null} + /> + ) + + const ok = await handle!.submitText('look at this', { attachments: [imageAttachment()] }) + + expect(ok).toBe(false) + expect(calls).not.toContain('prompt.submit') + }) +}) + describe('usePromptActions submit session-context isolation (#54527)', () => { const STORED_SESSION_A = 'stored-project-a' const STORED_SESSION_B = 'stored-project-b' diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 709652dce7bf..80ed8f7b6da8 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -175,9 +175,23 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { let startingRouteToken = getRouteToken() + // Set once this submit itself mints a new chat (see the re-pin after + // createBackendSessionForSend): that create re-homes selection + route to + // the session it made, but the navigate() only lands on the NEXT React + // render. A submit that then awaits real work — an image/file upload — + // sees the route token flip mid-flight and would misread our own re-home + // as a user session switch, silently aborting EVERY first send that + // carries an attachment (#65733). Inside that window drift is judged by + // the session refs — every real switch retargets them synchronously — + // instead of the deferred route token. + let createdSessionId: null | string = null + const sessionContextDrifted = (): boolean => targetStartedInCurrentView && - (selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken) + (createdSessionId !== null + ? activeSessionIdRef.current !== createdSessionId || + selectedStoredSessionIdRef.current !== startingStoredSessionId + : selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken) const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionContextDrifted() @@ -428,8 +442,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // Re-pin the baseline to the created chat for the rest of the // pipeline; the closures (seedOptimistic et al) see the new value. + // From here on drift is judged against the created session id, not the + // route token: createBackendSessionForSend's navigate() re-homes the + // route on a LATER render, so a following upload await would otherwise + // read our own re-home as a user switch (#65733). startingStoredSessionId = selectedStoredSessionIdRef.current startingRouteToken = getRouteToken() + createdSessionId = sessionId seedOptimistic(sessionId) }