Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/sim/app/api/copilot/chat/stop/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
17 changes: 12 additions & 5 deletions apps/sim/lib/mothership/chat/persisted-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -31,7 +38,7 @@ describe('persisted-message', () => {
toolCall: {
id: 'unfinished',
name: 'run_code',
state: 'executing',
state,
params: { code: 'keep me' },
},
},
Expand All @@ -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)
}
)

Expand Down
5 changes: 4 additions & 1 deletion apps/sim/lib/mothership/chat/persisted-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/mothership/chat/terminal-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading