Files
supabase/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts
bd76d7fc34 feat(studio): wrap assistant Edge Function approval in a Confirm card (#49168)
<img width="1512" height="862" alt="image"
src="https://github.com/user-attachments/assets/79a6d4dc-dcd2-489f-97d7-3ee7a0196b7d"
/>


## 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?

Feature / UI refactor.

## What is the current behavior?

Assistant Edge Function approval nests `ConfirmFooter` under the
function block. `addToolApprovalResponse` is wired whenever state is
`approval-requested`, including automatic approvals.

## What is the new behavior?

Introduces a `Confirm` card that owns the frame, with the footer
attached below the body. Edge Function approval uses that card.
Interactive Approve/Deny only runs for manual `approval-requested` parts
(`!approval.isAutomatic`), matching the [AI SDK tool-approvals `useChat`
guidelines](https://ai-sdk.dev/docs/agents/tool-approvals).

SQL still uses `DisplayBlockRenderer` until #49170. `ConfirmFooter` is
inlined into `Confirm` so SQL can keep importing the named footer until
that PR.

## Additional context

Part of stack #49171. Base: `chore/ai-sdk-7` (#49167).

Notebook proposal Confirm wrapping is **not** in this stack — that file
lives on [#49159](https://github.com/supabase/supabase/pull/49159).
Follow up after that stack merges.

## Test plan

- [ ] Deploy-edge-function tool part shows Confirm with Skip / Deploy
- [ ] Existing-function replace warning still requires the second
confirm
- [ ] After approve, footer morphs to loading and buttons disable
- [ ] `Confirm.utils.test.ts` and `EdgeFunctionRenderer.test.tsx` pass

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

* **New Features**
* Added confirmation cards for AI-assisted actions, including approve
and cancel controls.
* Improved handling of manual approval requests for SQL execution,
notebook changes, and Edge Function deployment.
* Added support for customizing report and Edge Function block styling.

* **Bug Fixes**
* Automatic approvals no longer appear as pending manual confirmations.
  * Skipped SQL actions now provide clearer messaging.

* **Tests**
* Expanded coverage for approval states, confirmation controls, and
automatic decisions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:35:34 +10:00

243 lines
8.2 KiB
TypeScript

import type { UIMessage } from 'ai'
import { describe, expect, test } from 'vitest'
import {
containsLogsSnippets,
formatAttachedSnippets,
hasPendingToolApproval,
isReadOnlySelect,
resolvePendingToolApprovalsAsDenied,
} from './AIAssistant.utils'
const createMessageWithPart = (
part: UIMessage['parts'][number],
role: UIMessage['role'] = 'assistant'
) =>
[
{
id: `${role}-msg-1`,
role,
parts: [part],
},
] as UIMessage[]
const pendingSqlApprovalPart = {
type: 'tool-execute_sql',
toolCallId: 'call-1',
state: 'approval-requested',
input: { sql: 'select 1', label: 'Test query' },
approval: { id: 'approval-1' },
} satisfies UIMessage['parts'][number]
describe('AIAssistant.utils.ts:isReadOnlySelect', () => {
test('Should return true for SQL that only contains SELECT operation', () => {
const sql = 'select * from countries where id > 100 order by id asc;'
const result = isReadOnlySelect(sql)
expect(result).toBe(true)
})
test('Should return false for SQL that contains INSERT operation', () => {
const sql = `insert into countries (id, name) values (1, 'hello');`
const result = isReadOnlySelect(sql)
expect(result).toBe(false)
})
test('Should return false for SQL that contains UPDATE operation', () => {
const sql = `update countries set name = 'hello' where id = 2;`
const result = isReadOnlySelect(sql)
expect(result).toBe(false)
})
test('Should return false for SQL that contains DELETE operation', () => {
const sql = `delete from countries where id = 2;`
const result = isReadOnlySelect(sql)
expect(result).toBe(false)
})
test('Should return false for SQL that contains ALTER operation', () => {
const sql = `alter table countries drop column id if exists;`
const result = isReadOnlySelect(sql)
expect(result).toBe(false)
})
test('Should return false for SQL that contains DROP operation', () => {
const sql = `drop table if exists countries;`
const result = isReadOnlySelect(sql)
expect(result).toBe(false)
})
test('Should return false for SQL that contains CREATE operation', () => {
const sql = `create schema test_schema;`
const result = isReadOnlySelect(sql)
expect(result).toBe(false)
})
test('Should return false for SQL that contains REPLACE operation', () => {
const sql = `create or replace view test_view as select * from countries where id > 500;`
const result = isReadOnlySelect(sql)
expect(result).toBe(false)
})
test('Should return false for SQL that calls a function not whitelisted', () => {
const sql = `select create_new_user();`
const result = isReadOnlySelect(sql)
expect(result).toBe(false)
})
test('Should return true for SQL that calls a function that is whitelisted', () => {
const sql = `select count(select * from countries);`
const result = isReadOnlySelect(sql)
expect(result).toBe(true)
})
test('Should return false for SQL that contains a write operation with a read operation', () => {
const sql1 = `select count(select * from countries); create schema joshen;`
const result1 = isReadOnlySelect(sql1)
expect(result1).toBe(false)
const sql2 = `create schema joshen; select count(select * from countries);`
const result2 = isReadOnlySelect(sql2)
expect(result2).toBe(false)
})
})
describe('AIAssistant.utils.ts:hasPendingToolApproval', () => {
test('Should return true when an assistant message has a pending tool approval', () => {
const messages = createMessageWithPart(pendingSqlApprovalPart)
expect(hasPendingToolApproval(messages)).toBe(true)
})
test('Should return false when approval has already been answered', () => {
const messages = createMessageWithPart({
type: 'tool-execute_sql',
toolCallId: 'call-1',
state: 'approval-responded',
input: { sql: 'select 1', label: 'Test query' },
approval: { id: 'approval-1', approved: true },
})
expect(hasPendingToolApproval(messages)).toBe(false)
})
test('Should ignore non-assistant messages', () => {
const messages = createMessageWithPart(pendingSqlApprovalPart, 'user')
expect(hasPendingToolApproval(messages)).toBe(false)
})
test('Should return true for dynamic tool approvals', () => {
const messages = createMessageWithPart({
type: 'dynamic-tool',
toolName: 'execute_sql',
toolCallId: 'call-1',
state: 'approval-requested',
input: { sql: 'select 1', label: 'Test query' },
approval: { id: 'approval-1' },
})
expect(hasPendingToolApproval(messages)).toBe(true)
})
test('Should ignore automatic approvals', () => {
const messages = createMessageWithPart({
type: 'tool-execute_sql',
toolCallId: 'call-1',
state: 'approval-requested',
input: { sql: 'select 1', label: 'Test query' },
approval: { id: 'approval-1', isAutomatic: true },
} as UIMessage['parts'][number])
expect(hasPendingToolApproval(messages)).toBe(false)
})
})
describe('AIAssistant.utils.ts:resolvePendingToolApprovalsAsDenied', () => {
test('Should convert pending approvals into denied outputs', () => {
const messages = createMessageWithPart(pendingSqlApprovalPart)
const resolvedMessages = resolvePendingToolApprovalsAsDenied(messages)
const [part] = resolvedMessages[0].parts
expect(part).toMatchObject({
state: 'output-denied',
approval: {
id: 'approval-1',
approved: false,
reason: 'Skipped because the user sent a follow-up message.',
},
})
})
test('Should leave completed approvals unchanged', () => {
const messages = createMessageWithPart({
type: 'tool-execute_sql',
toolCallId: 'call-1',
state: 'output-available',
input: { sql: 'select 1', label: 'Test query' },
output: [],
approval: { id: 'approval-1', approved: true },
})
expect(resolvePendingToolApprovalsAsDenied(messages)).toEqual(messages)
})
})
describe('containsLogsSnippets', () => {
test('is true when an attached query is a logs query', () => {
expect(
containsLogsSnippets([{ label: 'Current Query', content: 'select 1', source: 'logs' }])
).toBe(true)
})
test('is false for a database query', () => {
expect(
containsLogsSnippets([{ label: 'Current Query', content: 'select 1', source: 'database' }])
).toBe(false)
})
test('is false once nothing is attached', () => {
expect(containsLogsSnippets([])).toBe(false)
expect(containsLogsSnippets(undefined)).toBe(false)
})
test('ignores plain string attachments, which carry no source', () => {
expect(containsLogsSnippets(['select 1'])).toBe(false)
})
test('is true when only some of several attachments are logs queries', () => {
expect(
containsLogsSnippets([
'select 1',
{ label: 'Current Query', content: 'select 2', source: 'database' },
{ label: 'Other', content: 'select 3', source: 'logs' },
])
).toBe(true)
})
})
describe('formatAttachedSnippets', () => {
test('fences a database query as sql', () => {
expect(
formatAttachedSnippets([{ label: 'Current Query', content: 'select 1', source: 'database' }])
).toBe('```sql\nselect 1\n```')
})
// The fence is how the model tells which attachment is ClickHouse. It also keeps a
// logs query out of MessageMarkdown's `sql` branch, which offers to run the block
// against Postgres and brands it with untrustedSql.
test('fences a logs query as clickhouse', () => {
expect(
formatAttachedSnippets([
{
label: 'Current Query',
content: "select count() from logs where source = 'edge_logs'",
source: 'logs',
},
])
).toBe("```clickhouse\nselect count() from logs where source = 'edge_logs'\n```")
})
test('labels each attachment with its own dialect', () => {
expect(
formatAttachedSnippets([
{ label: 'A', content: 'select 1', source: 'database' },
{ label: 'B', content: 'select 2', source: 'logs' },
])
).toBe('```sql\nselect 1\n```\n```clickhouse\nselect 2\n```')
})
test('falls back to sql for a plain string attachment', () => {
expect(formatAttachedSnippets(['select 1'])).toBe('```sql\nselect 1\n```')
})
})