diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index 9b6a2a43c33..0eaf2801d12 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -220,6 +220,27 @@ describe('copilot chat stop route', () => { } ) + it('persists completed replay when a late title session event follows completion', async () => { + mockReads({ + chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null }, + last: { messageId: 'stream-1', role: 'user' }, + }) + const envelope = { v: 1, ts: '2026-09-24T19:00:00Z', stream: { streamId: 'stream-1' } } + mockReadEvents.mockResolvedValue([ + { + ...envelope, + seq: 1, + type: 'text', + payload: { channel: 'assistant', text: 'Full response' }, + }, + { ...envelope, seq: 2, type: 'complete', payload: { status: 'cancelled' } }, + { ...envelope, seq: 3, type: 'session', payload: { kind: 'title', title: 'New title' } }, + ]) + const response = await stopRequest(createRequest({ chatId: 'chat-1', streamId: 'stream-1' })) + expect(response.status).toBe(200) + expect(mockAppendCopilotChatMessages.mock.calls[0][1][0].content).toBe('Full response') + }) + it('does not finalize a contiguous prefix before the final event is flushed', async () => { mockReadEvents.mockResolvedValue([ { diff --git a/apps/sim/lib/mothership/chat/persisted-message.test.ts b/apps/sim/lib/mothership/chat/persisted-message.test.ts index 0379fcbe70c..52fa4ab4735 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.test.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.test.ts @@ -17,9 +17,16 @@ import { } from './persisted-message' describe('persisted-message', () => { - it.each([false, true])( - 'cancels unfinished tools even when the stopped marker already exists: %s', - (alreadyStopped) => { + it.each([ + { state: 'executing', alreadyStopped: false }, + { state: 'executing', alreadyStopped: true }, + { state: 'pending', alreadyStopped: false }, + { state: 'pending', alreadyStopped: true }, + { state: 'awaiting_approval', alreadyStopped: false }, + { state: 'awaiting_approval', alreadyStopped: true }, + ] as const)( + 'cancels $state tools (existing stopped marker: $alreadyStopped)', + ({ state, alreadyStopped }) => { const message: PersistedMessage = { id: 'assistant', role: 'assistant', @@ -31,7 +38,7 @@ describe('persisted-message', () => { toolCall: { id: 'unfinished', name: 'run_code', - state: 'executing', + state, params: { code: 'keep me' }, }, }, @@ -47,7 +54,7 @@ describe('persisted-message', () => { }) expect(saved.contentBlocks?.[1].toolCall?.state).toBe('success') expect(saved.contentBlocks?.filter((block) => block.type === 'complete')).toHaveLength(1) - expect(message.contentBlocks?.[0].toolCall?.state).toBe('executing') + expect(message.contentBlocks?.[0].toolCall?.state).toBe(state) } ) diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts index 4cf6169147c..2e15cabb9da 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.ts @@ -360,7 +360,10 @@ export function buildPersistedAssistantMessage( export function withStoppedContentBlock(message: PersistedMessage): PersistedMessage { const contentBlocks = (message.contentBlocks ?? []).map( (block): PersistedContentBlock => - block.toolCall?.state === 'executing' + block.toolCall && + (block.toolCall.state === 'executing' || + block.toolCall.state === 'pending' || + block.toolCall.state === 'awaiting_approval') ? { ...block, toolCall: { diff --git a/apps/sim/lib/mothership/chat/terminal-state.ts b/apps/sim/lib/mothership/chat/terminal-state.ts index 202ecd9cdf6..891ca013608 100644 --- a/apps/sim/lib/mothership/chat/terminal-state.ts +++ b/apps/sim/lib/mothership/chat/terminal-state.ts @@ -41,7 +41,7 @@ export interface FinalizeAssistantTurnResult { outcome: (typeof CopilotChatFinalizeOutcome)[keyof typeof CopilotChatFinalizeOutcome] } -/** Only the matching terminal run and a gap-free replay through its final event can be persisted. */ +/** Require the matching terminal run and gap-free replay containing its completion frame. */ export async function readStoppedAssistantMessage( streamId: string, chatId: string, @@ -50,9 +50,9 @@ export async function readStoppedAssistantMessage( const run = await getLatestRunForStream(streamId, userId) if (run?.chatId !== chatId || !isTerminalStreamStatus(run.status)) return null const events = await readEvents(streamId, '0') - /** StreamWriter starts at 1; Redis may trim oldest events or skip corrupt entries. */ + /** Titles can arrive after completion; only the completion frame and an unbroken prefix matter. */ if ( - events.at(-1)?.type !== 'complete' || + !events.some((event) => event.type === 'complete') || !events.every((event, index) => event.seq === index + 1) ) return null