diff --git a/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts b/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts index c3ec05b09b..95421beac6 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts @@ -952,14 +952,25 @@ export class FlowChatManager { break } const furthest = Math.max(...batch.map((m) => m.created_seq)) - // A page that moved nothing would ask for the same rows forever. - if (afterSeq !== undefined && furthest <= afterSeq) { - readWhole = true - break - } + // A page that moved nothing would ask for the same rows forever, and is no more a + // finished read than the cap above. + if (afterSeq !== undefined && furthest <= afterSeq) break afterSeq = furthest } + if (!readWhole) { + // Reading stopped before the conversation did, so none of this is a picture of + // it. Appending would stand these rows beside the temp ones already showing the + // same answer, and sweeping would drop the only thing showing what was never + // read. The transcript keeps what it has: the next turn's poll resumes from it, + // and a reload refetches. + console.warn( + `Stopped reading conversation ${conversationId} after ${POLL_MAX_PAGES} pages ` + + `(${response.length} rows, up to seq ${afterSeq}); leaving the transcript as it is` + ) + return + } + if (options?.isNewConversation) { await this.refreshConversations() } @@ -974,15 +985,9 @@ export class FlowChatManager { } } - if (!readWhole) { - // The cap is a guard against a cursor that stops advancing, not a reason to - // believe the conversation ends here. Sweeping now would drop the rows standing - // in for what was never read — the failure this paging exists to prevent. - console.warn(`Stopped reading conversation ${conversationId} after ${POLL_MAX_PAGES} pages`) - } // Only remove temporary messages when explicitly requested (e.g., after job completion) // During streaming, we keep temp messages to avoid them disappearing due to race conditions - if (options?.removeTempMessages && readWhole) { + if (options?.removeTempMessages) { this.#rowsById[conversationId] = this.#rowsOf(conversationId).filter( (msg) => !msg.id.startsWith('temp-') || msg.message_type === 'user' ) diff --git a/frontend/src/lib/components/flows/conversations/FlowChatManager.test.ts b/frontend/src/lib/components/flows/conversations/FlowChatManager.test.ts index b6782fabf2..e624ca7bf3 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatManager.test.ts +++ b/frontend/src/lib/components/flows/conversations/FlowChatManager.test.ts @@ -205,6 +205,38 @@ describe('reading a turn longer than one page', () => { const calls = vi.mocked(FlowConversationsService.listConversationMessages).mock.calls expect((calls[1][0] as any).afterSeq).toBe(50) }) + + /** + * Reading can stop before the conversation does. What was read is then not a picture of + * it, and applying it would both duplicate what the temp rows already show and sweep + * away the only record of what was never read. + */ + it('leaves the transcript alone when it could not read to the end', async () => { + let seq = 0 + vi.mocked(FlowConversationsService.listConversationMessages) + .mockReset() + .mockImplementation((async () => { + const batch = assistantRows(seq + 1, 50) + seq += 50 + return batch + }) as any) + const manager = managerWithRows() + manager.selectedConversationId = 'a' + const streamed = { + id: 'temp-answer', + conversation_id: 'a', + message_type: 'assistant', + content: 'the answer as it streamed', + created_at: new Date().toISOString(), + created_seq: 0 + } + manager.messages = [streamed as any] + + await (manager as any).pollConversationMessages('a', { removeTempMessages: true }) + + // Untouched: neither the rows it managed to read nor the sweep were applied. + expect(manager.messages).toEqual([streamed]) + }) }) /**