Skip to content

Commit 0ced63b

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 0ced63b

6 files changed

Lines changed: 238 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: 3 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,8 @@ 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+
// Also strip on read: rows written before the backfill still carry outputs.
88+
return rows.map((row) => stripToolResultOutput(row.content as PersistedMessage))
8889
}
8990

9091
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: 2 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,7 @@ function toRow(
3131
chatId,
3232
messageId: message.id,
3333
role: message.role,
34-
content: message,
34+
content: stripToolResultOutput(message),
3535
seq,
3636
model: options?.chatModel ?? null,
3737
streamId: options?.streamId ?? null,

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

Lines changed: 136 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,137 @@ 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+
expect(message.contentBlocks?.[0].toolCall?.result).toHaveProperty('output')
274+
})
275+
276+
it('omits error when the original result had none', () => {
277+
const message: PersistedMessage = {
278+
id: 'msg-1',
279+
role: 'assistant',
280+
content: '',
281+
timestamp: '2026-01-01T00:00:00.000Z',
282+
contentBlocks: [
283+
{
284+
type: 'tool',
285+
phase: 'call',
286+
toolCall: {
287+
id: 't',
288+
name: 'read',
289+
state: 'success',
290+
result: { success: true, output: [1, 2, 3] },
291+
},
292+
},
293+
],
294+
}
295+
296+
expect(stripToolResultOutput(message).contentBlocks?.[0].toolCall?.result).toEqual({
297+
success: true,
298+
})
299+
})
300+
301+
it('returns the same reference when there is nothing to strip', () => {
302+
const noBlocks: PersistedMessage = {
303+
id: 'u',
304+
role: 'user',
305+
content: 'hi',
306+
timestamp: '2026-01-01T00:00:00.000Z',
307+
}
308+
expect(stripToolResultOutput(noBlocks)).toBe(noBlocks)
309+
310+
const noOutput: PersistedMessage = {
311+
id: 'msg',
312+
role: 'assistant',
313+
content: 'done',
314+
timestamp: '2026-01-01T00:00:00.000Z',
315+
contentBlocks: [
316+
{ type: 'text', channel: 'assistant', content: 'done' },
317+
{ type: 'tool', phase: 'call', toolCall: { id: 't', name: 'read', state: 'pending' } },
318+
{
319+
type: 'tool',
320+
phase: 'call',
321+
toolCall: {
322+
id: 't2',
323+
name: 'read',
324+
state: 'error',
325+
result: { success: false, error: 'x' },
326+
},
327+
},
328+
],
329+
}
330+
expect(stripToolResultOutput(noOutput)).toBe(noOutput)
331+
})
332+
333+
it('strips every tool block while leaving text/thinking blocks intact', () => {
334+
const message: PersistedMessage = {
335+
id: 'msg',
336+
role: 'assistant',
337+
content: '',
338+
timestamp: '2026-01-01T00:00:00.000Z',
339+
contentBlocks: [
340+
{ type: 'text', channel: 'thinking', content: 'hmm' },
341+
{
342+
type: 'tool',
343+
phase: 'call',
344+
toolCall: {
345+
id: 'a',
346+
name: 'run_workflow',
347+
state: 'success',
348+
result: { success: true, output: { big: 1 } },
349+
},
350+
},
351+
{ type: 'text', channel: 'assistant', content: 'answer' },
352+
{
353+
type: 'tool',
354+
phase: 'call',
355+
toolCall: {
356+
id: 'b',
357+
name: 'read',
358+
state: 'success',
359+
result: { success: true, output: 'file contents' },
360+
},
361+
},
362+
],
363+
}
364+
365+
const blocks = stripToolResultOutput(message).contentBlocks ?? []
366+
expect(blocks[0]).toEqual({ type: 'text', channel: 'thinking', content: 'hmm' })
367+
expect(blocks[1].toolCall?.result).toEqual({ success: true })
368+
expect(blocks[2]).toEqual({ type: 'text', channel: 'assistant', content: 'answer' })
369+
expect(blocks[3].toolCall?.result).toEqual({ success: true })
370+
expect(JSON.stringify(blocks)).not.toContain('file contents')
371+
})
372+
})

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 || typeof result !== 'object' || !('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)