diff --git a/frontend/ai.client/src/app/session/services/chat/chat-http.service.spec.ts b/frontend/ai.client/src/app/session/services/chat/chat-http.service.spec.ts index 890ea4f8e..a5579e62c 100644 --- a/frontend/ai.client/src/app/session/services/chat/chat-http.service.spec.ts +++ b/frontend/ai.client/src/app/session/services/chat/chat-http.service.spec.ts @@ -212,11 +212,13 @@ describe('ChatHttpService', () => { vi.restoreAllMocks(); }); - describe('Stop racing the end of the turn', () => { + describe('Stop or page-hide racing the end of the turn', () => { // `done` means the server's turn is over, but loading (and so the Stop // button) only clears on the transport's close, which can trail it — on a // first turn `session_title` may arrive after `done`. A Stop in that - // window must not stamp a false "interrupted" marker on a finished turn. + // window must not stamp a false "interrupted" marker on a finished turn, + // and neither may a page departure: the session is still listed as + // streaming until close, so page-hide attribution would otherwise pick it. const encoder = new TextEncoder(); let controller: AbortController; @@ -310,6 +312,48 @@ describe('ChatHttpService', () => { expect(controller.signal.aborted).toBe(true); }); + it('a page-hide after done but before close sends no navigated_away', async () => { + chatStateService.streamingSessionIds.mockReturnValue(['s1']); + openStream('event: message_start\ndata: {"role":"assistant"}\n\nevent: done\ndata: {}\n\n'); + + const streaming = service.sendChatRequest({ session_id: 's1', message: 'hi' }); + await vi.waitFor(() => expect(parser.hasReceivedDone('s1')).toBe(true)); + expect(chatStateService.releaseAbortController).not.toHaveBeenCalled(); + + window.dispatchEvent(new Event('pagehide')); + + expect(interruptPosts()).toHaveLength(0); + // Attribution never intervenes, so the stream is left to close itself. + expect(controller.signal.aborted).toBe(false); + controller.abort(); + await streaming; + }); + + it('a page-hide mid-stream still signals navigated_away', async () => { + chatStateService.streamingSessionIds.mockReturnValue(['s1']); + openStream('event: message_start\ndata: {"role":"assistant"}\n\n'); + + const streaming = service.sendChatRequest({ session_id: 's1', message: 'hi' }); + await vi.waitFor(() => + expect(parser.parseEventSourceMessage).toHaveBeenCalledWith( + 's1', + 'message_start', + expect.anything(), + 'stream-1', + ), + ); + + window.dispatchEvent(new Event('pagehide')); + + const posts = interruptPosts(); + expect(posts).toHaveLength(1); + expect(String(posts[0][0])).toContain('/sessions/s1/interrupt'); + expect(JSON.parse(String(posts[0][1].body))).toEqual({ reason: 'navigated_away' }); + expect(posts[0][1].keepalive).toBe(true); + controller.abort(); + await streaming; + }); + it('runs the title fallback the skipped onclose would have, and no interrupted-turn cost refresh', async () => { vi.useFakeTimers(); try { diff --git a/frontend/ai.client/src/app/session/services/chat/chat-http.service.ts b/frontend/ai.client/src/app/session/services/chat/chat-http.service.ts index c8895ed5d..cde084e70 100644 --- a/frontend/ai.client/src/app/session/services/chat/chat-http.service.ts +++ b/frontend/ai.client/src/app/session/services/chat/chat-http.service.ts @@ -490,6 +490,12 @@ export class ChatHttpService { if (typeof window === 'undefined') return; const onPageHide = () => { for (const sessionId of this.chatStateService.streamingSessionIds()) { + // A session stays "streaming" until the transport closes, which can + // trail `done`. A departure in that window interrupts nothing, and + // the backend's lease gate can't be relied on to drop it — the lease + // is released in the stream generator's `finally`, which can itself + // come after `done` — so skip it here, as the Stop path does. + if (this.streamParserService.hasReceivedDone(sessionId)) continue; this.signalInterrupt(sessionId, 'navigated_away'); } };