Skip to content
Merged
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 @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
};
Expand Down