mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## 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 -->
182 lines
6.2 KiB
TypeScript
182 lines
6.2 KiB
TypeScript
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()
|
|
})
|
|
})
|