Files
supabase/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.test.ts
Saxon Fletcher e605178a63 feat(studio): render assistant log query results (#49293)
<img width="1510" height="862" alt="image"
src="https://github.com/user-attachments/assets/f7157bad-9b23-4d73-a9aa-2a7a7c179318"
/>


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

`query_logs` can return rows to the assistant, but the chat UI does not
hydrate those rows into the query result by default. The query only
becomes visible after clicking **Run query**, even though the same SQL
and time range work when rerun manually.

## What is the new behavior?

- Renders `query_logs` tool output through a dedicated logs message part
using the shared assistant query cell.
- Parses the exact MCP untrusted-data envelope into the initial query
result, without changing what the assistant model receives.
- Preserves the logs source and time range for manual reruns.
- Infers a useful table or chart presentation from the returned rows
while retaining explicit display settings.
- Adds focused tests for MCP result parsing, timestamps, errors, query
source handling, and visualization inference.

## 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. Wait for `query_logs` to finish. Verify the query cell appears with
results already populated; do not click **Run query** first.
4. Verify the aggregate result opens as a chart, then switch to the
table view and confirm the underlying rows are present.
5. Click **Run query** and verify the query runs successfully again
using the same logs source and 15-minute time range.
6. Ask: `Show the 20 most recent log entries from the last 15 minutes.`
Verify this non-aggregate result opens as a table with rows already
populated.
7. Confirm the assistant's written summary agrees with the displayed
rows and does not report zero rows when results are visible.

## Additional context

This is the top PR in stack #49294 and depends on the back-end knowledge
change in #49292.

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 Assistant support for querying and displaying application
logs.
* Added automatic visualization selection, including charts for
time-based and categorical data.
* Added source-aware query handling with dedicated titles, time ranges,
and result displays.
  * Added clearer loading, parsing, and error states for log queries.

* **Bug Fixes**
* Improved handling of streamed results, source changes, and query
display updates.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-21 09:30:55 +10:00

133 lines
4.5 KiB
TypeScript

import { untrustedSql } from '@supabase/pg-meta'
import { describe, expect, it } from 'vitest'
import {
changeAssistantQuerySource,
createAssistantQueryModel,
getAssistantQueryDisplay,
setAssistantQuerySql,
shouldClearAssistantQueryResult,
toAssistantQueryResult,
} from './AssistantQueryCell.utils'
import { DEFAULT_CELL_ROW_LIMIT } from '@/components/interfaces/Explorer/QueryCell/QueryCell.utils'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
describe('getAssistantQueryDisplay', () => {
it('defaults to a table view with no chart when axes are missing', () => {
expect(getAssistantQueryDisplay({})).toEqual({ view: 'table', chart: undefined })
})
it('builds a bar chart config from axis hints', () => {
expect(getAssistantQueryDisplay({ view: 'chart', xAxis: 'day', yAxis: 'signups' })).toEqual({
view: 'chart',
chart: {
type: 'bar',
x_column: 'day',
y_series: ['signups'],
cumulative: false,
scale: 'linear',
show_labels: false,
},
})
})
it('keeps an empty y-axis list when only the x-axis is provided', () => {
expect(getAssistantQueryDisplay({ xAxis: 'day' }).chart?.y_series).toEqual([])
})
})
describe('toAssistantQueryResult', () => {
it('returns undefined when the output is not an array of row objects', () => {
expect(toAssistantQueryResult(undefined)).toBeUndefined()
expect(toAssistantQueryResult('error')).toBeUndefined()
expect(toAssistantQueryResult({ rows: [] })).toBeUndefined()
})
it('keeps row objects and drops primitives, arrays, and nulls', () => {
expect(toAssistantQueryResult([{ id: 1 }, null, ['x'], 4, { id: 2 }])).toEqual({
rows: [{ id: 1 }, { id: 2 }],
})
})
it('accepts an empty array as a successful empty result', () => {
expect(toAssistantQueryResult([])).toEqual({ rows: [] })
})
})
describe('assistant query model', () => {
it('starts as a database query with the notebook default row limit', () => {
expect(createAssistantQueryModel('select 1')).toEqual({
_tag: 'database',
uncheckedSql: untrustedSql('select 1'),
rowLimit: DEFAULT_CELL_ROW_LIMIT,
})
})
it('starts as a logs query when the source is logs', () => {
const time_range = { _tag: 'relative_time_range' as const, unit: 'day' as const, amount: 1 }
expect(createAssistantQueryModel('select 1 from logs', { _tag: 'logs', time_range })).toEqual({
_tag: 'logs',
uncheckedSql: untrustedLogSql('select 1 from logs'),
time_range,
})
})
it('rebrands the live SQL for the current backend', () => {
const database = createAssistantQueryModel('select 1')
expect(setAssistantQuerySql(database, 'select 2').uncheckedSql).toBe(untrustedSql('select 2'))
const logs = changeAssistantQuerySource(database, {
_tag: 'logs',
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
})
expect(logs._tag).toBe('logs')
expect(setAssistantQuerySql(logs, 'select 3').uncheckedSql).toBe(untrustedLogSql('select 3'))
})
it('carries the SQL across a source change and restores the default row limit onto logs → database', () => {
const logs = changeAssistantQuerySource(createAssistantQueryModel('select 1'), {
_tag: 'logs',
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
})
const database = changeAssistantQuerySource(logs, { _tag: 'database' })
expect(database).toEqual({
_tag: 'database',
uncheckedSql: untrustedSql('select 1'),
rowLimit: DEFAULT_CELL_ROW_LIMIT,
})
})
it('clears an existing result when only the logs time range changes', () => {
const logs = createAssistantQueryModel('select 1 from logs', {
_tag: 'logs',
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
})
expect(
shouldClearAssistantQueryResult(logs, {
_tag: 'logs',
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 3 },
})
).toBe(true)
expect(
shouldClearAssistantQueryResult(logs, {
_tag: 'logs',
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
})
).toBe(false)
})
it('compares canonical database bindings when deciding whether to clear results', () => {
const database = createAssistantQueryModel('select 1')
expect(shouldClearAssistantQueryResult(database, { _tag: 'database' })).toBe(false)
expect(
shouldClearAssistantQueryResult(database, {
_tag: 'database',
database_identifier: 'replica-1',
})
).toBe(true)
})
})