Skip to content

Commit 7908c06

Browse files
committed
Cancel waiting tools and retain completed replay after title updates
1 parent 491740a commit 7908c06

4 files changed

Lines changed: 34 additions & 10 deletions

File tree

‎apps/sim/app/api/copilot/chat/stop/route.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,27 @@ describe('copilot chat stop route', () => {
220220
}
221221
)
222222

223+
it('persists completed replay when a late title session event follows completion', async () => {
224+
mockReads({
225+
chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null },
226+
last: { messageId: 'stream-1', role: 'user' },
227+
})
228+
const envelope = { v: 1, ts: '2026-09-24T19:00:00Z', stream: { streamId: 'stream-1' } }
229+
mockReadEvents.mockResolvedValue([
230+
{
231+
...envelope,
232+
seq: 1,
233+
type: 'text',
234+
payload: { channel: 'assistant', text: 'Full response' },
235+
},
236+
{ ...envelope, seq: 2, type: 'complete', payload: { status: 'cancelled' } },
237+
{ ...envelope, seq: 3, type: 'session', payload: { kind: 'title', title: 'New title' } },
238+
])
239+
const response = await stopRequest(createRequest({ chatId: 'chat-1', streamId: 'stream-1' }))
240+
expect(response.status).toBe(200)
241+
expect(mockAppendCopilotChatMessages.mock.calls[0][1][0].content).toBe('Full response')
242+
})
243+
223244
it('does not finalize a contiguous prefix before the final event is flushed', async () => {
224245
mockReadEvents.mockResolvedValue([
225246
{

‎apps/sim/lib/mothership/chat/persisted-message.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ import {
1717
} from './persisted-message'
1818

1919
describe('persisted-message', () => {
20-
it.each([false, true])(
21-
'cancels unfinished tools even when the stopped marker already exists: %s',
22-
(alreadyStopped) => {
20+
it.each(['executing', 'pending', 'awaiting_approval'] as const)(
21+
'cancels unfinished %s tools even when the stopped marker already exists',
22+
(state) => {
2323
const message: PersistedMessage = {
2424
id: 'assistant',
2525
role: 'assistant',
@@ -31,12 +31,12 @@ describe('persisted-message', () => {
3131
toolCall: {
3232
id: 'unfinished',
3333
name: 'run_code',
34-
state: 'executing',
34+
state,
3535
params: { code: 'keep me' },
3636
},
3737
},
3838
{ type: 'tool', toolCall: { id: 'finished', name: 'read', state: 'success' } },
39-
...(alreadyStopped ? [{ type: 'complete' as const, status: 'cancelled' as const }] : []),
39+
{ type: 'complete', status: 'cancelled' },
4040
],
4141
}
4242
const saved = withStoppedContentBlock(message)
@@ -47,7 +47,7 @@ describe('persisted-message', () => {
4747
})
4848
expect(saved.contentBlocks?.[1].toolCall?.state).toBe('success')
4949
expect(saved.contentBlocks?.filter((block) => block.type === 'complete')).toHaveLength(1)
50-
expect(message.contentBlocks?.[0].toolCall?.state).toBe('executing')
50+
expect(message.contentBlocks?.[0].toolCall?.state).toBe(state)
5151
}
5252
)
5353

‎apps/sim/lib/mothership/chat/persisted-message.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,10 @@ export function buildPersistedAssistantMessage(
360360
export function withStoppedContentBlock(message: PersistedMessage): PersistedMessage {
361361
const contentBlocks = (message.contentBlocks ?? []).map(
362362
(block): PersistedContentBlock =>
363-
block.toolCall?.state === 'executing'
363+
block.toolCall &&
364+
(block.toolCall.state === 'executing' ||
365+
block.toolCall.state === 'pending' ||
366+
block.toolCall.state === 'awaiting_approval')
364367
? {
365368
...block,
366369
toolCall: {

‎apps/sim/lib/mothership/chat/terminal-state.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface FinalizeAssistantTurnResult {
4141
outcome: (typeof CopilotChatFinalizeOutcome)[keyof typeof CopilotChatFinalizeOutcome]
4242
}
4343

44-
/** Only the matching terminal run and a gap-free replay through its final event can be persisted. */
44+
/** Require the matching terminal run and gap-free replay containing its completion frame. */
4545
export async function readStoppedAssistantMessage(
4646
streamId: string,
4747
chatId: string,
@@ -50,9 +50,9 @@ export async function readStoppedAssistantMessage(
5050
const run = await getLatestRunForStream(streamId, userId)
5151
if (run?.chatId !== chatId || !isTerminalStreamStatus(run.status)) return null
5252
const events = await readEvents(streamId, '0')
53-
/** StreamWriter starts at 1; Redis may trim oldest events or skip corrupt entries. */
53+
/** Titles can arrive after completion; only the completion frame and an unbroken prefix matter. */
5454
if (
55-
events.at(-1)?.type !== 'complete' ||
55+
!events.some((event) => event.type === 'complete') ||
5656
!events.every((event, index) => event.seq === index + 1)
5757
)
5858
return null

0 commit comments

Comments
 (0)