Skip to content
Closed
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
103 changes: 102 additions & 1 deletion apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1294,7 +1294,14 @@ describe('usePromptActions sleep/wake session recovery', () => {
})

it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => {
const createBackendSessionForSend = vi.fn(async () => RUNTIME_SESSION_ID)
const activeSessionIdRef: MutableRefObject<string | null> = { current: null }

const createBackendSessionForSend = vi.fn(async () => {
activeSessionIdRef.current = RUNTIME_SESSION_ID

return RUNTIME_SESSION_ID
})

const calls: string[] = []

const requestGateway = vi.fn(async (method: string) => {
Expand All @@ -1307,6 +1314,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
render(
<Harness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
createBackendSessionForSend={createBackendSessionForSend}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
Expand All @@ -1321,6 +1329,99 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(createBackendSessionForSend).toHaveBeenCalledTimes(1)
expect(calls).not.toContain('session.resume')
})

it('submits the first message after session creation routes to the newly titled chat', async () => {
const activeSessionIdRef: MutableRefObject<string | null> = { current: null }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
let routeToken = '/'
const calls: { method: string; params?: Record<string, unknown> }[] = []

const createBackendSessionForSend = vi.fn(async () => {
activeSessionIdRef.current = RUNTIME_SESSION_ID
selectedStoredSessionIdRef.current = STORED_SESSION_ID
routeToken = `/${STORED_SESSION_ID}`

return RUNTIME_SESSION_ID
})

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })

return {} as never
})

let handle: HarnessHandle | null = null
render(
<Harness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
createBackendSessionForSend={createBackendSessionForSend}
getRouteToken={() => routeToken}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
storedSessionId={null}
/>
)

const ok = await handle!.submitText('first message becomes the title and must also send')

expect(ok).toBe(true)
expect(calls).toContainEqual({
method: 'prompt.submit',
params: {
session_id: RUNTIME_SESSION_ID,
text: 'first message becomes the title and must also send'
}
})
})

it('aborts when the user switches chats during post-create async work', async () => {
const activeSessionIdRef: MutableRefObject<string | null> = { current: null }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
let routeToken = '/'
const calls: string[] = []

const createBackendSessionForSend = vi.fn(async () => {
// session.create performs its intentional promotion first.
activeSessionIdRef.current = RUNTIME_SESSION_ID
selectedStoredSessionIdRef.current = STORED_SESSION_ID
routeToken = `/${STORED_SESSION_ID}`
await Promise.resolve()

// Then the user selects another chat while post-create work is pending.
activeSessionIdRef.current = 'runtime-session-b'
selectedStoredSessionIdRef.current = 'stored-session-b'
routeToken = '/stored-session-b'

return RUNTIME_SESSION_ID
})

const requestGateway = vi.fn(async (method: string) => {
calls.push(method)

return {} as never
})

let handle: HarnessHandle | null = null
render(
<Harness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
createBackendSessionForSend={createBackendSessionForSend}
getRouteToken={() => routeToken}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
storedSessionId={null}
/>
)

expect(await handle!.submitText('must not follow the user into chat B')).toBe(false)
expect(calls).not.toContain('prompt.submit')
})
})

describe('usePromptActions submit session-context isolation (#54527)', () => {
Expand Down
31 changes: 24 additions & 7 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { setAwaitingResponse, setBusy, setMessages } from '@/store/session'

import { sessionRoute } from '../../../routes'
import type { ClientSessionState } from '../../../types'

import {
Expand Down Expand Up @@ -120,11 +121,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// redirect the user's text into a different chat (#54527).
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
let expectedStoredSessionId = startingStoredSessionId
let expectedRouteToken = getRouteToken()

const sessionContextDrifted = (): boolean =>
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
selectedStoredSessionIdRef.current !== expectedStoredSessionId || getRouteToken() !== expectedRouteToken

// One submit in flight per session — drop any concurrent re-fire so a
// stalled turn can't stack the same prompt into multiple real turns.
Expand Down Expand Up @@ -281,10 +282,6 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return false
}

if (sessionContextDrifted()) {
return abortForSessionSwitch(sessionId)
}

if (!sessionId) {
dropOptimistic(null)
releaseBusy()
Expand All @@ -293,6 +290,26 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return false
}

// session.create deliberately promotes the fresh draft onto its new
// stored-session route. Accept only that exact promotion: post-create
// work (for example applying an armed YOLO mode) can await before this
// function regains control, so the user may genuinely switch chats in
// that window. Never rebase the guard onto an unrelated destination.
const promotedStoredSessionId = selectedStoredSessionIdRef.current
const promotedRouteToken = getRouteToken()

const promotionIsCurrent =
activeSessionIdRef.current === sessionId &&
(promotedStoredSessionId
? promotedRouteToken === sessionRoute(promotedStoredSessionId)
: promotedRouteToken === expectedRouteToken)

if (!promotionIsCurrent) {
return abortForSessionSwitch(sessionId)
}

expectedStoredSessionId = promotedStoredSessionId
expectedRouteToken = promotedRouteToken
seedOptimistic(sessionId)
}

Expand Down