Files
supabase/apps/studio/lib/ai/message-utils.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

68 lines
2.4 KiB
TypeScript

import {
isToolUIPart,
type UIDataTypes,
type UIMessage,
type UIMessagePart,
type UITools,
} from 'ai'
type UIPart = UIMessagePart<UIDataTypes, UITools>
/**
* Prepares messages for API transmission by cleaning and limiting history
*/
export function prepareMessagesForAPI(messages: UIMessage[]): UIMessage[] {
// [Joshen] Specifically limiting the chat history that get's sent to reduce the
// size of the context that goes into the model. This should always be an odd number
// as much as possible so that the first message is always the user's
const MAX_CHAT_HISTORY = 7
const slicedMessages = messages.slice(-MAX_CHAT_HISTORY)
// Filter out results from messages before sending to the model
const cleanedMessages = slicedMessages.map((_message) => {
const message = _message as UIMessage & { results?: unknown }
const cleanedMessage = { ...message } as UIMessage & { results?: unknown }
if (message.role === 'assistant' && message.results) {
delete cleanedMessage.results
}
return cleanedMessage as UIMessage
})
return cleanedMessages
}
/**
* Approval id when the part is waiting on a human Approve/Deny.
* Narrows with `isToolUIPart` first, matching the AI SDK `useChat` approval pattern:
* `state === 'approval-requested' && !approval.isAutomatic`.
*
* @see https://ai-sdk.dev/docs/agents/tool-approvals
*/
export function getManualApprovalId(part: UIPart): string | undefined {
if (!isToolUIPart(part) || part.state !== 'approval-requested') return undefined
if ('isAutomatic' in part.approval && part.approval.isAutomatic === true) return undefined
return part.approval.id
}
/** True when the part is waiting on a human Approve/Deny, not an automatic policy decision. */
export function isManualApprovalRequested(part: UIPart): boolean {
return getManualApprovalId(part) !== undefined
}
/**
* Returns approval IDs to auto-deny when the model issues multiple approval-required
* tool calls in the same turn — all but the first, so the model reissues them sequentially.
*/
export function getParallelApprovalIdsToReject(messages: UIMessage[]): string[] {
const lastMessage = messages.findLast((m) => m.role === 'assistant')
if (!lastMessage) return []
const pendingIds: string[] = []
for (const part of lastMessage.parts ?? []) {
const id = getManualApprovalId(part)
if (id) pendingIds.push(id)
}
return pendingIds.slice(1)
}