Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> = { current: null }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { 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<string, unknown> }[] = []

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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(
<Harness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
createBackendSessionForSend={createBackendSessionForSend}
getRouteToken={getRouteToken}
onReady={h => (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<string | null> = { current: null }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { 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(
<Harness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
createBackendSessionForSend={createBackendSessionForSend}
onReady={h => (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'
Expand Down
21 changes: 20 additions & 1 deletion apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch ignores route changes for the rest of the post-create submit window. openSettings() navigates to SETTINGS_ROUTE without changing either session ref (use-session-actions/index.ts:416-418), so an attachment upload completing after that navigation would still submit. Preserve the route check by accepting only the expected created-session route transition.


const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionContextDrifted()

Expand Down Expand Up @@ -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)
}
Expand Down