mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
## 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 — final PR (9/9) of the SQL editor logs-source stack. **Base branch:** `charislam/sql-editor-inline-ai-clickhouse-dialect` (PR 8). Nothing here is user-visible: entry points stay behind `sqlEditorLogsSource` + `otelLegacyLogs`, and flag rollout happens after the whole stack merges. ## What is the current behavior? - The Assistant has no idea a SQL editor snippet targets the logs backend. Ask it about a logs snippet and it answers in Postgres, because the attached query is fenced as ` ```sql ` and nothing tells the model otherwise. - Because the `sql` fence is what `MessageMarkdown` treats as runnable Postgres, an attached ClickHouse query is rendered with a Run-against-Postgres affordance and branded with `untrustedSql`. - "Debug with Assistant" on a failed logs query produces a dialect-less prompt, so both the in-app assistant and the copyable version get debugged as Postgres. - A report referencing a `log_sql` snippet runs its ClickHouse SQL against the user's Postgres database and surfaces the resulting error. ## What is the new behavior? **Assistant panel.** The "Current Query" chip records which backend the attached query targets. That reaches the model two ways: each attachment is fenced with its own dialect (` ```clickhouse ` vs ` ```sql `), and a `containsLogsSnippets` flag rides on the user message as AI SDK `metadata`. The server reads the flag off the conversation and prepends the ClickHouse dialect rules plus the logs schema reference as a non-cached context message. Two design points worth calling out in review: - The flag lives on the **message**, not the request body, so Retry and the tool-approval continuation reproduce the context a message was originally asked in — neither of those passes a per-call body. - It's derived from **what's actually attached**, so detaching the chip drops the claim rather than leaving the two able to disagree. The `clickhouse` fence also keeps a logs query out of `MessageMarkdown`'s `sql` branch, so it's no longer offered as runnable Postgres or branded with `untrustedSql` — a boundary this stack's distinct brands exist to prevent crossing. **Debug flow.** `buildDebugChatArgs` attaches its query with a source for the same reason, and names the dialect in the prompt text so the copyable version stands on its own outside the app. **Reports.** A report only stores a snippet id, so whether it queries the logs backend is only knowable once the content loads. `ReportBlock` guards on the fetched type and renders a `LogsSnippetReportBlock` placeholder instead of executing. Double-guarded: no `sql` for a logs snippet (so it's out of the query key and `queryFn` short-circuits even on an explicit `refetch`) and `enabled` excludes it. **Incidental cleanups.** `buildAssistantContextMessages` extracted out of `generate-assistant-response`; a schema-access sentinel that was duplicated as a string literal across two files (and compared against) replaced with one exported constant; `SqlSnippet` deduplicated to a single declaration; `resolveSnippetSource` / `isLogsSource` shared instead of re-implemented per surface. **Tests.** 4 new/extended suites. Notable cases pinned: a message with no metadata must validate (`safeValidateUIMessages` applies `metadataSchema` to *every* message, so a required schema would 400 every existing conversation); only *user* messages count, so a model reply can't talk the server into a different dialect; a mixed-attachment message is flagged without overclaiming a single source; and `ReportBlock` registers no pg-meta mock for the logs cases, so an unhandled request failing the test *is* the assertion that logs SQL never reaches Postgres. Verified: `pnpm typecheck`, `lint:ratchet` (no regression), Prettier, and the full Studio suite (459 files / 4969 tests). ## Additional context <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for recognizing log snippets in reports, with clear guidance to open them in the SQL editor or remove them. - AI Assistant now understands log snippets and provides ClickHouse-specific context, formatting, and troubleshooting guidance. - Snippets retain their source information when shared with the AI Assistant. - **Bug Fixes** - Prevented unsupported log snippets from being executed as regular database queries. - Improved source detection when opening snippets directly from links. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
231 lines
7.8 KiB
TypeScript
231 lines
7.8 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)
|
|
})
|
|
})
|
|
|
|
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```')
|
|
})
|
|
})
|