fix(studio): invalidate notebook caches after assistant create/update (#49415)

## Summary
- The assistant's `create_notebook`/`update_notebook` tools run entirely
server-side, so an open notebook tab's React Query cache and Valtio
store never learn a write happened — the tab keeps showing stale content
until a manual reload.
- Adds `collectNotebookCacheEffects`/`applyNotebookCacheEffects`
(`apps/studio/lib/ai/notebook-cache-invalidation.ts`), which scan
finished assistant messages for completed
`create_notebook`/`update_notebook` tool calls and evict the affected
notebook via `evictNotebookFromCaches`
(`apps/studio/data/content/notebooks/notebook-cache.ts`), plus
invalidate the nav list.
- Wired into `createChatInstance`'s `onFinish` in
`state/ai-assistant-state.tsx`, with per-chat dedupe so replayed history
isn't reprocessed.
- Removes the cache entry outright rather than invalidating it, since a
remounting `useNotebookQuery` would otherwise read the stale cached
value synchronously before its refetch lands.
- Explicitly skips eviction when the open tab has unsaved local edits,
so an assistant write can't silently discard them.

Related: [FE-4235](https://linear.app/supabase/issue/FE-4235)

**Out of scope:** this only protects the client-side cache/store from
being clobbered after the fact. Preventing the assistant's
`update_notebook` tool call itself from overwriting a user's unsaved
edits (a data-layer conflict, not a cache-freshness one) is tracked
separately in [FE-4255](https://linear.app/supabase/issue/FE-4255).

## Test plan
- [x] `pnpm test:studio -- notebook-cache notebook-cache-invalidation
ai-assistant-state.notebook-cache-invalidation
ExplorerNotebookTab.assistant-cache-invalidation
ExplorerNotebookTabCoordinator` — all passing
- [x] Reproduction-first component test
(`ExplorerNotebookTab.assistant-cache-invalidation.test.tsx`) — verified
it fails without the fix (stale content persists) and passes with it
- [x] Regression test for the dirty-notebook guard (an edited, unsaved
notebook is left untouched by an assistant write)
- [x] `pnpm typecheck --filter=studio` / `pnpm lint --filter=studio`
clean


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Notebook changes made through the AI assistant now appear correctly in
open notebook tabs and after reopening them.
* Saved notebook caches are refreshed after completed create or update
actions, preventing stale content from being displayed.
  * Unsaved notebook changes are preserved during cache cleanup.
  * Closing a notebook tab now consistently removes its cached content.

* **Tests**
* Added coverage for assistant-driven updates, remounts, duplicate
actions, project context changes, and cache behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-08-25 14:15:27 +08:00
1 parent 3490a63a7c
commit de3a8799d6
9 files changed
+666 -35

No files matched your search

@@ -54,7 +54,7 @@ export const ExplorerNotebookTabCoordinator = () => {
const notebookId = tab.metadata?.notebookId
if (!ref || !notebookId) return
evictNotebookFromCaches({ queryClient, projectRef: ref, id: notebookId, mode: 'remove' })
evictNotebookFromCaches({ queryClient, projectRef: ref, id: notebookId })
},
confirmClose: (notebookTabs) => {
const dirtyCount = notebookTabs.filter((tab) => {
@@ -0,0 +1,176 @@
import { QueryClient } from '@tanstack/react-query'
import { screen } from '@testing-library/react'
import { HttpResponse } from 'msw'
import { describe, expect, it, vi } from 'vitest'
import { ExplorerNotebookTab } from '../ExplorerNotebookTab'
import type { components } from '@/data/api'
import { contentKeys } from '@/data/content/keys'
import {
applyNotebookCacheEffects,
collectNotebookCacheEffects,
} from '@/lib/ai/notebook-cache-invalidation'
import { createAssistantMessageWithUpdateNotebookTool } from '@/lib/ai/test-fixtures'
import { notebooksState } from '@/state/notebooks/notebooks-state'
import type { Notebook } from '@/state/notebooks/types'
import { createTabsState, TabsStateContext } from '@/state/tabs'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils'
const PROJECT_REF = 'default'
const NOTEBOOK_ID = 'notebook-assistant-cache-test'
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
return {
...actual,
IS_PLATFORM: true,
useParams: () => ({ ref: 'default', id: 'notebook-assistant-cache-test' }),
useFlag: () => false,
}
})
vi.mock('@/components/ui/CodeEditor/CodeEditor', () => ({
CodeEditor: ({ value }: { value: string }) => (
<textarea aria-label="SQL editor" value={value} readOnly />
),
}))
vi.mock('../QueryEditor/QuerySourceMenu', () => ({
QuerySourceMenu: () => null,
}))
const seedNotebook = () => {
delete notebooksState.notebooks[NOTEBOOK_ID]
const notebook: Notebook = {
id: NOTEBOOK_ID,
type: 'notebook',
name: 'Test notebook',
visibility: 'project',
favorite: false,
owner_id: 1,
project_id: 1,
content: {
schema_version: 1,
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'Original content' }],
},
}
notebooksState.setNotebook({ projectRef: PROJECT_REF, notebook })
}
const renderNotebookTab = (queryClient: QueryClient) =>
customRender(
<TabsStateContext.Provider value={createTabsState(PROJECT_REF)}>
<ExplorerNotebookTab />
</TabsStateContext.Provider>,
{ queryClient }
)
describe('ExplorerNotebookTab — assistant cache invalidation', () => {
it('refetches and renders the updated cells after an assistant update_notebook tool call completes', async () => {
setupSqlEditorMocks()
seedNotebook()
const queryClient = new QueryClient()
queryClient.setQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID), { id: NOTEBOOK_ID })
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/content/item/:id',
response: () =>
HttpResponse.json<components['schemas']['GetUserContentByIdResponse']>({
id: NOTEBOOK_ID,
type: 'notebook',
name: 'Test notebook',
description: '',
favorite: false,
folder_id: null,
inserted_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-02T00:00:00.000Z',
visibility: 'project',
owner_id: 1,
project_id: 1,
content: {
schema_version: 1,
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'Updated by assistant' }],
},
}),
})
renderNotebookTab(queryClient)
expect(await screen.findByText('Original content')).toBeInTheDocument()
const message = createAssistantMessageWithUpdateNotebookTool({
id: NOTEBOOK_ID,
name: 'Test notebook',
})
const effects = collectNotebookCacheEffects([message], new Set())
await applyNotebookCacheEffects({ queryClient, projectRef: PROJECT_REF, effects })
expect(await screen.findByText('Updated by assistant')).toBeInTheDocument()
expect(screen.queryByText('Original content')).not.toBeInTheDocument()
})
it('shows the updated cells on remount, when the query cache still holds the pre-eviction notebook', async () => {
setupSqlEditorMocks()
seedNotebook()
const queryClient = new QueryClient()
queryClient.setQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID), {
id: NOTEBOOK_ID,
type: 'notebook',
name: 'Test notebook',
description: '',
favorite: false,
visibility: 'project',
owner_id: 1,
project_id: 1,
inserted_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
content: {
schema_version: 1,
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'Original content' }],
},
})
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/content/item/:id',
response: () =>
HttpResponse.json<components['schemas']['GetUserContentByIdResponse']>({
id: NOTEBOOK_ID,
type: 'notebook',
name: 'Test notebook',
description: '',
favorite: false,
folder_id: null,
inserted_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-02T00:00:00.000Z',
visibility: 'project',
owner_id: 1,
project_id: 1,
content: {
schema_version: 1,
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'Updated by assistant' }],
},
}),
})
// While the tab is unmounted (navigated away to a separate Explorer chat tab), the
// assistant updates the notebook.
const message = createAssistantMessageWithUpdateNotebookTool({
id: NOTEBOOK_ID,
name: 'Test notebook',
})
const effects = collectNotebookCacheEffects([message], new Set())
await applyNotebookCacheEffects({ queryClient, projectRef: PROJECT_REF, effects })
// Simulate navigating back: the notebook tab mounts fresh.
renderNotebookTab(queryClient)
expect(await screen.findByText('Updated by assistant')).toBeInTheDocument()
expect(screen.queryByText('Original content')).not.toBeInTheDocument()
})
})
@@ -38,7 +38,7 @@ afterEach(() => {
})
describe('evictNotebookFromCaches', () => {
it('removes a saved notebook from the store and invalidates its cache entry in "refresh" mode', async () => {
it('removes a saved notebook from the store and drops its cache entry', async () => {
seedNotebook('saved')
const queryClient = new QueryClient()
seedQueryData(queryClient)
@@ -47,26 +47,6 @@ describe('evictNotebookFromCaches', () => {
queryClient,
projectRef: PROJECT_REF,
id: NOTEBOOK_ID,
mode: 'refresh',
})
expect(evicted).toBe(true)
expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeUndefined()
expect(
queryClient.getQueryState(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID))?.isInvalidated
).toBe(true)
})
it('removes a saved notebook from the store and drops its cache entry in "remove" mode', async () => {
seedNotebook('saved')
const queryClient = new QueryClient()
seedQueryData(queryClient)
const evicted = await evictNotebookFromCaches({
queryClient,
projectRef: PROJECT_REF,
id: NOTEBOOK_ID,
mode: 'remove',
})
expect(evicted).toBe(true)
@@ -83,7 +63,6 @@ describe('evictNotebookFromCaches', () => {
queryClient,
projectRef: PROJECT_REF,
id: NOTEBOOK_ID,
mode: 'remove',
})
expect(evicted).toBe(false)
@@ -101,7 +80,6 @@ describe('evictNotebookFromCaches', () => {
queryClient,
projectRef: PROJECT_REF,
id: NOTEBOOK_ID,
mode: 'remove',
})
expect(evicted).toBe(false)
@@ -6,8 +6,6 @@ import { notebooksState } from '@/state/notebooks/notebooks-state'
import type { StateNotebook } from '@/state/notebooks/types'
import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle'
export type NotebookCacheEvictionMode = 'refresh' | 'remove'
/**
* Whether a notebook has edits worth discarding on close: anything not yet
* saved, except a never-persisted notebook that's still empty (nothing to
@@ -28,6 +26,12 @@ export function hasDiscardableChanges(
* Applies to a persisted notebook (always safe to refetch) and to a dirty
* unsaved notebook (safe once the caller has confirmed discarding it).
*
* Removes the query entry outright rather than invalidating it: a mounted
* `useNotebookQuery` observer would otherwise read the stale cached value
* synchronously, before its refetch lands, and `notebooksState.setNotebook`'s
* merge guard would treat that stale merge as already-loaded and drop the
* real update.
*
* @returns A boolean indicating whether the notebook was successfully evicted
* from the cache.
*/
@@ -35,25 +39,17 @@ export async function evictNotebookFromCaches({
queryClient,
projectRef,
id,
mode,
}: {
queryClient: QueryClient
projectRef: string
id: string
mode: NotebookCacheEvictionMode
}): Promise<boolean> {
const stateNotebook = notebooksState.notebooks[id]
const canEvict = stateNotebook?.status === 'saved' || hasDiscardableChanges(stateNotebook)
if (!canEvict) return false
notebooksState.removeNotebook({ id })
const queryKey = contentKeys.resource(projectRef, id)
if (mode === 'remove') {
queryClient.removeQueries({ queryKey })
} else {
await queryClient.invalidateQueries({ queryKey })
}
queryClient.removeQueries({ queryKey: contentKeys.resource(projectRef, id) })
return true
}
@@ -0,0 +1,182 @@
import { QueryClient } from '@tanstack/react-query'
import type { ToolUIPart, UIMessage } from 'ai'
import { afterEach, describe, expect, it } from 'vitest'
import {
applyNotebookCacheEffects,
collectNotebookCacheEffects,
} from './notebook-cache-invalidation'
import {
createAssistantMessageWithCreateNotebookTool,
createAssistantMessageWithUpdateNotebookTool,
createAssistantTextMessage,
createUserMessage,
} from './test-fixtures'
import { contentKeys } from '@/data/content/keys'
import { notebooksState } from '@/state/notebooks/notebooks-state'
import type { Notebook } from '@/state/notebooks/types'
const PROJECT_REF = 'default'
function toolPart(overrides: Partial<ToolUIPart>): ToolUIPart {
return {
type: 'tool-update_notebook',
toolCallId: 'call-1',
state: 'output-available',
input: {},
output: { id: 'notebook-1', name: 'Signup funnel' },
...overrides,
} as ToolUIPart
}
function assistantMessage(parts: UIMessage['parts'], id = 'assistant-1'): UIMessage {
return { id, role: 'assistant', parts }
}
describe('collectNotebookCacheEffects', () => {
it('collects a create_notebook output-available part', () => {
const messages = [createAssistantMessageWithCreateNotebookTool()]
const effects = collectNotebookCacheEffects(messages, new Set())
expect(effects).toEqual([{ _tag: 'upserted', toolCallId: 'call-notebook-1', id: 'notebook-1' }])
})
it('collects an update_notebook output-available part', () => {
const messages = [createAssistantMessageWithUpdateNotebookTool()]
const effects = collectNotebookCacheEffects(messages, new Set())
expect(effects).toEqual([{ _tag: 'upserted', toolCallId: 'call-notebook-1', id: 'notebook-1' }])
})
it('ignores non-terminal tool states', () => {
const messages = [
assistantMessage([
toolPart({ type: 'tool-create_notebook', state: 'input-streaming' }),
toolPart({ type: 'tool-update_notebook', state: 'approval-requested' }),
toolPart({ type: 'tool-update_notebook', state: 'output-error' }),
]),
]
expect(collectNotebookCacheEffects(messages, new Set())).toEqual([])
})
it('ignores malformed output', () => {
const messages = [
assistantMessage([toolPart({ output: { unexpected: true } })]),
assistantMessage([toolPart({ toolCallId: 'call-2', output: undefined })]),
]
expect(collectNotebookCacheEffects(messages, new Set())).toEqual([])
})
it('ignores unrelated message types and tool parts', () => {
const messages = [
createUserMessage('update my notebook'),
createAssistantTextMessage('Sure, updating it now.'),
]
expect(collectNotebookCacheEffects(messages, new Set())).toEqual([])
})
it('collects output on an earlier message even when a later message has already finished', () => {
const messages = [
assistantMessage([toolPart({ toolCallId: 'call-earlier' })], 'assistant-1'),
createAssistantTextMessage('All done!', 'assistant-2'),
]
const effects = collectNotebookCacheEffects(messages, new Set())
expect(effects).toEqual([{ _tag: 'upserted', toolCallId: 'call-earlier', id: 'notebook-1' }])
})
it('dedupes against the processed set', () => {
const messages = [assistantMessage([toolPart({ toolCallId: 'call-1' })])]
expect(collectNotebookCacheEffects(messages, new Set(['call-1']))).toEqual([])
})
})
describe('applyNotebookCacheEffects', () => {
const NOTEBOOK: Notebook = {
id: 'notebook-1',
type: 'notebook',
name: 'Test notebook',
visibility: 'project',
favorite: false,
owner_id: 1,
project_id: 1,
content: { schema_version: 1, cells: [] },
}
afterEach(() => {
delete notebooksState.notebooks[NOTEBOOK.id]
notebooksState.needsSaving.clear()
})
it('invalidates the nav list and evicts an upserted, saved notebook from the cache', async () => {
notebooksState.setNotebook({ projectRef: PROJECT_REF, notebook: NOTEBOOK })
const queryClient = new QueryClient()
queryClient.setQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK.id), { id: NOTEBOOK.id })
queryClient.setQueryData(contentKeys.allContentLists(PROJECT_REF), [])
queryClient.setQueryData(contentKeys.infiniteList(PROJECT_REF), {})
await applyNotebookCacheEffects({
queryClient,
projectRef: PROJECT_REF,
effects: [{ _tag: 'upserted', toolCallId: 'call-1', id: NOTEBOOK.id }],
})
expect(queryClient.getQueryState(contentKeys.allContentLists(PROJECT_REF))?.isInvalidated).toBe(
true
)
expect(queryClient.getQueryState(contentKeys.infiniteList(PROJECT_REF))?.isInvalidated).toBe(
true
)
expect(queryClient.getQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK.id))).toBeUndefined()
expect(notebooksState.notebooks[NOTEBOOK.id]).toBeUndefined()
})
it('leaves a dirty (unsaved) notebook in the store untouched', async () => {
notebooksState.addNotebook({ projectRef: PROJECT_REF, notebook: NOTEBOOK })
const queryClient = new QueryClient()
await applyNotebookCacheEffects({
queryClient,
projectRef: PROJECT_REF,
effects: [{ _tag: 'upserted', toolCallId: 'call-1', id: NOTEBOOK.id }],
})
expect(notebooksState.notebooks[NOTEBOOK.id]).toBeDefined()
})
it('leaves an edited (unsaved, non-empty) notebook untouched', async () => {
notebooksState.setNotebook({ projectRef: PROJECT_REF, notebook: NOTEBOOK })
notebooksState.updateCells({
id: NOTEBOOK.id,
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'local edit' }],
})
expect(notebooksState.notebooks[NOTEBOOK.id].status).toBe('unsaved')
const queryClient = new QueryClient()
await applyNotebookCacheEffects({
queryClient,
projectRef: PROJECT_REF,
effects: [{ _tag: 'upserted', toolCallId: 'call-1', id: NOTEBOOK.id }],
})
expect(notebooksState.notebooks[NOTEBOOK.id]).toBeDefined()
})
it('no-ops entirely for an empty effects list', async () => {
const queryClient = new QueryClient()
queryClient.setQueryData(contentKeys.allContentLists(PROJECT_REF), [])
await applyNotebookCacheEffects({ queryClient, projectRef: PROJECT_REF, effects: [] })
expect(queryClient.getQueryState(contentKeys.allContentLists(PROJECT_REF))?.isInvalidated).toBe(
false
)
})
})
@@ -0,0 +1,67 @@
import type { QueryClient } from '@tanstack/react-query'
import type { ToolUIPart, UIMessage } from 'ai'
import { notebookToolOutputSchema } from '@/components/ui/AIAssistantPanel/Message.utils'
import { contentKeys } from '@/data/content/keys'
import { evictNotebookFromCaches } from '@/data/content/notebooks/notebook-cache'
import { notebooksState } from '@/state/notebooks/notebooks-state'
export type NotebookCacheEffect =
| { _tag: 'upserted'; toolCallId: string; id: string }
| { _tag: 'deleted'; toolCallId: string; id: string }
const NOTEBOOK_UPSERT_TOOL_TYPES = new Set(['tool-create_notebook', 'tool-update_notebook'])
function isNotebookUpsertPart(part: UIMessage['parts'][number]): part is ToolUIPart {
return NOTEBOOK_UPSERT_TOOL_TYPES.has(part.type)
}
export function collectNotebookCacheEffects(
messages: Array<UIMessage>,
processed: ReadonlySet<string>
): Array<NotebookCacheEffect> {
const effects: Array<NotebookCacheEffect> = []
for (const message of messages) {
if (message.role !== 'assistant') continue
for (const part of message.parts ?? []) {
if (!isNotebookUpsertPart(part)) continue
if (part.state !== 'output-available') continue
if (processed.has(part.toolCallId)) continue
const result = notebookToolOutputSchema.safeParse(part.output)
if (!result.success) continue
effects.push({ _tag: 'upserted', toolCallId: part.toolCallId, id: result.data.id })
}
}
return effects
}
export async function applyNotebookCacheEffects({
queryClient,
projectRef,
effects,
}: {
queryClient: QueryClient
projectRef: string
effects: Array<NotebookCacheEffect>
}): Promise<void> {
if (effects.length === 0) return
await Promise.all([
queryClient.invalidateQueries({ queryKey: contentKeys.allContentLists(projectRef) }),
queryClient.invalidateQueries({ queryKey: contentKeys.infiniteList(projectRef) }),
])
await Promise.all(
effects.map((effect) => {
const stateNotebook = notebooksState.notebooks[effect.id]
if (stateNotebook && stateNotebook.status !== 'saved') return
return evictNotebookFromCaches({ queryClient, projectRef, id: effect.id })
})
)
}
+22
View File
@@ -73,6 +73,28 @@ export function createAssistantMessageWithUpdateNotebookTool(
}
}
export function createAssistantMessageWithCreateNotebookTool(
output: Record<string, unknown> = {
id: 'notebook-1',
name: 'Signup funnel',
},
id = 'assistant-notebook-msg-1'
): UIMessage {
return {
id,
role: 'assistant',
parts: [
{
type: 'tool-create_notebook',
state: 'output-available',
toolCallId: 'call-notebook-1',
input: { name: 'Signup funnel', content: { schema_version: 1, cells: [] } },
output,
} satisfies ToolUIPart,
],
}
}
export function createAssistantMessageWithMultipleTools(
id = 'assistant-multi-tool-msg-1'
): UIMessage {
@@ -0,0 +1,181 @@
import type { UIMessageChunk } from 'ai'
import { convertArrayToReadableStream } from 'ai/test'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { contentKeys } from '@/data/content/keys'
import { getQueryClient } from '@/data/query-client'
import { notebooksState } from '@/state/notebooks/notebooks-state'
import type { Notebook } from '@/state/notebooks/types'
const testContext = vi.hoisted(() => ({
queuedStreams: [] as Array<Array<UIMessageChunk>>,
onSend: undefined as (() => void) | undefined,
}))
vi.mock('ai', async (importOriginal) => {
const actual = await importOriginal<typeof import('ai')>()
return {
...actual,
DefaultChatTransport: class {
constructor(private options: any = {}) {}
async sendMessages(opts: any) {
await this.options.prepareSendMessagesRequest?.({
api: this.options.api,
id: opts.chatId,
messages: opts.messages,
body: { ...this.options.body, ...opts.body },
headers: {},
credentials: undefined,
requestMetadata: opts.metadata,
trigger: opts.trigger,
messageId: opts.messageId,
})
testContext.onSend?.()
const chunks = testContext.queuedStreams.shift()
if (!chunks) throw new Error('No queued stream for this sendMessages call')
return convertArrayToReadableStream(chunks)
}
async reconnectToStream() {
return null
}
},
}
})
const { createAiAssistantState } = await import('./ai-assistant-state')
const PROJECT_REF = 'default'
const NOTEBOOK_ID = 'notebook-onfinish-test'
const seedNotebook = () => {
delete notebooksState.notebooks[NOTEBOOK_ID]
const notebook: Notebook = {
id: NOTEBOOK_ID,
type: 'notebook',
name: 'Test notebook',
visibility: 'project',
favorite: false,
owner_id: 1,
project_id: 1,
content: {
schema_version: 1,
cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'Original' }],
},
}
notebooksState.setNotebook({ projectRef: PROJECT_REF, notebook })
}
afterEach(() => {
delete notebooksState.notebooks[NOTEBOOK_ID]
notebooksState.needsSaving.clear()
testContext.queuedStreams = []
testContext.onSend = undefined
})
describe('createChatInstance onFinish — notebook cache invalidation via a real Chat/stream', () => {
it('evicts the notebook from caches once update_notebook reaches output-available, after an approval round trip', async () => {
seedNotebook()
const queryClient = getQueryClient()
queryClient.setQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID), { id: NOTEBOOK_ID })
const state = createAiAssistantState()
state.setContext({ projectRef: PROJECT_REF })
const chatId = state.createChat({ name: 'Delete a cell' })
const chatInstance = state.chatInstances[chatId]
// First stream: assistant proposes update_notebook, which needs approval.
testContext.queuedStreams.push([
{ type: 'start' },
{
type: 'tool-input-available',
toolCallId: 'call-1',
toolName: 'update_notebook',
input: {
id: NOTEBOOK_ID,
expected_updated_at: '2024-01-01T00:00:00.000Z',
operations: [{ _tag: 'delete_cell', cell_id: 'cell-1' }],
},
},
{ type: 'tool-approval-request', approvalId: 'approval-1', toolCallId: 'call-1' },
{ type: 'finish' },
])
await chatInstance.sendMessage({ text: 'Delete the first cell' })
// Still pending approval — must not evict yet.
expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeDefined()
// Second stream: after approval, the tool actually executes and returns its result.
testContext.queuedStreams.push([
{ type: 'start' },
{
type: 'tool-output-available',
toolCallId: 'call-1',
output: { id: NOTEBOOK_ID, name: 'Test notebook' },
},
{ type: 'finish' },
])
await chatInstance.addToolApprovalResponse({ id: 'approval-1', approved: true })
await vi.waitFor(() => {
expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeUndefined()
})
expect(queryClient.getQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID))).toBeUndefined()
})
it('binds cache effects to the project the request was sent under, not whatever project is active when the stream finishes', async () => {
seedNotebook()
const OTHER_PROJECT_REF = 'other-project'
const queryClient = getQueryClient()
queryClient.setQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID), { id: NOTEBOOK_ID })
const state = createAiAssistantState()
state.setContext({ projectRef: PROJECT_REF })
const chatId = state.createChat({ name: 'Delete a cell' })
const chatInstance = state.chatInstances[chatId]
testContext.queuedStreams.push([
{ type: 'start' },
{
type: 'tool-input-available',
toolCallId: 'call-1',
toolName: 'update_notebook',
input: {
id: NOTEBOOK_ID,
expected_updated_at: '2024-01-01T00:00:00.000Z',
operations: [{ _tag: 'delete_cell', cell_id: 'cell-1' }],
},
},
{ type: 'tool-approval-request', approvalId: 'approval-1', toolCallId: 'call-1' },
{ type: 'finish' },
])
await chatInstance.sendMessage({ text: 'Delete the first cell' })
testContext.queuedStreams.push([
{ type: 'start' },
{
type: 'tool-output-available',
toolCallId: 'call-1',
output: { id: NOTEBOOK_ID, name: 'Test notebook' },
},
{ type: 'finish' },
])
// Simulate navigating to a different project while the approval round-trip is in
// flight: after the request was sent (with the origin project ref in its body), but
// before its stream resolves and onFinish runs.
testContext.onSend = () => state.setContext({ projectRef: OTHER_PROJECT_REF })
await chatInstance.addToolApprovalResponse({ id: 'approval-1', approved: true })
await vi.waitFor(() => {
expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeUndefined()
})
// Must evict the origin project's cache entry — the one the write actually happened
// in — not whichever project happened to be active once the stream finished.
expect(queryClient.getQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID))).toBeUndefined()
})
})
+29
View File
@@ -17,10 +17,15 @@ import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio'
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import type { AiSupportStatus } from '@/data/feedback/ai-chat-front-sync'
import { constructHeaders } from '@/data/fetchers'
import { getQueryClient } from '@/data/query-client'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { prepareMessagesForAPI } from '@/lib/ai/message-utils'
import { isKnownAssistantModelId } from '@/lib/ai/model.utils'
import type { AssistantModelId } from '@/lib/ai/model.utils'
import {
applyNotebookCacheEffects,
collectNotebookCacheEffects,
} from '@/lib/ai/notebook-cache-invalidation'
import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
type SuggestionsType = {
@@ -277,6 +282,19 @@ function createChatInstance(
state: AiAssistantState,
options: { id: string; initialMessages: MessageType[] }
) {
// Seeded so effects already reflected in persisted history aren't replayed on the first
// onFinish after a reload.
const processedNotebookToolCallIds = new Set<string>(
collectNotebookCacheEffects(options.initialMessages, new Set()).map(
(effect) => effect.toolCallId
)
)
// The project a pending request's tool calls actually ran against — captured when the
// request is sent, not re-read from (mutable) state.context in onFinish, since the user
// can switch projects while the request is still in flight.
let requestProjectRef: string | undefined
return new Chat<MessageType>({
id: options.id,
messages: options.initialMessages.map((message) => sanitizeForCloning(message)),
@@ -299,6 +317,8 @@ function createChatInstance(
// Get the chat specific to this request to ensure we have the correct name
const chat = state.chats[options.id]
requestProjectRef = state.context.projectRef
return {
...opts,
body: {
@@ -368,6 +388,15 @@ function createChatInstance(
.then(({ syncSupportChatToFront }) => syncSupportChatToFront(options.id, state))
.catch(() => {})
}
const projectRef = requestProjectRef
if (projectRef) {
const effects = collectNotebookCacheEffects(messages, processedNotebookToolCallIds)
effects.forEach((effect) => processedNotebookToolCallIds.add(effect.toolCallId))
if (effects.length > 0) {
void applyNotebookCacheEffects({ queryClient: getQueryClient(), projectRef, effects })
}
}
}
},
})