Files
supabase/apps/studio/lib/ai/notebook-cache-invalidation.test.ts
Charis 632e099487 feat(studio): track assistant notebook divergence (#49539)
## Summary
- record server divergence when an assistant changes a notebook with
local unsaved edits
- clear the session-only marker after a successful save or notebook
removal
- cover update, delete, saved eviction, and lifecycle behavior

## Verification
- pnpm --dir apps/studio exec vitest run
state/notebooks/notebooks-state.test.ts
lib/ai/notebook-cache-invalidation.test.ts
- pnpm --dir apps/studio exec eslint state/notebooks/notebooks-state.ts
state/notebooks/notebooks-state.test.ts
lib/ai/notebook-cache-invalidation.ts
lib/ai/notebook-cache-invalidation.test.ts
- pnpm --dir apps/studio typecheck

Stacked on the approval-warning PR for FE-4255.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of server changes to notebooks with unsaved local
edits.
* Server updates and deletions are now tracked as divergences instead of
being silently skipped.
* Divergence indicators are cleared when changes are saved or notebooks
are removed.
  * Unrelated notebook changes no longer create false conflicts.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-25 11:42:46 -04:00

232 lines
8.4 KiB
TypeScript

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,
createAssistantMessageWithDeleteNotebookTool,
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('collects a delete_notebook output-available part', () => {
const messages = [createAssistantMessageWithDeleteNotebookTool()]
const effects = collectNotebookCacheEffects(messages, new Set())
expect(effects).toEqual([{ _tag: 'deleted', 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()
notebooksState.serverDivergedWhileDirty.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('invalidates the nav list and evicts a deleted 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: 'deleted', 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('records an assistant update while leaving a dirty notebook 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()
expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK.id)).toBe('updated')
})
it('records an assistant deletion while leaving an edited 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: 'deleted', toolCallId: 'call-1', id: NOTEBOOK.id }],
})
expect(notebooksState.notebooks[NOTEBOOK.id]).toBeDefined()
expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK.id)).toBe('deleted')
})
it('does not record a conflict for a different notebook', 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: 'other-notebook' }],
})
expect(notebooksState.serverDivergedWhileDirty.has(NOTEBOOK.id)).toBe(false)
expect(notebooksState.serverDivergedWhileDirty.has('other-notebook')).toBe(false)
})
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
)
})
})