fix(chat): a partial read of a conversation changes nothing

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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-15 17:56:38 +02:00
co-authored by Claude Opus 5
parent ad847bfa78
commit 3e90c8f5d9
2 changed files with 49 additions and 12 deletions
@@ -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'
)
@@ -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])
})
})
/**