Skip to content

Commit 77dedc2

Browse files
committed
fix(mothership): bound preview metadata frames and settle stopped rows on every save
- Compact every preview phase except content and completion, which the preview adapter already bounds; a model-written patch search string could otherwise exceed one replay write in an edit_meta frame - Settle unfinished tool rows as stopped in buildPersistedAssistantMessage for a cancelled turn, so background and API callers that persist the result directly never save pending, executing, or awaiting-approval rows
1 parent aea7ed0 commit 77dedc2

4 files changed

Lines changed: 68 additions & 11 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,3 +802,25 @@ describe('buildPersistedAssistantMessage on a completed turn', () => {
802802
}
803803
)
804804
})
805+
806+
describe('buildPersistedAssistantMessage on a cancelled turn', () => {
807+
it.each(['pending', 'executing', 'awaiting_approval'] as const)(
808+
'settles a %s tool row as stopped for a caller that persists the result directly',
809+
(status) => {
810+
const persisted = buildPersistedAssistantMessage({
811+
success: false,
812+
cancelled: true,
813+
content: 'Partial answer',
814+
toolCalls: [],
815+
contentBlocks: [
816+
{ type: 'tool_call', timestamp: 1, toolCall: { id: 'call-1', name: 'read', status } },
817+
],
818+
})
819+
820+
expect(persisted.contentBlocks?.[0].toolCall).toMatchObject({
821+
state: 'cancelled',
822+
display: { title: 'Stopped by user' },
823+
})
824+
}
825+
)
826+
})

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -359,13 +359,18 @@ export function buildPersistedAssistantMessage(
359359
return normalized
360360
}
361361

362-
// A completed turn settles its stragglers as the live view did at `complete`.
363-
if (result.success && message.contentBlocks) {
364-
message.contentBlocks = settleUnfinishedToolCalls(message.contentBlocks, 'success')
362+
// A finished turn settles its stragglers as the live view did at its terminal;
363+
// background and API callers persist a stopped turn without withStoppedContentBlock.
364+
if (message.contentBlocks) {
365+
message.contentBlocks = result.success
366+
? settleUnfinishedToolCalls(message.contentBlocks, 'success')
367+
: settleUnfinishedToolCalls(message.contentBlocks, 'cancelled', STOPPED_TOOL_DISPLAY)
365368
}
366369
return message
367370
}
368371

372+
const STOPPED_TOOL_DISPLAY = { title: 'Stopped by user' } as const
373+
369374
const UNSETTLED_TOOL_STATES: ReadonlySet<LocalToolCallStatus> = new Set<LocalToolCallStatus>([
370375
'pending',
371376
'executing',
@@ -392,9 +397,11 @@ function settleUnfinishedToolCalls(
392397
}
393398

394399
export function withStoppedContentBlock(message: PersistedMessage): PersistedMessage {
395-
const contentBlocks = settleUnfinishedToolCalls(message.contentBlocks ?? [], 'cancelled', {
396-
title: 'Stopped by user',
397-
})
400+
const contentBlocks = settleUnfinishedToolCalls(
401+
message.contentBlocks ?? [],
402+
'cancelled',
403+
STOPPED_TOOL_DISPLAY
404+
)
398405
const hasAssistantText = contentBlocks.some(
399406
(block) =>
400407
block.type === MothershipStreamV1EventType.text &&

‎apps/sim/lib/mothership/request/session/replay-compaction.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe('compactStreamEvent', () => {
147147
expect(args.stdin).toBe(`${'x'.repeat(STREAM_STRING_PREVIEW_UNITS)}…[truncated, 1 MB total]`)
148148
})
149149

150-
it('never compacts a file preview', () => {
150+
it('never compacts preview content, which the client applies as exact deltas', () => {
151151
const preview: StreamEvent = {
152152
type: 'tool',
153153
payload: {
@@ -164,6 +164,27 @@ describe('compactStreamEvent', () => {
164164
expect(compactStreamEvent(preview)).toBe(preview)
165165
})
166166

167+
it('bounds a preview edit whose model-written search text is past one replay write', () => {
168+
const meta: StreamEvent = {
169+
type: 'tool',
170+
payload: {
171+
toolCallId: 'c',
172+
toolName: 'prepare_file_edit',
173+
previewPhase: 'file_preview_edit_meta',
174+
edit: { strategy: 'search_replace', search: 's'.repeat(2 * MB), replaceAll: false },
175+
},
176+
}
177+
178+
const payload = payloadOf(compactStreamEvent(meta))
179+
const edit = toRecord(payload.edit)
180+
181+
expect(serializedBytes(payload)).toBeLessThanOrEqual(STREAM_EVENT_MAX_PAYLOAD_BYTES)
182+
expect(payload.previewPhase).toBe('file_preview_edit_meta')
183+
expect(edit.strategy).toBe('search_replace')
184+
expect(edit.replaceAll).toBe(false)
185+
expect(edit.search).toBe(`${'s'.repeat(STREAM_STRING_PREVIEW_UNITS)}…[truncated, 2 MB total]`)
186+
})
187+
167188
it('compacts a copy and leaves the caller’s event whole for dispatch', () => {
168189
const stdout = 's'.repeat(MB)
169190
const event: StreamEvent = {

‎apps/sim/lib/mothership/request/session/replay-compaction.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,21 @@ function shed(
225225
* event for dispatch. Long strings are cut to their head in place, so every
226226
* object keeps its shape; if that is not enough, long arrays keep their head,
227227
* and past one replay write the smallest sufficient bulk is replaced by a size
228-
* note, keeping the fields beside it. Assistant text, file previews, and the
229-
* arguments of calls the browser executes are never cut; an event still too
230-
* large is refused by the buffer, which ends the turn with an error.
228+
* note, keeping the fields beside it. Assistant text, the arguments of calls the
229+
* browser executes, and preview content and completions are never cut: the
230+
* client applies preview content as exact deltas, and the preview adapter
231+
* bounds both itself. An event still too large is refused by the buffer, which
232+
* ends the turn with an error.
231233
*/
232234
export function compactStreamEvent(event: StreamEvent): StreamEvent {
233235
const payload = toRecordOrNull(event.payload)
234236
// Text length is part of the receipt the worker and a replacement check.
235-
if (!payload || event.type === MothershipStreamV1EventType.text || 'previewPhase' in payload) {
237+
if (
238+
!payload ||
239+
event.type === MothershipStreamV1EventType.text ||
240+
payload.previewPhase === 'file_preview_content' ||
241+
payload.previewPhase === 'file_preview_complete'
242+
) {
236243
return event
237244
}
238245
if (estimateBytes(payload) <= STREAM_EVENT_COMPACTION_THRESHOLD_BYTES) return event

0 commit comments

Comments
 (0)