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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-16 15:49:18 +02:00
co-authored by Claude Opus 5
parent 9bc076762b
commit 96db2d8431
2 changed files with 61 additions and 9 deletions
@@ -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 !== '')
}
/**
@@ -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')