From 96db2d8431074bc21ab89a369f25e584921c0ac2 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 16 Sep 2026 15:49:18 +0200 Subject: [PATCH] fix(chat): look for the answer itself, and finish a reopened turn too A turn's rows are written by tasks the run does not wait for, so their order says nothing: a tool row can land after the answer and an empty thinking row before it. Reading the answer off the last row therefore called an answered turn unanswered and showed the run's result beside the answer it already had, and called an unanswered one answered and showed nothing at all. An answer is any assistant row with something in it, which neither arrival can disturb. A conversation reopened at the moment its run finishes took the third way out of a turn and the only one that ended it on the spot: no read back, no result to fall back on. The last rows of such a turn are exactly the ones still on their way, so it ends the way the turns that were followed end. Co-Authored-By: Claude Opus 5 (1M context) --- .../conversations/FlowChatManager.svelte.ts | 25 +++++++---- .../conversations/FlowChatManager.test.ts | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts b/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts index 099caeb228..030c5c0616 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts @@ -1442,10 +1442,14 @@ export class FlowChatManager { for (;;) { if (!this.#isCurrent(turn)) return false try { - const { completed } = await api.getCompletedResult(jobId, turn.signal) - // Nothing to take over, so the turn opened to ask the question goes with it. + const { completed, result } = await api.getCompletedResult(jobId, turn.signal) + // Over already, so there is no run to follow — but a turn reopened at the + // moment it finishes is a turn whose last rows may still be on their way, + // and it is finished here the same way the turns that were followed are. if (completed) { - this.#endTurnIfCurrent(turn) + await this.#reconcileTurn(turn) + if (!this.#isCurrent(turn)) return false + await this.#finishTurn(turn, jobId, result) return false } break @@ -1541,17 +1545,20 @@ export class FlowChatManager { * Whether the transcript already shows this turn's answer. * * Asked of the rows after the message the turn answers, because that is where its own - * work begins — a count of rows says nothing, since a turn writes tool and thinking rows - * whose arrival has no bearing on whether the answer came. A turn whose question is no - * longer on screen is left alone: without the boundary there is nothing to be sure of, - * and showing an answer twice is the worse mistake. + * work begins. An answer is any assistant row with something in it — not the last row, + * which says nothing: a turn's rows are written by tasks the run does not wait for, so a + * tool row can land after the answer and a thinking row before it, and neither arrival + * bears on whether the answer came. A turn whose question is no longer on screen is left + * alone: without the boundary there is nothing to be sure of, and showing an answer twice + * is the worse mistake. */ #turnAnswered(turn: Turn): boolean { const rows = this.#rowsOf(turn.conversationId) const asked = rows.findIndex((row) => row.id === turn.userRowId) if (asked < 0) return true - const last = rows[rows.length - 1] - return rows.length > asked + 1 && last?.message_type === 'assistant' + return rows + .slice(asked + 1) + .some((row) => row.message_type === 'assistant' && row.content !== '') } /** diff --git a/frontend/src/lib/components/flows/conversations/FlowChatManager.test.ts b/frontend/src/lib/components/flows/conversations/FlowChatManager.test.ts index e13f1116c2..06d1996a48 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatManager.test.ts +++ b/frontend/src/lib/components/flows/conversations/FlowChatManager.test.ts @@ -1026,6 +1026,51 @@ describe('a conversation opened while its run is still going', () => { expect(manager.isConversationBusy('a')).toBe(true) }) + /** + * A turn's rows are written by tasks the run does not wait for, so a tool row can land + * after the answer. Reading the answer off the last row would call such a turn unanswered + * and show the run's result beside the answer it already has. + */ + it('finds the answer behind a tool row that landed after it', async () => { + vi.mocked(FlowConversationsService.listConversationMessages) + .mockReset() + .mockResolvedValue([ + { + id: 'db-answer', + conversation_id: 'a', + message_type: 'assistant', + content: 'the answer', + created_at: new Date().toISOString(), + created_seq: 5 + }, + { + id: 'db-tool', + conversation_id: 'a', + message_type: 'tool', + content: 'Used get_time tool', + created_at: new Date().toISOString(), + created_seq: 6 + } + ] as any) + jobCompleted.value = true + jobCompleted.success = true + jobCompleted.result = { windmill_chat_answer: 'the answer' } + const manager = (live = managerWithRows()) + ;(manager as any).initialize( + vi.fn(async () => 'job-1'), + 'u/admin/flow', + false + ) + manager.operatingWorkspace = () => 'ws' + manager.selectedConversationId = 'a' + manager.inputMessage = 'ask' + + await manager.sendMessage(undefined, undefined, 'a') + await vi.waitFor(() => expect(manager.isConversationBusy('a')).toBe(false)) + + expect(manager.messages.filter((m) => m.content === 'the answer')).toHaveLength(1) + }) + it('leaves a conversation whose run is over alone', async () => { jobCompleted.value = true const manager = opened('job-done')