From 3e90c8f5d9689bc20d914eeb59e299f1b5a2009a Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 15 Sep 2026 17:56:38 +0200 Subject: [PATCH] fix(chat): a partial read of a conversation changes nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeping the temp rows on a capped read was half the answer: the rows that were read got appended anyway, so the reader would have seen the same range twice with the tail still missing. Reading that stops before the conversation does is not a picture of it, so none of it is applied — the transcript keeps what it has, the next turn's poll resumes from it, and a reload refetches. A cursor that stops advancing takes the same route. It was marked a whole read, which is the opposite of what it is: a full page came back, so rows almost certainly remain. Unreachable against a backend that filters on the cursor, but the two guards should not disagree about what they mean. The warning carries how far it got, since the conversation id alone does not say how far behind the transcript is. Co-Authored-By: Claude Opus 5 (1M context) --- .../conversations/FlowChatManager.svelte.ts | 29 ++++++++++------- .../conversations/FlowChatManager.test.ts | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) 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]) + }) }) /**