Files
supabase/apps/studio/lib/ai/tools/tool-sanitizer.test.ts
dcac820571 feat(studio): add assistant notebook run tool (#49361)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Assistant feature and data-handling plumbing.

## Stack context

This stack is based on #49352 (`chore/assistant-tool-outcomes`) and
assumes #49350–#49352 merge first.

Review bottom to top:

1. #49361 — assistant notebook run tool
2. #49362 — assistant notebook run UI
3. #49364 — terminal-state polish

## What is the current behavior?

The Assistant can read and edit notebooks, but it cannot execute all
saved query cells as one approved operation.

## What is the new behavior?

- Adds a `run_notebook` tool with one approval gate for the complete
notebook.
- Executes database and log cells sequentially in notebook order.
- Rejects stale runs when the notebook changed after the Assistant read
it.
- Resolves primary and read-replica connections and forwards
authorization to log and replica requests.
- Shares rows with the model only when the organization's AI
data-sharing level permits it.
- Strictly validates and sanitizes persisted notebook-run output before
replaying message history.
- Registers the tool in prompts, filtering, mocks, and tool
construction.

## How to test manually

This is the tool/data layer; use the top-of-stack preview from #49364
for the complete UI while checking these behaviors.

1. In Explorer, create and save a notebook named **Assistant run smoke
test** with:
   - a markdown cell
   - a working database query
   - a working Logs query
- a database query that returns no rows, such as `select 1 where false`
2. Open the AI Assistant and ask: **Read the “Assistant run smoke test”
notebook and analyze it using its current results.**
3. Confirm the Assistant reads the notebook and requests one
`run_notebook` approval for all query cells, rather than requesting one
approval per cell.
4. Approve the run. Confirm database and Logs queries execute in
notebook order, the markdown cell is not executed, and the Assistant
responds only after the complete run finishes.
5. Start another run but do not approve it yet. In another tab, edit and
save the notebook. Return to the pending approval and approve it.
6. Confirm the stale run is rejected, the Assistant reads the latest
notebook version, and a new approval is required.
7. Optional privacy check: set the organization AI data-sharing level to
schema-only, run a query containing a recognizable value, and confirm
the value remains visible in the notebook result UI but is not repeated
in the Assistant's answer.

## Automated test

`mise exec node@22 -- pnpm --dir apps/studio exec vitest --run
lib/ai/tool-filter.test.ts lib/ai/tools/index.test.ts
lib/ai/tools/mock-tools.test.ts lib/ai/tools/notebook-tools.test.ts
lib/ai/tools/tool-sanitizer.test.ts`

80 tests pass at this stack boundary.

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

- **New Features**
- Added AI-assisted notebook execution for database and log cells, with
approval, freshness checks, replica support, and per-cell error
handling.
- Added notebook deletion and database discovery and validation for
notebook management.
  - Added configurable privacy controls for notebook results.
  - Added request header support for analytics SQL execution.

- **Bug Fixes**
- Improved replica lookup handling so other notebook cells can continue
when one lookup fails.
- Prevented invalid, unauthorized, or overly detailed notebook execution
results from being exposed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saxon Fletcher <SaxonF@users.noreply.github.com>
2026-08-25 18:16:33 +10:00

271 lines
8.5 KiB
TypeScript

import type { ToolUIPart } from 'ai'
import { describe, expect, test } from 'vitest'
// End of third-party imports
import { prepareMessagesForAPI } from '../message-utils'
import {
createAssistantMessageWithExecuteSqlTool,
createAssistantMessageWithMultipleTools,
createAssistantMessageWithUpdateNotebookTool,
createLongConversation,
} from '../test-fixtures'
import { INVALID_NOTEBOOK_RUN_OUTPUT_MESSAGE, type NotebookRunOutput } from './notebook-run-output'
import { NO_DATA_PERMISSIONS, sanitizeMessagePart } from './tool-sanitizer'
const notebookRunPart = {
type: 'tool-run_notebook',
state: 'output-available',
toolCallId: 'run-1',
input: { id: 'notebook-1', expected_updated_at: '2026-01-01T00:00:00.000Z' },
output: {
id: 'notebook-1',
name: 'Notebook',
updated_at: '2026-01-01T00:00:00.000Z',
cells: [
{
cell_id: 'db',
title: 'Database',
source: 'database',
status: 'success',
rows: [{ secret: 'database value' }],
},
{
cell_id: 'logs',
title: 'Logs',
source: 'logs',
status: 'success',
rows: [{ secret: 'log value' }],
},
],
},
} as unknown as ToolUIPart
describe('messages are sanitized based on opt-in level', () => {
test('notebook runs sanitize database and log rows independently', () => {
const schemaOutput = (sanitizeMessagePart(notebookRunPart, 'schema') as ToolUIPart)
.output as NotebookRunOutput
const logOutput = (sanitizeMessagePart(notebookRunPart, 'schema_and_log') as ToolUIPart)
.output as NotebookRunOutput
const dataOutput = (
sanitizeMessagePart(notebookRunPart, 'schema_and_log_and_data') as ToolUIPart
).output as NotebookRunOutput
expect(schemaOutput.cells[0].rows).toBeUndefined()
expect(schemaOutput.cells[1].rows).toBeUndefined()
expect(logOutput.cells[0].rows).toBeUndefined()
expect(logOutput.cells[1].rows).toEqual([{ secret: 'log value' }])
expect(dataOutput.cells[0].rows).toEqual([{ secret: 'database value' }])
expect(dataOutput.cells[1].rows).toEqual([{ secret: 'log value' }])
})
test.each([
['a non-object output', 'client-controlled output'],
['an object without cells', { status: 'privacy message' }],
[
'a cell with an untrusted source label',
{
id: 'notebook-1',
name: 'Notebook',
updated_at: '2026-01-01T00:00:00.000Z',
cells: [
{
cell_id: 'db',
title: 'Database',
source: 'untrusted-source',
status: 'success',
rows: [{ secret: 'database value' }],
},
],
},
],
])('notebook runs fail closed for %s', (_description, output) => {
const malformedPart = { ...notebookRunPart, output } as ToolUIPart
expect((sanitizeMessagePart(malformedPart, 'schema_and_log') as ToolUIPart).output).toBe(
INVALID_NOTEBOOK_RUN_OUTPUT_MESSAGE
)
})
test('messages are sanitized at disabled level', () => {
const messages = [
createAssistantMessageWithExecuteSqlTool('SELECT email FROM users', [
{ email: 'test@example.com' },
]),
]
// Prepare messages as frontend would
const preparedMessages = prepareMessagesForAPI(messages)
// Sanitize messages as API endpoint would
const processedMessages = preparedMessages.map((msg) => {
if (msg.role === 'assistant' && msg.parts) {
const processedParts = msg.parts.map((part) => {
return sanitizeMessagePart(part, 'disabled')
})
return { ...msg, parts: processedParts }
}
return msg
})
const output = (processedMessages[0].parts[1] as ToolUIPart).output
expect(output).toMatch(NO_DATA_PERMISSIONS)
})
test('messages are sanitized at schema level', () => {
const messages = [
createAssistantMessageWithExecuteSqlTool('SELECT email FROM users', [
{ email: 'test@example.com' },
]),
]
// Prepare messages as frontend would
const preparedMessages = prepareMessagesForAPI(messages)
// Sanitize messages as API endpoint would
const processedMessages = preparedMessages.map((msg) => {
if (msg.role === 'assistant' && msg.parts) {
const processedParts = msg.parts.map((part) => {
return sanitizeMessagePart(part, 'schema')
})
return { ...msg, parts: processedParts }
}
return msg
})
const output = (processedMessages[0].parts[1] as ToolUIPart).output
expect(output).toMatch(NO_DATA_PERMISSIONS)
})
test('messages are sanitized at schema and log level', () => {
const messages = [
createAssistantMessageWithExecuteSqlTool('SELECT email FROM users', [
{ email: 'test@example.com' },
]),
]
// Prepare messages as frontend would
const preparedMessages = prepareMessagesForAPI(messages)
// Sanitize messages as API endpoint would
const processedMessages = preparedMessages.map((msg) => {
if (msg.role === 'assistant' && msg.parts) {
const processedParts = msg.parts.map((part) => {
return sanitizeMessagePart(part, 'schema_and_log')
})
return { ...msg, parts: processedParts }
}
return msg
})
const output = (processedMessages[0].parts[1] as ToolUIPart).output
expect(output).toMatch(NO_DATA_PERMISSIONS)
})
test('messages are not sanitized at data level', () => {
const messages = [
createAssistantMessageWithExecuteSqlTool('SELECT email FROM users', [
{ email: 'test@example.com' },
]),
]
// Prepare messages as frontend would
const preparedMessages = prepareMessagesForAPI(messages)
// Sanitize messages as API endpoint would
const processedMessages = preparedMessages.map((msg) => {
if (msg.role === 'assistant' && msg.parts) {
const processedParts = msg.parts.map((part) => {
return sanitizeMessagePart(part, 'schema_and_log_and_data')
})
return { ...msg, parts: processedParts }
}
return msg
})
const output = (processedMessages[0].parts[1] as ToolUIPart).output
expect(output).toEqual([{ email: 'test@example.com' }])
})
test('multiple tool parts in message are sanitized', () => {
const messages = [createAssistantMessageWithMultipleTools()]
// Prepare messages as frontend would
const preparedMessages = prepareMessagesForAPI(messages)
// Sanitize messages as API endpoint would
const processedMessages = preparedMessages.map((msg) => {
if (msg.role === 'assistant' && msg.parts) {
const processedParts = msg.parts.map((part) => {
return sanitizeMessagePart(part, 'schema')
})
return { ...msg, parts: processedParts }
}
return msg
})
const parts = processedMessages[0].parts
parts.forEach((part) => {
if (part.type.startsWith('tool')) {
const tool = part as ToolUIPart
expect(tool.output).toMatch(NO_DATA_PERMISSIONS)
}
})
})
test('long message chain is sanitized', () => {
const messages = createLongConversation()
// Prepare messages as frontend would
const preparedMessages = prepareMessagesForAPI(messages)
// Sanitize messages as API endpoint would
const processedMessages = preparedMessages.map((msg) => {
if (msg.role === 'assistant' && msg.parts) {
const processedParts = msg.parts.map((part) => {
return sanitizeMessagePart(part, 'schema')
})
return { ...msg, parts: processedParts }
}
return msg
})
processedMessages.forEach((msg) => {
if (msg.role === 'assistant' && msg.parts) {
const parts = msg.parts
parts.forEach((part) => {
if (part.type.startsWith('tool')) {
const tool = part as ToolUIPart
expect(tool.output).toMatch(NO_DATA_PERMISSIONS)
}
})
}
})
})
test('update_notebook previous_content is stripped from the tool output regardless of opt-in level', () => {
const message = createAssistantMessageWithUpdateNotebookTool()
const sanitized = sanitizeMessagePart(message.parts[0], 'schema') as ToolUIPart
expect(sanitized.output).toEqual({ id: 'notebook-1', name: 'Signup funnel' })
})
test('an update_notebook output that never had previous_content passes through unchanged', () => {
const message = createAssistantMessageWithUpdateNotebookTool({
id: 'notebook-1',
name: 'Signup funnel',
})
const sanitized = sanitizeMessagePart(message.parts[0], 'schema') as ToolUIPart
expect(sanitized.output).toEqual({ id: 'notebook-1', name: 'Signup funnel' })
})
})