diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 37aeaa2735c..e1d6ec21740 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -2,12 +2,18 @@ * @vitest-environment node */ +import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() })) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) +/** + * Overrides the global `@sim/db` mock: the logs-context tests below need + * controllable row data, which the stable `dbChainMockFns.limit` provides. + */ +vi.mock('@sim/db', () => dbChainMock) import { processContextsServer } from './process-contents' @@ -67,3 +73,186 @@ describe('processContextsServer - skill contexts', () => { expect(result).toEqual([]) }) }) + +describe('processContextsServer - logs contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a tagged run to a compact summary with a block overview, never raw input/output', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + executionData: { + traceSpans: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + duration: 500, + input: { prompt: 'do the thing' }, + output: { error: '429 No active subscription' }, + }, + ], + }, + costTotal: '0.05', + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toHaveLength(1) + expect(result[0].type).toBe('logs') + expect(result[0].tag).toBe('@My Flow') + + const summary = JSON.parse(result[0].content) + expect(summary).toMatchObject({ + executionId: 'exec-1', + workflowId: 'wf-1', + workflowName: 'My Flow', + level: 'error', + trigger: 'manual', + totalDurationMs: 1000, + cost: { total: 0.05 }, + overview: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + durationMs: 500, + }, + ], + }) + const serialized = JSON.stringify(summary) + expect(serialized).not.toContain('do the thing') + expect(serialized).not.toContain('429 No active subscription') + expect(summary.note).toContain('query_logs') + expect(summary.note).toContain('exec-1') + }) + + it('drops the overview (keeping the rest of the summary) when it exceeds the size cap', async () => { + const traceSpans = Array.from({ length: 2000 }, (_, i) => ({ + id: `span-${i}`, + blockId: `block-${i}`, + name: `Block ${i}`, + type: 'agent', + status: 'success', + duration: 10, + })) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + executionData: { traceSpans }, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + const summary = JSON.parse(result[0].content) + expect(summary.overview).toBeUndefined() + expect(summary.executionId).toBe('exec-1') + expect(summary.note).toContain('query_logs') + }) + + it('drops a log context when the workflow is outside the current workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-other', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-other' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) + + it('drops a log context the user is not authorized to read', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: false, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index ef33580211c..a49ea07ccc0 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull, ne } from 'drizzle-orm' +import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { buildVfsFolderPathMap, @@ -18,6 +19,8 @@ import { encodeVfsSegment, } from '@/lib/copilot/vfs/path-utils' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { toOverview } from '@/lib/logs/log-views' +import type { TraceSpan } from '@/lib/logs/types' import { getTableById } from '@/lib/table/service' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -565,6 +568,31 @@ async function processWorkflowBlockFromDb( } } +/** + * Cap on the serialized summary (including the block overview tree) sent for + * a tagged run. `toOverview` already excludes every block's input/output, so + * this is a safety net against pathological span counts, not the primary + * defense — mirrors `MAX_FULL_RESULT_BYTES` in `query-logs.ts`, scaled down + * since this lands in the prompt unconditionally rather than behind an + * explicit tool call. + */ +const MAX_LOG_SUMMARY_BYTES = 64 * 1024 + +/** + * Resolve a tagged run to a compact summary instead of its full execution + * trace. A run's trace can carry every block's input/output plus nested + * tool-call spans, which is unbounded and would repeatedly blow the context + * window if inlined directly. The summary includes the block-level overview + * tree (name/type/status/timing/cost, no input or output — the same + * projection `query_logs`'s `overview` view returns) so the model can see + * which block failed without a round trip, and points it at `query_logs` for + * that block's actual input/output/error, or to grep the trace. + * + * `materializeExecutionData` only unwraps a top-level object-storage pointer, + * for runs whose whole trace was offloaded as one blob — a no-op for the + * common inline case. Individual span input/output stay as large-value refs; + * `toOverview` never resolves those. + */ async function processExecutionLogFromDb( executionId: string, userId: string | undefined, @@ -610,12 +638,14 @@ async function processExecutionLogFromDb( } } - // Heavy execution data may live in object storage; resolve the pointer. const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store') const executionData = (await materializeExecutionData( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - )) as any + )) as { traceSpans?: TraceSpan[] } | undefined + const overview = executionData?.traceSpans?.length + ? toOverview(executionData.traceSpans) + : undefined const summary = { id: log.id, @@ -627,13 +657,13 @@ async function processExecutionLogFromDb( endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null), totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', - executionData: executionData - ? { - traceSpans: executionData.traceSpans || undefined, - errorDetails: executionData.errorDetails || undefined, - } - : undefined, cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined, + overview, + note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`, + } + + if (overview && JSON.stringify(summary).length > MAX_LOG_SUMMARY_BYTES) { + summary.overview = undefined } const content = JSON.stringify(summary)