Files
supabase/apps/studio/lib/ai/tools/studio-tools.test.ts
Saxon Fletcher aa2897f712 feat(studio): teach assistant to query ClickHouse logs (#49292)
## 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 and bug fix.

## What is the current behavior?

The assistant can call `query_logs`, but it is not given the ClickHouse
schema and query-writing guidance it needs. It also lacks a current UTC
reference for producing the absolute timestamps required by the tool,
which can lead to valid queries being run against the wrong time range
and reported as returning zero rows.

## What is the new behavior?

- Adds a dedicated `logs` knowledge topic backed by the shared
ClickHouse schema and query guidance.
- Requires the assistant to load that knowledge before using
`query_logs`.
- Includes the current UTC time in project context so relative requests
can be converted to correct absolute tool parameters.
- Covers the new knowledge flow and context with focused tests and
updates the assistant eval expectation.

## How to test

1. Check out this PR and run Studio against a project that has recent
logs. Generate some project activity first, such as an API request, if
needed.
2. Open the AI Assistant and ask: `Show log counts by minute for the
last 15 minutes and summarize any spikes.`
3. Expand the assistant's tool activity and verify it loads the `logs`
knowledge topic before calling `query_logs`.
4. Inspect the `query_logs` input and verify:
- `iso_timestamp_start` and `iso_timestamp_end` are absolute UTC
timestamps ending in `Z`.
   - The timestamps cover approximately the requested 15-minute window.
- The SQL uses ClickHouse syntax, includes a `LIMIT`, and does not put
the time range in the SQL `WHERE` clause.
5. Verify the assistant's summary reflects the rows returned by
`query_logs` instead of reporting zero rows when results are present.

## Additional context

This is the bottom PR in stack #49294. The front-end visualization is
added separately in #49293.

Verified with 59 focused tests across assistant context, Studio/MCP
tools, query display, and logs result parsing.


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

- **New Features**
- Added AI-assisted project log querying through the `query_logs` tool.
- Added logs knowledge guidance for time ranges, schema discovery, query
limits, and concise result summaries.
- Project context now includes the current UTC timestamp to improve
relative time-range interpretation.
- Improved notebook assistance with safer table verification and
appropriate handling of log queries.

- **Bug Fixes**
- Prevented incorrect SQL timestamp filtering and enabled cross-service
searches without requiring a source filter.
  - Added validation for supported knowledge topics.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-21 09:30:54 +10:00

262 lines
8.6 KiB
TypeScript

import { safeSql } from '@supabase/pg-meta'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getStudioTools } from './studio-tools'
import { executeSql } from '@/data/sql/execute-sql-mutation'
import { NO_DATA_PERMISSIONS } from '@/lib/ai/tools/tool-sanitizer'
vi.mock('@/data/sql/execute-sql-mutation', () => ({
executeSql: vi.fn(),
}))
describe('ai/tools/studio-tools', () => {
beforeEach(() => {
vi.mocked(executeSql).mockReset()
})
describe('getStudioTools', () => {
it('should return an object with tool definitions', () => {
const tools = getStudioTools()
expect(tools).toBeDefined()
expect(typeof tools).toBe('object')
})
it('should include execute_sql tool', () => {
const tools = getStudioTools()
expect(tools.execute_sql).toBeDefined()
expect(tools.execute_sql.description).toContain('execute a SQL statement')
})
it('should include deploy_edge_function tool', () => {
const tools = getStudioTools()
expect(tools.deploy_edge_function).toBeDefined()
expect(tools.deploy_edge_function.description).toContain('deploy a Supabase Edge Function')
})
it('should include rename_chat tool', () => {
const tools = getStudioTools()
expect(tools.rename_chat).toBeDefined()
expect(tools.rename_chat.description).toContain('Rename the current chat session')
})
it('should have exactly 4 tools', () => {
const tools = getStudioTools()
const toolNames = Object.keys(tools)
expect(toolNames).toHaveLength(4)
expect(toolNames).toContain('load_knowledge')
expect(toolNames).toContain('execute_sql')
expect(toolNames).toContain('deploy_edge_function')
expect(toolNames).toContain('rename_chat')
})
it('should include logs in the load_knowledge schema', () => {
const tools = getStudioTools()
const schema = tools.load_knowledge.inputSchema
if ('safeParse' in schema) {
expect(schema.safeParse({ name: 'logs' }).success).toBe(true)
expect(schema.safeParse({ name: 'pg_best_practices' }).success).toBe(true)
expect(schema.safeParse({ name: 'not_a_topic' }).success).toBe(false)
} else {
expect(schema).toBeDefined()
}
})
it('should return ClickHouse logs knowledge for load_knowledge logs', async () => {
const tools = getStudioTools()
if (!tools.load_knowledge.execute) throw new Error('execute is undefined')
const result = await tools.load_knowledge.execute(
{ name: 'logs' },
{ toolCallId: 'test', messages: [], context: {} }
)
expect(result).toContain('query_logs')
expect(result).toContain('iso_timestamp_start')
expect(result).toContain('# Supabase logs SQL (ClickHouse)')
expect(result).toContain('interactive query cell')
})
it('should have execute_sql with correct input schema fields', () => {
const tools = getStudioTools()
const executeSqlTool = tools.execute_sql
// Check that the tool has an input schema
expect(executeSqlTool.inputSchema).toBeDefined()
// Verify the schema exists and is a Zod object
const schema = executeSqlTool.inputSchema
expect(schema).toBeDefined()
expect((schema as any)._def.typeName).toBe('ZodObject')
})
it('should have deploy_edge_function with input schema', () => {
const tools = getStudioTools()
const deployTool = tools.deploy_edge_function
expect(deployTool.inputSchema).toBeDefined()
// Verify the schema exists and is a Zod object
expect(deployTool.inputSchema).toBeDefined()
expect((deployTool.inputSchema as any)._def.typeName).toBe('ZodObject')
})
it('should have rename_chat with execute function', async () => {
const tools = getStudioTools()
const renameTool = tools.rename_chat
expect(renameTool.execute).toBeDefined()
expect(typeof renameTool.execute).toBe('function')
// Test the execute function
if (!renameTool.execute) throw new Error('execute is undefined')
const result = await renameTool.execute(
{ newName: 'Test Chat' },
{ toolCallId: 'test', messages: [], context: {} }
)
expect(result).toEqual({ status: 'Chat request sent to client' })
})
it('should validate execute_sql input schema correctly', () => {
const tools = getStudioTools()
const schema = tools.execute_sql.inputSchema
// Check if schema is a Zod schema with safeParse
if ('safeParse' in schema) {
// Valid input
const validInput = {
sql: safeSql`SELECT * FROM users`,
label: 'Get users',
chartConfig: { view: 'table' as const },
isWriteQuery: false,
}
expect(schema.safeParse(validInput).success).toBe(true)
// Valid chart config
const validChartInput = {
sql: safeSql`SELECT count(*) FROM users`,
label: 'User count',
chartConfig: { view: 'chart' as const, xAxis: 'date', yAxis: 'count' },
isWriteQuery: false,
}
expect(schema.safeParse(validChartInput).success).toBe(true)
// Missing required field
const invalidInput = {
sql: safeSql`SELECT * FROM users`,
// missing label, chartConfig, isWriteQuery
}
expect(schema.safeParse(invalidInput).success).toBe(false)
} else {
// Skip test if schema doesn't have safeParse
expect(schema).toBeDefined()
}
})
it('should require approval for read and write SQL queries', () => {
const tools = getStudioTools()
expect(tools.execute_sql.needsApproval).toBe(true)
})
it('should return execute_sql rows to the UI and sanitize model output without data opt-in', async () => {
const rows = [{ email: 'test@example.com' }]
vi.mocked(executeSql).mockResolvedValue({ result: rows })
const tools = getStudioTools({
projectRef: 'test-project',
connectionString: 'encrypted-connection-string',
aiOptInLevel: 'schema',
})
if (!tools.execute_sql.execute) throw new Error('execute is undefined')
const result = await tools.execute_sql.execute(
{
sql: 'SELECT email FROM users',
label: 'Get emails',
chartConfig: { view: 'table' },
isWriteQuery: false,
},
{ toolCallId: 'test', messages: [], context: {} }
)
expect(executeSql).toHaveBeenCalledWith(
{
projectRef: 'test-project',
connectionString: 'encrypted-connection-string',
sql: 'SELECT email FROM users',
},
undefined,
undefined
)
expect(result).toEqual(rows)
expect((tools.execute_sql as any).toModelOutput({ output: result })).toEqual({
type: 'text',
value: NO_DATA_PERMISSIONS,
})
})
it('should return execute_sql rows with data opt-in', async () => {
const rows = [{ email: 'test@example.com' }]
vi.mocked(executeSql).mockResolvedValue({ result: rows })
const tools = getStudioTools({
projectRef: 'test-project',
connectionString: 'encrypted-connection-string',
aiOptInLevel: 'schema_and_log_and_data',
})
if (!tools.execute_sql.execute) throw new Error('execute is undefined')
const result = await tools.execute_sql.execute(
{
sql: 'SELECT email FROM users',
label: 'Get emails',
chartConfig: { view: 'table' },
isWriteQuery: false,
},
{ toolCallId: 'test', messages: [], context: {} }
)
expect(executeSql).toHaveBeenCalledWith(
{
projectRef: 'test-project',
connectionString: 'encrypted-connection-string',
sql: 'SELECT email FROM users',
},
undefined,
undefined
)
expect(result).toEqual(rows)
expect((tools.execute_sql as any).toModelOutput({ output: result })).toEqual({
type: 'json',
value: rows,
})
})
it('should validate rename_chat input schema correctly', () => {
const tools = getStudioTools()
const schema = tools.rename_chat.inputSchema
// Check if schema is a Zod schema with safeParse
if ('safeParse' in schema) {
// Valid input
expect(schema.safeParse({ newName: 'My Chat' }).success).toBe(true)
// Invalid input - missing newName
expect(schema.safeParse({}).success).toBe(false)
// Invalid input - wrong type
expect(schema.safeParse({ newName: 123 }).success).toBe(false)
} else {
// Skip test if schema doesn't have safeParse
expect(schema).toBeDefined()
}
})
})
})