From 7908c06f63be2a60b2b8d28a6b7f13cc0d57ade4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Sep 2026 12:52:25 -0700 Subject: [PATCH 1/2] Cancel waiting tools and retain completed replay after title updates --- .../app/api/copilot/chat/stop/route.test.ts | 21 +++++++++++++++++++ .../mothership/chat/persisted-message.test.ts | 12 +++++------ .../lib/mothership/chat/persisted-message.ts | 5 ++++- .../sim/lib/mothership/chat/terminal-state.ts | 6 +++--- 4 files changed, 34 insertions(+), 10 deletions(-) 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..ba436f69d8f 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.test.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.test.ts @@ -17,9 +17,9 @@ 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(['executing', 'pending', 'awaiting_approval'] as const)( + 'cancels unfinished %s tools even when the stopped marker already exists', + (state) => { const message: PersistedMessage = { id: 'assistant', role: 'assistant', @@ -31,12 +31,12 @@ describe('persisted-message', () => { toolCall: { id: 'unfinished', name: 'run_code', - state: 'executing', + state, params: { code: 'keep me' }, }, }, { type: 'tool', toolCall: { id: 'finished', name: 'read', state: 'success' } }, - ...(alreadyStopped ? [{ type: 'complete' as const, status: 'cancelled' as const }] : []), + { type: 'complete', status: 'cancelled' }, ], } const saved = withStoppedContentBlock(message) @@ -47,7 +47,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 From 92d9263dbee37f120ce7c4a215f90bdeec89d1b3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Sep 2026 13:03:12 -0700 Subject: [PATCH 2/2] Retain Stop marker creation coverage for every unfinished tool state --- .../lib/mothership/chat/persisted-message.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/mothership/chat/persisted-message.test.ts b/apps/sim/lib/mothership/chat/persisted-message.test.ts index ba436f69d8f..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(['executing', 'pending', 'awaiting_approval'] as const)( - 'cancels unfinished %s tools even when the stopped marker already exists', - (state) => { + 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', @@ -36,7 +43,7 @@ describe('persisted-message', () => { }, }, { type: 'tool', toolCall: { id: 'finished', name: 'read', state: 'success' } }, - { type: 'complete', status: 'cancelled' }, + ...(alreadyStopped ? [{ type: 'complete' as const, status: 'cancelled' as const }] : []), ], } const saved = withStoppedContentBlock(message)