Files
supabase/apps/studio/lib/ai/generate-assistant-response.utils.test.ts
Charis 8920439569 Expose previous notebook content in update_notebook (#49401)
## Summary
- Plumb pre-update notebook snapshot through `update_notebook` tool
response as `previous_content`
- Add sanitizers in `tool-sanitizer.ts` to strip snapshot before model
sees it
- Add client-side stripping in `prepareMessagesForAPI` to avoid
re-uploading snapshot on subsequent turns
- This is PR 2 of 3 fixing Linear issue FE-4243 (notebook update
proposal shows 'unapplyable' error for already-completed updates)
- Ships no visible behavior change on its own; enables PR 3 to restore
diff preview for completed updates

## Test plan
- [x] Unit tests: 80/80 passing across notebook-tools.test.ts,
tool-sanitizer.test.ts, generate-assistant-response.utils.test.ts,
message-utils.test.ts, and mock-tools.test.ts
- [x] Typecheck: clean for all changed files
- [x] ESLint: zero errors, lint:ratchet passes (exit 0)
- [x] Integration: previous_content is correctly populated with
pre-update notebook, stripped before model context, and stripped on
client-side re-upload

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

- **Bug Fixes**
- Notebook updates now retain previous content for recovery and history.
- AI responses expose only the notebook’s ID and name, keeping previous
content out of model-visible data.

- **Tests**
- Added coverage for notebook update results, content sanitization, and
message preparation, including cases where previous content is absent or
preserved.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-21 14:58:32 -04:00

150 lines
4.3 KiB
TypeScript

import type { ToolUIPart, UIMessage } from 'ai'
import { describe, expect, it } from 'vitest'
import { prepareMessagesForModel } from './generate-assistant-response.utils'
import { encodeNotebookToolError, NotebookToolError } from './tools/notebook-tools'
function assistantMessage(parts: UIMessage['parts']): UIMessage {
return { id: 'msg-1', role: 'assistant', parts }
}
function toolPart(overrides: Partial<ToolUIPart>): ToolUIPart {
return {
type: 'tool-update_notebook',
toolCallId: 'call-1',
state: 'output-available',
input: {},
...overrides,
} as ToolUIPart
}
describe('prepareMessagesForModel', () => {
it('filters out a plain output-error tool part', () => {
const messages = [
assistantMessage([toolPart({ state: 'output-error', errorText: 'Network error' })]),
]
const result = prepareMessagesForModel(messages, 'schema')
expect(result[0].parts).toEqual([])
})
it('keeps a stale-update_notebook conflict and rewrites errorText to the plain message', () => {
const error = new NotebookToolError('Notebook changed since expected_updated_at', {
exposeToAssistant: true,
})
const messages = [
assistantMessage([
toolPart({ state: 'output-error', errorText: encodeNotebookToolError(error)! }),
]),
]
const result = prepareMessagesForModel(messages, 'schema')
expect(result[0].parts).toEqual([
toolPart({ state: 'output-error', errorText: 'Notebook changed since expected_updated_at' }),
])
})
it('still filters out an unrelated update_notebook output-error', () => {
const messages = [
assistantMessage([
toolPart({ state: 'output-error', errorText: 'Unexpected upstream failure' }),
]),
]
const result = prepareMessagesForModel(messages, 'schema')
expect(result[0].parts).toEqual([])
})
it('still filters out JSON-shaped output-error text lacking the notebook_tool_error tag', () => {
const messages = [
assistantMessage([
toolPart({
state: 'output-error',
errorText: JSON.stringify({
exposeToAssistant: true,
message: 'not a real notebook error',
}),
}),
]),
]
const result = prepareMessagesForModel(messages, 'schema')
expect(result[0].parts).toEqual([])
})
it('exposes any tool part carrying a validly-tagged NotebookToolError, not just update_notebook', () => {
const error = new NotebookToolError('Notebook changed since expected_updated_at', {
exposeToAssistant: true,
})
const messages = [
assistantMessage([
toolPart({
type: 'tool-execute_sql',
state: 'output-error',
errorText: encodeNotebookToolError(error)!,
}),
]),
]
const result = prepareMessagesForModel(messages, 'schema')
expect(result[0].parts).toEqual([
toolPart({
type: 'tool-execute_sql',
state: 'output-error',
errorText: 'Notebook changed since expected_updated_at',
}),
])
})
it('still filters out input-streaming, input-available, and approval-requested parts', () => {
const messages = [
assistantMessage([
toolPart({ state: 'input-streaming' }),
toolPart({ state: 'input-available' }),
toolPart({ state: 'approval-requested', approval: { id: 'a1' } } as Partial<ToolUIPart>),
]),
]
const result = prepareMessagesForModel(messages, 'schema')
expect(result[0].parts).toEqual([])
})
it('strips update_notebook previous_content before it reaches the model on history replay', () => {
const messages = [
assistantMessage([
toolPart({
state: 'output-available',
output: {
id: 'notebook-1',
name: 'Signup funnel',
previous_content: { schema_version: 1, cells: [] },
},
}),
]),
]
const result = prepareMessagesForModel(messages, 'schema')
expect(result[0].parts).toEqual([
toolPart({
state: 'output-available',
output: { id: 'notebook-1', name: 'Signup funnel' },
}),
])
})
it('keeps non-tool parts untouched', () => {
const messages = [assistantMessage([{ type: 'text', text: 'hello' }])]
const result = prepareMessagesForModel(messages, 'schema')
expect(result[0].parts).toEqual([{ type: 'text', text: 'hello' }])
})
})