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')