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
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,78 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
})
})

it('submits a non-default-profile file after its own delayed route commit', async () => {
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
})

const calls: { method: string; params?: Record<string, unknown> }[] = []
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
const activeSessionIdRef: MutableRefObject<string | null> = { current: null }
let routeToken = '/?profile=recruiter'

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

if (method === 'file.attach') {
// A background profile's route commits after its session + gateway are
// ready. Upload latency makes that create-owned navigation observable
// here; it is not the user switching to another chat.
routeToken = '/stored-recruiter?profile=recruiter'

return {
attached: true,
ref_text: '@file:.hermes/desktop-attachments/candidate.pdf',
uploaded: true
} as never
}

return {} as never
})

const createBackendSessionForSend = vi.fn(async () => {
activeSessionIdRef.current = 'rt-recruiter'
selectedStoredSessionIdRef.current = 'stored-recruiter'

return 'rt-recruiter'
})

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}
/>
)
await waitFor(() => expect(handle).not.toBeNull())

expect(
await handle!.submitText('', {
attachments: [
{
id: 'file:candidate.pdf',
kind: 'file',
label: 'candidate.pdf',
path: 'C:\\Users\\Admin\\Downloads\\candidate.pdf'
}
]
})
).toBe(true)
expect(calls.find(call => call.method === 'prompt.submit')?.params).toMatchObject({
session_id: 'rt-recruiter',
text: '@file:.hermes/desktop-attachments/candidate.pdf'
})
})

it('aborts when the user switches sessions during the tail of a successful create', async () => {
// createBackendSessionForSend awaits once more (armed-YOLO apply) AFTER
// committing the refs and returning a real id, so a switch in that window
Expand Down
23 changes: 16 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 @@ -139,15 +139,22 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {

// Pin the session context for the whole async submit pipeline. Without
// this, a fast session switch during session.resume / file.attach can
// redirect the user's text into a different chat (#54527). Mutable —
// not const — because a new-chat submit legitimately re-homes to the
// session it creates (see the re-pin after createBackendSessionForSend).
// redirect the user's text into a different chat (#54527).
const startingActiveSessionId = activeSessionIdRef.current
let startingStoredSessionId = selectedStoredSessionIdRef.current
let startingRouteToken = getRouteToken()
const startingRouteToken = getRouteToken()

// Before a runtime session exists, the route is part of the submit's
// identity. Once this submit creates and pins a real session, its own
// navigation may commit later (notably after a background-profile swap
// while file.attach awaits I/O). From then on the stable stored/runtime
// ids are authoritative; treating the delayed route commit as drift
// aborts the first file send and strands an empty session.
let guardRouteToken = true

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

// 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 @@ -332,9 +339,11 @@ 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.
// pipeline; the closures (seedOptimistic et al) see the new value. The
// create's own route navigation can commit asynchronously, so stable
// session ids — not the still-settling route — guard this phase.
startingStoredSessionId = selectedStoredSessionIdRef.current
startingRouteToken = getRouteToken()
guardRouteToken = false

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 disables route drift detection for every post-create route change, not just the created session's delayed navigation. Please retain an expected created-session/semantic route check and add a regression for a route-only change to a different session during attachment sync.


seedOptimistic(sessionId)
}
Expand Down