From fb7f9ee4f5050e95e1b50a61f1d412e58f9f139f Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Fri, 18 Sep 2026 17:25:50 +0200 Subject: [PATCH] fix: follow the turn running now when the listing named one already over Co-Authored-By: Claude Opus 5 (1M context) --- chat-sdk/src/chat.ts | 25 +++++++++++------ chat-sdk/test/chat.test.ts | 57 ++++++++++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/chat-sdk/src/chat.ts b/chat-sdk/src/chat.ts index de15f937c4..e464fe2c1e 100644 --- a/chat-sdk/src/chat.ts +++ b/chat-sdk/src/chat.ts @@ -234,15 +234,24 @@ class ChatImpl implements Chat { await this.#selecting if (!this.#turnActive(turn)) return // A listing is a snapshot: the turn it named can have ended and another one started - // since. Following it would drop the newer turn's rows and leave the chat idle while - // that turn runs, so it is left to the next listing to name the turn that runs now. - const newerTurn = this.#state.messages.some( - (m) => m.role === 'user' && m.seq !== undefined && m.seq > userSeq + // since. The newest user message this chat holds names the turn to follow instead — + // following the one the listing named would drop the newer turn's rows and leave the + // chat idle while it runs. + const newest = this.#state.messages.reduce( + (found, m) => + m.role === 'user' && m.seq !== undefined && m.seq > (found?.seq ?? userSeq) ? m : found, + undefined ) - if (newerTurn) { - if (this.#turn === turn) this.#turn = undefined - this.#set({ status: 'idle' }) - return + if (newest) { + if (!newest.jobId) { + // Its row is written and its run is not named yet: there is nothing to follow, so + // it is left to the next listing to name the turn that runs now. + if (this.#turn === turn) this.#turn = undefined + this.#set({ status: 'idle' }) + return + } + turn.jobId = newest.jobId + userSeq = newest.seq! } // The stream replays the turn from its start, so the rows it already wrote go and // come back as it replays them. The message that started it stays: it is the turn's diff --git a/chat-sdk/test/chat.test.ts b/chat-sdk/test/chat.test.ts index c917dd82c0..d7bc1b3cf0 100644 --- a/chat-sdk/test/chat.test.ts +++ b/chat-sdk/test/chat.test.ts @@ -966,25 +966,64 @@ describe('createChat with server history', () => { expect(state.conversations.map((c) => c.id)).toEqual(['conv']) }) - test('a running turn the listing named is not followed once a newer one has started', async () => { + test('a listing that names a turn already over follows the one running now', async () => { + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2' + ? sse([ + { + type: 'update', + new_result_stream: ndjson({ type: 'token_delta', content: 'second answer' }), + stream_offset: 1, + completed: true, + only_result: { windmill_chat_answer: 'second answer' } + } + ]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-2') + ? json({ flow_status: { modules: [{ job: 'step-2' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })]) + return json([ + messageRow(50, 'user', 'first'), + messageRow(51, 'assistant', 'first answer'), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + // The listing named the first turn; it ended and the second one started before this select. + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-1'))).toBe(false) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(true) + expect(chat.getState().messages.map((m) => [m.serverId, m.content])).toEqual([ + ['row-50', 'first'], + ['row-51', 'first answer'], + ['row-52', 'second'], + ['row-53', 'second answer'] + ]) + expect(chat.getState().status).toBe('idle') + expect(chat.getState().error).toBeUndefined() + }) + + test('a newer message whose run is not named yet leaves the chat free', async () => { const { fetch, calls } = fetchMock( (c) => c.url.pathname.endsWith('/messages') - ? json([ - messageRow(50, 'user', 'first'), - messageRow(51, 'assistant', 'first answer'), - messageRow(52, 'user', 'second') - ]) + ? json([messageRow(50, 'user', 'first'), messageRow(52, 'user', 'second')]) : undefined ) const chat = createChat(options({}, fetch)) await chat.selectConversation('conv') - // The listing named the first turn; it ended and another one started before this select. await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) expect(chat.getState().status).toBe('idle') - expect(chat.getState().messages.map((m) => m.serverId)).toEqual(['row-50', 'row-51', 'row-52']) expect(calls.some((c) => c.url.pathname.includes('getupdate_sse'))).toBe(false) - // The chat is free: a message sent now starts its own turn rather than being refused. + // A message sent now starts its own turn rather than being refused. expect(chat.getState().error).toBeUndefined() })