diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 3ced9edfae..8c0e4fe76e 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -71,6 +71,34 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(1) }) + it('strips tool-result output on read, keeping success/error', async () => { + const toolMsg = { + id: 'm-tool', + role: 'assistant', + content: '', + timestamp: '2026-01-01T00:00:02.000Z', + contentBlocks: [ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'tc-1', + name: 'get_workflow_logs', + state: 'success', + result: { success: true, output: { huge: 'x'.repeat(5000) } }, + }, + }, + ], + } + dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) + dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: toolMsg }]) + + const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID) + + expect(result?.messages?.[0].contentBlocks?.[0].toolCall?.result).toEqual({ success: true }) + expect(JSON.stringify(result?.messages)).not.toContain('huge') + }) + it('returns an empty transcript for a chat with no messages', async () => { dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) dbChainMockFns.orderBy.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 1fcb5e51af..fe4d578a78 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -6,7 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/workflow-authz' import { and, asc, eq, isNull, sql } from 'drizzle-orm' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, @@ -84,7 +84,8 @@ export async function loadCopilotChatMessages(chatId: string): Promise row.content as PersistedMessage) + // Also strip on read: rows written before the backfill still carry outputs. + return rows.map((row) => stripToolResultOutput(row.content as PersistedMessage)) } type CopilotChatAuthRow = Pick< diff --git a/apps/sim/lib/copilot/chat/messages-store.test.ts b/apps/sim/lib/copilot/chat/messages-store.test.ts index a96cff1250..3ca4983442 100644 --- a/apps/sim/lib/copilot/chat/messages-store.test.ts +++ b/apps/sim/lib/copilot/chat/messages-store.test.ts @@ -26,6 +26,31 @@ const assistantMsg: PersistedMessage = { timestamp: '2026-01-01T00:00:01.000Z', } +const toolMsg: PersistedMessage = { + id: 'msg-tool-1', + role: 'assistant', + content: '', + timestamp: '2026-01-01T00:00:02.000Z', + contentBlocks: [ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'tc-1', + name: 'get_workflow_logs', + state: 'error', + params: { workflowId: 'wf-1' }, + result: { success: false, output: { huge: 'x'.repeat(5000) }, error: 'too big' }, + }, + }, + ], +} + +/** The persisted `content` of the most recently inserted row at `index`. */ +function lastRowContent(index: number): PersistedMessage { + return lastValuesRows()[index].content as PersistedMessage +} + /** The first arg passed to the most recent `.values(...)` call. */ function lastValuesRows() { const calls = dbChainMockFns.values.mock.calls @@ -131,6 +156,14 @@ describe('messages-store', () => { 'connection lost' ) }) + + it('strips tool-result output before persisting, keeping success/error', async () => { + await appendCopilotChatMessages('chat-1', [toolMsg]) + + const toolCall = lastRowContent(0).contentBlocks?.[0].toolCall + expect(toolCall?.result).toEqual({ success: false, error: 'too big' }) + expect(JSON.stringify(lastValuesRows())).not.toContain('huge') + }) }) describe('replaceCopilotChatMessages', () => { @@ -192,5 +225,13 @@ describe('messages-store', () => { await expect(replaceCopilotChatMessages('chat-1', [userMsg])).rejects.toThrow('tx aborted') }) + + it('strips tool-result output before persisting, keeping success/error', async () => { + await replaceCopilotChatMessages('chat-1', [toolMsg]) + + const toolCall = lastRowContent(0).contentBlocks?.[0].toolCall + expect(toolCall?.result).toEqual({ success: false, error: 'too big' }) + expect(JSON.stringify(lastValuesRows())).not.toContain('huge') + }) }) }) diff --git a/apps/sim/lib/copilot/chat/messages-store.ts b/apps/sim/lib/copilot/chat/messages-store.ts index 485a76ead3..15b988c799 100644 --- a/apps/sim/lib/copilot/chat/messages-store.ts +++ b/apps/sim/lib/copilot/chat/messages-store.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { copilotMessages } from '@sim/db/schema' import { and, eq, notInArray, sql } from 'drizzle-orm' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import type { DbOrTx } from '@/lib/db/types' /** @@ -31,7 +31,7 @@ function toRow( chatId, messageId: message.id, role: message.role, - content: message, + content: stripToolResultOutput(message), seq, model: options?.chatModel ?? null, streamId: options?.streamId ?? null, diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index de70139423..afe43cf6a0 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -8,6 +8,8 @@ import { buildPersistedAssistantMessage, buildPersistedUserMessage, normalizeMessage, + type PersistedMessage, + stripToolResultOutput, } from './persisted-message' describe('persisted-message', () => { @@ -234,3 +236,137 @@ describe('persisted-message', () => { expect(msg.contexts).toBeUndefined() }) }) + +describe('stripToolResultOutput', () => { + it('drops result.output but keeps success and error', () => { + const message: PersistedMessage = { + id: 'msg-1', + role: 'assistant', + content: '', + timestamp: '2026-01-01T00:00:00.000Z', + contentBlocks: [ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'tool-1', + name: 'get_workflow_logs', + state: 'error', + params: { workflowId: 'wf-1' }, + display: { title: 'Reading logs' }, + result: { success: false, output: { huge: 'x'.repeat(1000) }, error: 'boom' }, + }, + }, + ], + } + + const stripped = stripToolResultOutput(message) + + expect(stripped.contentBlocks?.[0].toolCall).toEqual({ + id: 'tool-1', + name: 'get_workflow_logs', + state: 'error', + params: { workflowId: 'wf-1' }, + display: { title: 'Reading logs' }, + result: { success: false, error: 'boom' }, + }) + expect(message.contentBlocks?.[0].toolCall?.result).toHaveProperty('output') + }) + + it('omits error when the original result had none', () => { + const message: PersistedMessage = { + id: 'msg-1', + role: 'assistant', + content: '', + timestamp: '2026-01-01T00:00:00.000Z', + contentBlocks: [ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 't', + name: 'read', + state: 'success', + result: { success: true, output: [1, 2, 3] }, + }, + }, + ], + } + + expect(stripToolResultOutput(message).contentBlocks?.[0].toolCall?.result).toEqual({ + success: true, + }) + }) + + it('returns the same reference when there is nothing to strip', () => { + const noBlocks: PersistedMessage = { + id: 'u', + role: 'user', + content: 'hi', + timestamp: '2026-01-01T00:00:00.000Z', + } + expect(stripToolResultOutput(noBlocks)).toBe(noBlocks) + + const noOutput: PersistedMessage = { + id: 'msg', + role: 'assistant', + content: 'done', + timestamp: '2026-01-01T00:00:00.000Z', + contentBlocks: [ + { type: 'text', channel: 'assistant', content: 'done' }, + { type: 'tool', phase: 'call', toolCall: { id: 't', name: 'read', state: 'pending' } }, + { + type: 'tool', + phase: 'call', + toolCall: { + id: 't2', + name: 'read', + state: 'error', + result: { success: false, error: 'x' }, + }, + }, + ], + } + expect(stripToolResultOutput(noOutput)).toBe(noOutput) + }) + + it('strips every tool block while leaving text/thinking blocks intact', () => { + const message: PersistedMessage = { + id: 'msg', + role: 'assistant', + content: '', + timestamp: '2026-01-01T00:00:00.000Z', + contentBlocks: [ + { type: 'text', channel: 'thinking', content: 'hmm' }, + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'a', + name: 'run_workflow', + state: 'success', + result: { success: true, output: { big: 1 } }, + }, + }, + { type: 'text', channel: 'assistant', content: 'answer' }, + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'b', + name: 'read', + state: 'success', + result: { success: true, output: 'file contents' }, + }, + }, + ], + } + + const blocks = stripToolResultOutput(message).contentBlocks ?? [] + expect(blocks[0]).toEqual({ type: 'text', channel: 'thinking', content: 'hmm' }) + expect(blocks[1].toolCall?.result).toEqual({ success: true }) + expect(blocks[2]).toEqual({ type: 'text', channel: 'assistant', content: 'answer' }) + expect(blocks[3].toolCall?.result).toEqual({ success: true }) + expect(JSON.stringify(blocks)).not.toContain('file contents') + }) +}) diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index efde8aa104..1d491d95a1 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -79,6 +79,34 @@ export interface PersistedMessage { contexts?: PersistedMessageContext[] } +/** + * Drop the `output` of every persisted tool result, keeping `success` and + * `error`. Tool outputs are never rendered (the chat thread shows only the tool + * name/title/status) and never replayed to the model (the upstream copilot + * service owns conversation memory), so storing them only bloats + * `copilot_messages.content` — a single `get_workflow_logs`/`run_workflow` + * result can reach hundreds of MB and stall task loads. + * + * Applied on both the write path (so new rows never store outputs) and the read + * path (so already-bloated rows still load fast). Returns the original + * reference when there is nothing to strip, preserving memoized identity for + * read-side consumers. + */ +export function stripToolResultOutput(message: PersistedMessage): PersistedMessage { + if (!message.contentBlocks?.length) return message + let changed = false + const contentBlocks = message.contentBlocks.map((block) => { + const toolCall = block.toolCall + const result = toolCall?.result + if (!toolCall || !result || typeof result !== 'object' || !('output' in result)) return block + changed = true + const strippedResult: { success: boolean; error?: string } = { success: result.success } + if (result.error !== undefined) strippedResult.error = result.error + return { ...block, toolCall: { ...toolCall, result: strippedResult } } + }) + return changed ? { ...message, contentBlocks } : message +} + // --------------------------------------------------------------------------- // Write: OrchestratorResult → PersistedMessage // ---------------------------------------------------------------------------