Skip to content

Commit 7f97352

Browse files
waleedlatif1claude
andcommitted
improvement(copilot): stop persisting tool-call result outputs in transcripts
Opening a Mothership task could take many seconds because a single persisted assistant message in copilot_messages.content can reach hundreds of MB, almost entirely inside contentBlocks[].toolCall.result.output (e.g. a get_workflow_logs or run_workflow result). The DB query is ~2ms; the cost is detoasting that payload, shipping it to the browser, and parsing it. These outputs are dead weight on the Sim side: they are never rendered (the thread shows only tool name/title/status) and never replayed to the model (the upstream copilot service owns conversation memory). So drop result.output before it is persisted, keeping result.success/error plus the tool metadata. - add stripToolResultOutput() in persisted-message.ts - apply it in messages-store toRow (covers every write path) and in loadCopilotChatMessages (existing rows render fast on read) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 919fa52 commit 7f97352

6 files changed

Lines changed: 242 additions & 4 deletions

File tree

apps/sim/lib/copilot/chat/lifecycle.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,34 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => {
7171
expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(1)
7272
})
7373

74+
it('strips tool-result output on read, keeping success/error', async () => {
75+
const toolMsg = {
76+
id: 'm-tool',
77+
role: 'assistant',
78+
content: '',
79+
timestamp: '2026-01-01T00:00:02.000Z',
80+
contentBlocks: [
81+
{
82+
type: 'tool',
83+
phase: 'call',
84+
toolCall: {
85+
id: 'tc-1',
86+
name: 'get_workflow_logs',
87+
state: 'success',
88+
result: { success: true, output: { huge: 'x'.repeat(5000) } },
89+
},
90+
},
91+
],
92+
}
93+
dbChainMockFns.limit.mockResolvedValueOnce([chatRow])
94+
dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: toolMsg }])
95+
96+
const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID)
97+
98+
expect(result?.messages?.[0].contentBlocks?.[0].toolCall?.result).toEqual({ success: true })
99+
expect(JSON.stringify(result?.messages)).not.toContain('huge')
100+
})
101+
74102
it('returns an empty transcript for a chat with no messages', async () => {
75103
dbChainMockFns.limit.mockResolvedValueOnce([chatRow])
76104
dbChainMockFns.orderBy.mockResolvedValueOnce([])

apps/sim/lib/copilot/chat/lifecycle.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
getActiveWorkflowRecord,
77
} from '@sim/workflow-authz'
88
import { and, asc, eq, isNull, sql } from 'drizzle-orm'
9-
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
9+
import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message'
1010
import {
1111
assertActiveWorkspaceAccess,
1212
checkWorkspaceAccess,
@@ -84,7 +84,9 @@ export async function loadCopilotChatMessages(chatId: string): Promise<Persisted
8484
asc(copilotMessages.createdAt),
8585
asc(copilotMessages.id)
8686
)
87-
return rows.map((row) => row.content as PersistedMessage)
87+
// Strip tool-result outputs on read too: new rows never store them, but
88+
// pre-backfill rows still carry large outputs, and this keeps the load fast.
89+
return rows.map((row) => stripToolResultOutput(row.content as PersistedMessage))
8890
}
8991

9092
type CopilotChatAuthRow = Pick<

apps/sim/lib/copilot/chat/messages-store.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,31 @@ const assistantMsg: PersistedMessage = {
2626
timestamp: '2026-01-01T00:00:01.000Z',
2727
}
2828

29+
const toolMsg: PersistedMessage = {
30+
id: 'msg-tool-1',
31+
role: 'assistant',
32+
content: '',
33+
timestamp: '2026-01-01T00:00:02.000Z',
34+
contentBlocks: [
35+
{
36+
type: 'tool',
37+
phase: 'call',
38+
toolCall: {
39+
id: 'tc-1',
40+
name: 'get_workflow_logs',
41+
state: 'error',
42+
params: { workflowId: 'wf-1' },
43+
result: { success: false, output: { huge: 'x'.repeat(5000) }, error: 'too big' },
44+
},
45+
},
46+
],
47+
}
48+
49+
/** The persisted `content` of the most recently inserted row at `index`. */
50+
function lastRowContent(index: number): PersistedMessage {
51+
return lastValuesRows()[index].content as PersistedMessage
52+
}
53+
2954
/** The first arg passed to the most recent `.values(...)` call. */
3055
function lastValuesRows() {
3156
const calls = dbChainMockFns.values.mock.calls
@@ -131,6 +156,14 @@ describe('messages-store', () => {
131156
'connection lost'
132157
)
133158
})
159+
160+
it('strips tool-result output before persisting, keeping success/error', async () => {
161+
await appendCopilotChatMessages('chat-1', [toolMsg])
162+
163+
const toolCall = lastRowContent(0).contentBlocks?.[0].toolCall
164+
expect(toolCall?.result).toEqual({ success: false, error: 'too big' })
165+
expect(JSON.stringify(lastValuesRows())).not.toContain('huge')
166+
})
134167
})
135168

