Skip to content

Commit 5925651

Browse files
authored
feat(vfs): add lazy vfs + remove dynamic fields for prompt caching hits (#5138)
* feat(vfs): add lazy vfs + remove dynamic fields for prompt caching hits * feat(vfs): send typed workspace snapshot for append-only deltas Build the workspace inventory from the primary db (fixes replica-lag staleness) and emit it as a typed VfsSnapshotV1 `vfs` payload alongside the markdown, so the mothership can diff it into append-only baseline/delta messages. Generate the TS contract mirror from the Go-owned JSON schema (sync-vfs-snapshot-contract) and sort connector types so diffs stay byte-stable. * fix(lint): fix lint * fix(vfs): forward the typed snapshot through the branch payload builder The branch buildPayload implementations hand-list the params they pass to buildCopilotRequestPayload and forwarded workspaceContext but dropped vfs, so the typed snapshot never reached the Go request (req.Vfs was always nil and the append-only delta path never engaged). Forward vfs in both the workflow and workspace branches, and add a regression guard asserting the branch threads it through (the bug slipped past tests because post.test mocked the payload builder and payload.test called it directly, bypassing the branch). * improvement(contracts): update vfs contracts
1 parent 208d135 commit 5925651

14 files changed

Lines changed: 886 additions & 280 deletions

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
33
import { LRUCache } from 'lru-cache'
44
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
55
import { isPaid } from '@/lib/billing/plan-helpers'
6+
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
67
import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools'
78
import { getToolEntry } from '@/lib/copilot/tool-executor/router'
89
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
@@ -33,6 +34,7 @@ interface BuildPayloadParams {
3334
prefetch?: boolean
3435
implicitFeedback?: string
3536
workspaceContext?: string
37+
vfs?: VfsSnapshotV1
3638
userPermission?: string
3739
userTimezone?: string
3840
userMetadata?: {
@@ -366,6 +368,7 @@ export async function buildCopilotRequestPayload(
366368
...(mothershipTools.length > 0 ? { mothershipTools } : {}),
367369
...(commands && commands.length > 0 ? { commands } : {}),
368370
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),
371+
...(params.vfs ? { vfs: params.vfs } : {}),
369372
...(params.userPermission ? { userPermission: params.userPermission } : {}),
370373
...(params.userTimezone ? { userTimezone: params.userTimezone } : {}),
371374
...(params.userMetadata &&

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
1717

1818
const {
1919
getEffectiveDecryptedEnv,
20-
generateWorkspaceContext,
20+
generateWorkspaceSnapshot,
2121
processContextsServer,
2222
resolveActiveResourceContext,
2323
buildCopilotRequestPayload,
@@ -31,7 +31,7 @@ const {
3131
mockPublishStatusChanged,
3232
} = vi.hoisted(() => ({
3333
getEffectiveDecryptedEnv: vi.fn(),
34-
generateWorkspaceContext: vi.fn(),
34+
generateWorkspaceSnapshot: vi.fn(),
3535
processContextsServer: vi.fn(),
3636
resolveActiveResourceContext: vi.fn(),
3737
buildCopilotRequestPayload: vi.fn(),
@@ -56,7 +56,7 @@ vi.mock('@/lib/environment/utils', () => ({
5656
}))
5757

5858
vi.mock('@/lib/copilot/chat/workspace-context', () => ({
59-
generateWorkspaceContext,
59+
generateWorkspaceSnapshot,
6060
}))
6161

6262
vi.mock('@/lib/copilot/chat/process-contents', () => ({
@@ -142,7 +142,10 @@ describe('handleUnifiedChatPost', () => {
142142
})
143143
getUserEntityPermissions.mockResolvedValue('write')
144144
getEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'secret' })
145-
generateWorkspaceContext.mockResolvedValue('workspace context')
145+
generateWorkspaceSnapshot.mockResolvedValue({
146+
markdown: 'workspace context',
147+
snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] },
148+
})
146149
processContextsServer.mockResolvedValue([])
147150
resolveActiveResourceContext.mockResolvedValue(null)
148151
buildCopilotRequestPayload.mockImplementation(async (params: Record<string, unknown>) => params)
@@ -178,11 +181,13 @@ describe('handleUnifiedChatPost', () => {
178181
)
179182

180183
expect(response.status).toBe(200)
181-
expect(generateWorkspaceContext).toHaveBeenCalledWith('ws-1', 'user-1')
184+
expect(generateWorkspaceSnapshot).toHaveBeenCalledWith('ws-1', 'user-1')
182185
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
183186
expect.objectContaining({
184187
model: 'claude-opus-4-8',
185188
workspaceContext: 'workspace context',
189+
// Regression guard: the branch must forward the typed snapshot, not drop it.
190+
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
186191
}),
187192
{ selectedModel: 'claude-opus-4-8' }
188193
)
@@ -221,6 +226,8 @@ describe('handleUnifiedChatPost', () => {
221226
expect.objectContaining({
222227
workspaceId: 'ws-1',
223228
workspaceContext: 'workspace context',
229+
// Regression guard: the branch must forward the typed snapshot, not drop it.
230+
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
224231
}),
225232
{ selectedModel: '' }
226233
)

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
resolveActiveResourceContext,
2323
} from '@/lib/copilot/chat/process-contents'
2424
import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state'
25-
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
25+
import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context'
2626
import { chatPubSub } from '@/lib/copilot/chat-status'
2727
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants'
2828
import {
@@ -32,6 +32,7 @@ import {
3232
} from '@/lib/copilot/generated/trace-attribute-values-v1'
3333
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
3434
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
35+
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
3536
import { createBadRequestResponse, createUnauthorizedResponse } from '@/lib/copilot/request/http'
3637
import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/lifecycle/start'
3738
import { startCopilotOtelRoot, withCopilotSpan } from '@/lib/copilot/request/otel'
@@ -184,6 +185,7 @@ type UnifiedChatBranch =
184185
prefetch?: boolean
185186
implicitFeedback?: string
186187
workspaceContext?: string
188+
vfs?: VfsSnapshotV1
187189
}) => Promise<Record<string, unknown>>
188190
buildExecutionContext: (params: {
189191
userId: string
@@ -212,6 +214,7 @@ type UnifiedChatBranch =
212214
userTimezone?: string
213215
userMetadata?: { name?: string; email?: string; timezone?: string }
214216
workspaceContext?: string
217+
vfs?: VfsSnapshotV1
215218
}) => Promise<Record<string, unknown>>
216219
buildExecutionContext: (params: {
217220
userId: string
@@ -618,6 +621,7 @@ async function resolveBranch(params: {
618621
prefetch: payloadParams.prefetch,
619622
implicitFeedback: payloadParams.implicitFeedback,
620623
workspaceContext: payloadParams.workspaceContext,
624+
vfs: payloadParams.vfs,
621625
userPermission: payloadParams.userPermission,
622626
userTimezone: payloadParams.userTimezone,
623627
userMetadata: payloadParams.userMetadata,
@@ -672,6 +676,7 @@ async function resolveBranch(params: {
672676
fileAttachments: payloadParams.fileAttachments,
673677
chatId: payloadParams.chatId,
674678
workspaceContext: payloadParams.workspaceContext,
679+
vfs: payloadParams.vfs,
675680
userPermission: payloadParams.userPermission,
676681
userTimezone: payloadParams.userTimezone,
677682
userMetadata: payloadParams.userMetadata,
@@ -902,7 +907,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
902907
? withCopilotSpan(
903908
TraceSpan.CopilotChatBuildWorkspaceContext,
904909
{ [TraceAttr.WorkspaceId]: workspaceId },
905-
() => generateWorkspaceContext(workspaceId, authenticatedUserId),
910+
() => generateWorkspaceSnapshot(workspaceId, authenticatedUserId),
906911
activeOtelRoot.context
907912
)
908913
: Promise.resolve(undefined)
@@ -947,14 +952,19 @@ export async function handleUnifiedChatPost(req: NextRequest) {
947952
activeOtelRoot.context
948953
)
949954

950-
const [agentContexts, userPermission, workspaceContext, , executionContext] =
955+
const [agentContexts, userPermission, workspaceSnapshot, , executionContext] =
951956
await Promise.all([
952957
agentContextsPromise,
953958
userPermissionPromise,
954959
workspaceContextPromise,
955960
persistUserMessagePromise,
956961
executionContextPromise,
957962
])
963+
// Both halves come from one primary-db fetch (workspace-context.ts):
964+
// `workspaceContext` is the markdown transition fallback, `vfs` is the
965+
// typed snapshot Go diffs into baseline+delta messages.
966+
const workspaceContext = workspaceSnapshot?.markdown
967+
const vfs = workspaceSnapshot?.snapshot
958968

959969
executionContext.userPermission = userPermission ?? undefined
960970

@@ -991,6 +1001,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
9911001
prefetch: body.prefetch,
9921002
implicitFeedback: body.implicitFeedback,
9931003
workspaceContext,
1004+
vfs,
9941005
})
9951006
: branch.buildPayload({
9961007
message: body.message,
@@ -1003,6 +1014,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10031014
userTimezone: body.userTimezone,
10041015
userMetadata,
10051016
workspaceContext,
1017+
vfs,
10061018
}),
10071019
activeOtelRoot.context
10081020
)

apps/sim/lib/copilot/chat/workspace-context.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,157 @@ describe('buildWorkspaceMd - connected integrations / credentials', () => {
116116
expect(md).toContain('## Connected Integrations\n(none)')
117117
})
118118
})
119+
120+
describe('buildWorkspaceMd - determinism (prompt-cache stability)', () => {
121+
it('is byte-identical regardless of input row order', () => {
122+
const a = buildWorkspaceMd(
123+
baseData({
124+
members: [
125+
{ name: 'Bob', email: 'bob@x.com', permissionType: 'admin' },
126+
{ name: 'Amy', email: 'amy@x.com', permissionType: 'write' },
127+
],
128+
workflows: [
129+
{ id: 'wf-2', name: 'Zeta', isDeployed: false, folderPath: null },
130+
{ id: 'wf-1', name: 'Alpha', isDeployed: true, folderPath: null },
131+
],
132+
tables: [
133+
{ id: 't-2', name: 'Orders', description: null, rowCount: 5 },
134+
{ id: 't-1', name: 'Customers', description: null, rowCount: 9 },
135+
],
136+
knowledgeBases: [
137+
{ id: 'kb-2', name: 'Docs', connectorTypes: ['notion', 'github'] },
138+
{ id: 'kb-1', name: 'Articles', connectorTypes: ['github', 'notion'] },
139+
],
140+
oauthIntegrations: [
141+
{ id: 'c-2', providerId: 'slack', displayName: null, role: null },
142+
{ id: 'c-1', providerId: 'github', displayName: null, role: null },
143+
],
144+
envVariables: ['ZED', 'API_KEY'],
145+
customTools: [
146+
{ id: 'ct-2', name: 'Beta Tool' },
147+
{ id: 'ct-1', name: 'Alpha Tool' },
148+
],
149+
mcpServers: [
150+
{ id: 'mcp-2', name: 'Zulu', url: null, enabled: false },
151+
{ id: 'mcp-1', name: 'Mike', url: 'https://x', enabled: true },
152+
],
153+
skills: [
154+
{ id: 'sk-2', name: 'Writer', description: 'writes' },
155+
{ id: 'sk-1', name: 'Editor', description: 'edits' },
156+
],
157+
jobs: [
158+
{
159+
id: 'j-2',
160+
title: 'Nightly',
161+
prompt: 'run nightly',
162+
cronExpression: '0 0 * * *',
163+
status: 'active',
164+
lifecycle: 'persistent',
165+
sourceTaskName: null,
166+
},
167+
{
168+
id: 'j-1',
169+
title: 'Hourly',
170+
prompt: 'run hourly',
171+
cronExpression: '0 * * * *',
172+
status: 'active',
173+
lifecycle: 'persistent',
174+
sourceTaskName: null,
175+
},
176+
],
177+
})
178+
)
179+
const b = buildWorkspaceMd(
180+
baseData({
181+
members: [
182+
{ name: 'Amy', email: 'amy@x.com', permissionType: 'write' },
183+
{ name: 'Bob', email: 'bob@x.com', permissionType: 'admin' },
184+
],
185+
workflows: [
186+
{ id: 'wf-1', name: 'Alpha', isDeployed: true, folderPath: null },
187+
{ id: 'wf-2', name: 'Zeta', isDeployed: false, folderPath: null },
188+
],
189+
tables: [
190+
{ id: 't-1', name: 'Customers', description: null, rowCount: 9 },
191+
{ id: 't-2', name: 'Orders', description: null, rowCount: 5 },
192+
],
193+
knowledgeBases: [
194+
{ id: 'kb-1', name: 'Articles', connectorTypes: ['notion', 'github'] },
195+
{ id: 'kb-2', name: 'Docs', connectorTypes: ['github', 'notion'] },
196+
],
197+
oauthIntegrations: [
198+
{ id: 'c-1', providerId: 'github', displayName: null, role: null },
199+
{ id: 'c-2', providerId: 'slack', displayName: null, role: null },
200+
],
201+
envVariables: ['API_KEY', 'ZED'],
202+
customTools: [
203+
{ id: 'ct-1', name: 'Alpha Tool' },
204+
{ id: 'ct-2', name: 'Beta Tool' },
205+
],
206+
mcpServers: [
207+
{ id: 'mcp-1', name: 'Mike', url: 'https://x', enabled: true },
208+
{ id: 'mcp-2', name: 'Zulu', url: null, enabled: false },
209+
],
210+
skills: [
211+
{ id: 'sk-1', name: 'Editor', description: 'edits' },
212+
{ id: 'sk-2', name: 'Writer', description: 'writes' },
213+
],
214+
jobs: [
215+
{
216+
id: 'j-1',
217+
title: 'Hourly',
218+
prompt: 'run hourly',
219+
cronExpression: '0 * * * *',
220+
status: 'active',
221+
lifecycle: 'persistent',
222+
sourceTaskName: null,
223+
},
224+
{
225+
id: 'j-2',
226+
title: 'Nightly',
227+
prompt: 'run nightly',
228+
cronExpression: '0 0 * * *',
229+
status: 'active',
230+
lifecycle: 'persistent',
231+
sourceTaskName: null,
232+
},
233+
],
234+
})
235+
)
236+
expect(a).toBe(b)
237+
})
238+
239+
it('ignores volatile workflow run timestamps', () => {
240+
const withRun = buildWorkspaceMd(
241+
baseData({
242+
workflows: [
243+
{
244+
id: 'wf-1',
245+
name: 'Alpha',
246+
isDeployed: false,
247+
folderPath: null,
248+
lastRunAt: new Date('2026-06-18T12:00:00Z'),
249+
},
250+
],
251+
})
252+
)
253+
const withoutRun = buildWorkspaceMd(
254+
baseData({
255+
workflows: [{ id: 'wf-1', name: 'Alpha', isDeployed: false, folderPath: null }],
256+
})
257+
)
258+
expect(withRun).toBe(withoutRun)
259+
expect(withRun).not.toContain('last run')
260+
})
261+
262+
it('ignores volatile table row counts', () => {
263+
const a = buildWorkspaceMd(
264+
baseData({ tables: [{ id: 't-1', name: 'Customers', description: null, rowCount: 1 }] })
265+
)
266+
const b = buildWorkspaceMd(
267+
baseData({ tables: [{ id: 't-1', name: 'Customers', description: null, rowCount: 9999 }] })
268+
)
269+
expect(a).toBe(b)
270+
expect(a).not.toContain('rows')
271+
})
272+
})

0 commit comments

Comments
 (0)