Files
supabase/apps/studio/lib/ai/message-utils.test.ts
T
Charis cd6afdd8b2 refactor(studio): add tool contracts and migrate update_notebook (#51626)
## Summary

Each assistant tool's identity and contract is currently written in up
to nine places that nothing ties together at compile time. This PR adds
a contract/server split and migrates `update_notebook`, the tool with
the most copies, as the first proof of the design.

- **Contract** (`lib/ai/tools/update-notebook.ts`): isomorphic file with
the input and output schemas, approval flag, minimum opt-in level, and
`toModelOutput`, the single rule for what the model may see.
- **Server** (`update-notebook.server.ts`): the executable tool, built
with `approvalGatedToolFromContract`.
- **Registry** (`registry.ts`): collects contracts, derives a typed
`StudioUIMessage`, and provides `sanitizeContractPart` for history
replay.
- **Shared types** (`types.ts`): `ToolContract`, `ToolContext`,
`ToolFactory`, plus a single `getAuthHeaders` replacing three variants.
- **Approval gate** (`approval-gate.ts`): approval-gated tools get
`acceptUntrustedSql` / `acceptUntrustedLogsSql` through a gate passed to
`execute`, and `*.server.ts` files can't import the raw functions
(ESLint).
- **Opt-in level**: `update_notebook` is gated by its contract's
`minOptInLevel`. `TOOL_CATEGORY_MAP` is now typed against `ToolName` and
excludes migrated tools, which exposed `get_active_incidents` missing
from the name enum.

Removed duplicates: the `update_notebook` sanitizer in
`tool-sanitizer.ts`, the client-side `stripNotebookSnapshot`, and the
copied update schemas in `Message.utils.ts`.

## Behavior changes

- The client no longer strips `previous_content` from `update_notebook`
outputs before sending history, so requests carry the previous notebook
snapshot. The server still strips it before the model sees it.
- On history replay, an `update_notebook` output that doesn't match its
schema is dropped instead of passed through minus `previous_content`.

## Testing

- New `registry.test.ts` (typed parts, replay sanitizer) and a contract
case in `tool-filter.test.ts`.
- Studio typecheck, lint ratchet, knip, Prettier and the affected vitest
suites pass.
2026-10-14 10:44:38 -04:00

266 lines
7.9 KiB
TypeScript

import type { DynamicToolUIPart, UIMessage } from 'ai'
import { describe, expect, it } from 'vitest'
import {
getParallelApprovalIdsToReject,
isManualApprovalRequested,
prepareMessagesForAPI,
} from './message-utils'
const makeApprovalPart = (id: string, isAutomatic = false): DynamicToolUIPart =>
({
type: 'dynamic-tool',
toolName: 'test_tool',
toolCallId: id,
state: 'approval-requested',
input: {},
approval: { id, ...(isAutomatic ? { isAutomatic: true } : {}) },
}) as DynamicToolUIPart
const makeResultPart = (id: string): DynamicToolUIPart => ({
type: 'dynamic-tool',
toolName: 'test_tool',
toolCallId: id,
state: 'output-available',
input: {},
output: {},
})
describe('isManualApprovalRequested', () => {
it('returns true for a human approval-requested tool part', () => {
expect(isManualApprovalRequested(makeApprovalPart('a1'))).toBe(true)
})
it('returns false for an automatic approval', () => {
expect(isManualApprovalRequested(makeApprovalPart('a1', true))).toBe(false)
})
it('returns false for a tool result part', () => {
expect(isManualApprovalRequested(makeResultPart('r1'))).toBe(false)
})
it('returns false for a content part with no state or approval', () => {
expect(isManualApprovalRequested({ type: 'text', text: 'hello' })).toBe(false)
})
})
describe('getParallelApprovalIdsToReject', () => {
it('returns [] for empty messages', () => {
expect(getParallelApprovalIdsToReject([])).toEqual([])
})
it('returns [] when there are no assistant messages', () => {
const messages: UIMessage[] = [{ id: '1', role: 'user', parts: [] }]
expect(getParallelApprovalIdsToReject(messages)).toEqual([])
})
it('returns [] when last assistant message has no pending approvals', () => {
const messages: UIMessage[] = [{ id: '1', role: 'assistant', parts: [makeResultPart('r1')] }]
expect(getParallelApprovalIdsToReject(messages)).toEqual([])
})
it('returns [] when there is only one pending approval', () => {
const messages: UIMessage[] = [{ id: '1', role: 'assistant', parts: [makeApprovalPart('a1')] }]
expect(getParallelApprovalIdsToReject(messages)).toEqual([])
})
it('returns all but the first id when there are multiple pending approvals', () => {
const messages: UIMessage[] = [
{
id: '1',
role: 'assistant',
parts: [makeApprovalPart('a1'), makeApprovalPart('a2'), makeApprovalPart('a3')],
},
]
expect(getParallelApprovalIdsToReject(messages)).toEqual(['a2', 'a3'])
})
it('only inspects the last assistant message', () => {
const messages: UIMessage[] = [
{
id: '1',
role: 'assistant',
parts: [makeApprovalPart('old1'), makeApprovalPart('old2')],
},
{ id: '2', role: 'user', parts: [] },
{ id: '3', role: 'assistant', parts: [makeApprovalPart('new1')] },
]
expect(getParallelApprovalIdsToReject(messages)).toEqual([])
})
it('ignores non-approval tool parts', () => {
const messages: UIMessage[] = [
{
id: '1',
role: 'assistant',
parts: [makeResultPart('r1'), makeApprovalPart('a1'), makeApprovalPart('a2')],
},
]
expect(getParallelApprovalIdsToReject(messages)).toEqual(['a2'])
})
it('ignores automatic approvals when picking extras to reject', () => {
const messages: UIMessage[] = [
{
id: '1',
role: 'assistant',
parts: [
makeApprovalPart('auto', true),
makeApprovalPart('manual-1'),
makeApprovalPart('manual-2'),
],
},
]
expect(getParallelApprovalIdsToReject(messages)).toEqual(['manual-2'])
})
})
describe('prepareMessagesForAPI', () => {
it('should limit messages to last 7 entries', () => {
const messages: UIMessage[] = Array.from({ length: 10 }, (_, i) => ({
id: `msg-${i}`,
role: 'user',
parts: [{ type: 'text', text: `Message ${i}` }],
}))
const result = prepareMessagesForAPI(messages)
expect(result).toHaveLength(7)
expect(result[0].parts[0]).toEqual({ type: 'text', text: 'Message 3' })
expect(result[6].parts[0]).toEqual({ type: 'text', text: 'Message 9' })
})
it('should remove results property from assistant messages', () => {
const messages = [
{
id: 'msg-1',
role: 'assistant',
parts: [{ type: 'text', text: 'Response' }],
results: { data: 'some data' },
},
] as Array<UIMessage & { results?: unknown }>
const result = prepareMessagesForAPI(messages)
expect(result).toHaveLength(1)
expect(result[0]).not.toHaveProperty('results')
expect(result[0].parts[0]).toEqual({ type: 'text', text: 'Response' })
})
it('should preserve messages without results', () => {
const messages: UIMessage[] = [
{
id: 'msg-1',
role: 'user',
parts: [{ type: 'text', text: 'Question' }],
},
{
id: 'msg-2',
role: 'assistant',
parts: [{ type: 'text', text: 'Answer' }],
},
]
const result = prepareMessagesForAPI(messages)
expect(result).toHaveLength(2)
expect(result[0]).toEqual(messages[0])
expect(result[1]).toEqual(messages[1])
})
it('should handle empty array', () => {
const messages: UIMessage[] = []
const result = prepareMessagesForAPI(messages)
expect(result).toHaveLength(0)
expect(result).toEqual([])
})
it('should handle array with fewer than 7 messages', () => {
const messages: UIMessage[] = [
{ id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Message 1' }] },
{ id: 'msg-2', role: 'assistant', parts: [{ type: 'text', text: 'Message 2' }] },
{ id: 'msg-3', role: 'user', parts: [{ type: 'text', text: 'Message 3' }] },
]
const result = prepareMessagesForAPI(messages)
expect(result).toHaveLength(3)
expect(result).toEqual(messages)
})
it('should handle array with exactly 7 messages', () => {
const messages: UIMessage[] = Array.from({ length: 7 }, (_, i) => ({
id: `msg-${i}`,
role: i % 2 === 0 ? 'user' : 'assistant',
parts: [{ type: 'text', text: `Message ${i}` }],
}))
const result = prepareMessagesForAPI(messages)
expect(result).toHaveLength(7)
expect(result).toEqual(messages)
})
it('should only remove results from assistant messages, not user messages', () => {
const messages = [
{
id: 'msg-1',
role: 'user',
parts: [{ type: 'text', text: 'Question' }],
results: { data: 'user data' },
},
{
id: 'msg-2',
role: 'assistant',
parts: [{ type: 'text', text: 'Answer' }],
results: { data: 'assistant data' },
},
] as Array<UIMessage & { results?: unknown }>
const result = prepareMessagesForAPI(messages)
expect(result).toHaveLength(2)
// User message keeps results (not removed by the function)
expect((result[0] as any).results).toEqual({ data: 'user data' })
// Assistant message has results removed
expect(result[1]).not.toHaveProperty('results')
})
it('should handle mixed messages with and without results', () => {
const messages = [
{
id: 'msg-1',
role: 'assistant',
parts: [{ type: 'text', text: 'First' }],
results: { data: 'data1' },
},
{
id: 'msg-2',
role: 'user',
parts: [{ type: 'text', text: 'Second' }],
},
{
id: 'msg-3',
role: 'assistant',
parts: [{ type: 'text', text: 'Third' }],
},
{
id: 'msg-4',
role: 'assistant',
parts: [{ type: 'text', text: 'Fourth' }],
results: { data: 'data2' },
},
] as Array<UIMessage & { results?: unknown }>
const result = prepareMessagesForAPI(messages)
expect(result).toHaveLength(4)
expect(result[0]).not.toHaveProperty('results')
expect(result[1]).toEqual(messages[1])
expect(result[2]).toEqual(messages[2])
expect(result[3]).not.toHaveProperty('results')
})
})