136169
describe('replaceCopilotChatMessages', () => {
@@ -192,5 +225,13 @@ describe('messages-store', () => {
192225

193226
await expect(replaceCopilotChatMessages('chat-1', [userMsg])).rejects.toThrow('tx aborted')
194227
})
228+
229+
it('strips tool-result output before persisting, keeping success/error', async () => {
230+
await replaceCopilotChatMessages('chat-1', [toolMsg])
231+
232+
const toolCall = lastRowContent(0).contentBlocks?.[0].toolCall
233+
expect(toolCall?.result).toEqual({ success: false, error: 'too big' })
234+
expect(JSON.stringify(lastValuesRows())).not.toContain('huge')
235+
})
195236
})
196237
})

apps/sim/lib/copilot/chat/messages-store.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { copilotMessages } from '@sim/db/schema'
33
import { and, eq, notInArray, sql } from 'drizzle-orm'
4-
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
4+
import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message'
55
import type { DbOrTx } from '@/lib/db/types'
66

77
/**
@@ -31,7 +31,9 @@ function toRow(
3131
chatId,
3232
messageId: message.id,
3333
role: message.role,
34-
content: message,
34+
// Strip tool-result outputs before persisting: they are never rendered or
35+
// replayed to the model, and inline they bloat the row to hundreds of MB.
36+
content: stripToolResultOutput(message),
3537
seq,
3638
model: options?.chatModel ?? null,
3739
streamId: options?.streamId ?? null,

apps/sim/lib/copilot/chat/persisted-message.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
buildPersistedAssistantMessage,
99
buildPersistedUserMessage,
1010
normalizeMessage,
11+
type PersistedMessage,
12+
stripToolResultOutput,
1113
} from './persisted-message'
1214

1315
describe('persisted-message', () => {
@@ -234,3 +236,138 @@ describe('persisted-message', () => {
234236
expect(msg.contexts).toBeUndefined()
235237
})
236238
})
239+
240+
describe('stripToolResultOutput', () => {
241+
it('drops result.output but keeps success and error', () => {
242+
const message: PersistedMessage = {
243+
id: 'msg-1',
244+
role: 'assistant',
245+
content: '',
246+
timestamp: '2026-01-01T00:00:00.000Z',
247+
contentBlocks: [
248+
{
249+
type: 'tool',
250+
phase: 'call',
251+
toolCall: {
252+
id: 'tool-1',
253+
name: 'get_workflow_logs',
254+
state: 'error',
255+
params: { workflowId: 'wf-1' },
256+
display: { title: 'Reading logs' },
257+
result: { success: false, output: { huge: 'x'.repeat(1000) }, error: 'boom' },
258+
},
259+
},
260+
],
261+
}
262+
263+
const stripped = stripToolResultOutput(message)
264+
265+
expect(stripped.contentBlocks?.[0].toolCall).toEqual({
266+
id: 'tool-1',
267+
name: 'get_workflow_logs',
268+
state: 'error',
269+
params: { workflowId: 'wf-1' },
270+
display: { title: 'Reading logs' },
271+
result: { success: false, error: 'boom' },
272+
})
273+
// Source is not mutated.
274+
expect(message.contentBlocks?.[0].toolCall?.result).toHaveProperty('output')
275+
})
276+
277+
it('omits error when the original result had none', () => {
278+
const message: PersistedMessage = {
279+
id: 'msg-1',
280+
role: 'assistant',
281+
content: '',
282+
timestamp: '2026-01-01T00:00:00.000Z',
283+
contentBlocks: [
284+
{
285+
type: 'tool',
286+
phase: 'call',
287+
toolCall: {
288+
id: 't',
289+
name: 'read',
290+
state: 'success',
291+
result: { success: true, output: [1, 2, 3] },
292+
},
293+
},
294+
],
295+
}
296+
297+
expect(stripToolResultOutput(message).contentBlocks?.[0].toolCall?.result).toEqual({
298+
success: true,
299+
})
300+
})
301+
302+
it('returns the same reference when there is nothing to strip', () => {
303+
const noBlocks: PersistedMessage = {
304+
id: 'u',
305+
role: 'user',
306+
content: 'hi',
307+
timestamp: '2026-01-01T00:00:00.000Z',
308+
}
309+
expect(stripToolResultOutput(noBlocks)).toBe(noBlocks)
310+
311+
const noOutput: PersistedMessage = {
312+
id: 'msg',
313+
role: 'assistant',
314+
content: 'done',
315+
timestamp: '2026-01-01T00:00:00.000Z',
316+
contentBlocks: [
317+
{ type: 'text', channel: 'assistant', content: 'done' },
318+
{ type: 'tool', phase: 'call', toolCall: { id: 't', name: 'read', state: 'pending' } },
319+
{
320+
type: 'tool',
321+
phase: 'call',
322+
toolCall: {
323+
id: 't2',
324+
name: 'read',
325+
state: 'error',
326+
result: { success: false, error: 'x' },
327+
},
328+
},
329+
],
330+
}
331+
expect(stripToolResultOutput(noOutput)).toBe(noOutput)
332+
})
333+
334+
it('strips every tool block while leaving text/thinking blocks intact', () => {
335+
const message: PersistedMessage = {
336+
id: 'msg',
337+
role: 'assistant',
338+
content: '',
339+
timestamp: '2026-01-01T00:00:00.000Z',
340+
contentBlocks: [
341+
{ type: 'text', channel: 'thinking', content: 'hmm' },
342+
{
343+
type: 'tool',
344+
phase: 'call',
345+
toolCall: {
346+
id: 'a',
347+
name: 'run_workflow',
348+
state: 'success',
349+
result: { success: true, output: { big: 1 } },
350+
},
351+
},
352+
{ type: 'text', channel: 'assistant', content: 'answer' },
353+
{
354+
type: 'tool',
355+
phase: 'call',
356+
toolCall: {
357+
id: 'b',
358+
name: 'read',
359+
state: 'success',
360+
result: { success: true, output: 'file contents' },
361+
},
362+
},
363+
],
364+
}
365+
366+
const blocks = stripToolResultOutput(message).contentBlocks ?? []
367+
expect(blocks[0]).toEqual({ type: 'text', channel: 'thinking', content: 'hmm' })
368+
expect(blocks[1].toolCall?.result).toEqual({ success: true })
369+
expect(blocks[2]).toEqual({ type: 'text', channel: 'assistant', content: 'answer' })
370+
expect(blocks[3].toolCall?.result).toEqual({ success: true })
371+
expect(JSON.stringify(blocks)).not.toContain('file contents')
372+
})
373+
})

apps/sim/lib/copilot/chat/persisted-message.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,34 @@ export interface PersistedMessage {
7979
contexts?: PersistedMessageContext[]
8080
}
8181

82+
/**
83+
* Drop the `output` of every persisted tool result, keeping `success` and
84+
* `error`. Tool outputs are never rendered (the chat thread shows only the tool
85+
* name/title/status) and never replayed to the model (the upstream copilot
86+
* service owns conversation memory), so storing them only bloats
87+
* `copilot_messages.content` — a single `get_workflow_logs`/`run_workflow`
88+
* result can reach hundreds of MB and stall task loads.
89+
*
90+
* Applied on both the write path (so new rows never store outputs) and the read
91+
* path (so already-bloated rows still load fast). Returns the original
92+
* reference when there is nothing to strip, preserving memoized identity for
93+
* read-side consumers.
94+
*/
95+
export function stripToolResultOutput(message: PersistedMessage): PersistedMessage {
96+
if (!message.contentBlocks?.length) return message
97+
let changed = false
98+
const contentBlocks = message.contentBlocks.map((block) => {
99+
const toolCall = block.toolCall
100+
const result = toolCall?.result
101+
if (!toolCall || !result || !('output' in result)) return block
102+
changed = true
103+
const strippedResult: { success: boolean; error?: string } = { success: result.success }
104+
if (result.error !== undefined) strippedResult.error = result.error
105+
return { ...block, toolCall: { ...toolCall, result: strippedResult } }
106+
})
107+
return changed ? { ...message, contentBlocks } : message
108+
}
109+
82110
// ---------------------------------------------------------------------------
83111
// Write: OrchestratorResult → PersistedMessage
84112
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)