Files
supabase/apps/studio/lib/ai/message-utils.ts
Charis 8920439569 Expose previous notebook content in update_notebook (#49401)
## Summary
- Plumb pre-update notebook snapshot through `update_notebook` tool
response as `previous_content`
- Add sanitizers in `tool-sanitizer.ts` to strip snapshot before model
sees it
- Add client-side stripping in `prepareMessagesForAPI` to avoid
re-uploading snapshot on subsequent turns
- This is PR 2 of 3 fixing Linear issue FE-4243 (notebook update
proposal shows 'unapplyable' error for already-completed updates)
- Ships no visible behavior change on its own; enables PR 3 to restore
diff preview for completed updates

## Test plan
- [x] Unit tests: 80/80 passing across notebook-tools.test.ts,
tool-sanitizer.test.ts, generate-assistant-response.utils.test.ts,
message-utils.test.ts, and mock-tools.test.ts
- [x] Typecheck: clean for all changed files
- [x] ESLint: zero errors, lint:ratchet passes (exit 0)
- [x] Integration: previous_content is correctly populated with
pre-update notebook, stripped before model context, and stripped on
client-side re-upload

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

- **Bug Fixes**
- Notebook updates now retain previous content for recovery and history.
- AI responses expose only the notebook’s ID and name, keeping previous
content out of model-visible data.

- **Tests**
- Added coverage for notebook update results, content sanitization, and
message preparation, including cases where previous content is absent or
preserved.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-21 14:58:32 -04:00

82 lines
3.1 KiB
TypeScript

import {
isToolUIPart,
type UIDataTypes,
type UIMessage,
type UIMessagePart,
type UITools,
} from 'ai'
type UIPart = UIMessagePart<UIDataTypes, UITools>
/** Strips `update_notebook`'s `previous_content` snapshot — display-only, never re-uploaded. */
function stripNotebookSnapshot(part: UIPart): UIPart {
if (!isToolUIPart(part) || part.type !== 'tool-update_notebook') return part
if (!part.output || typeof part.output !== 'object') return part
const { previous_content, ...sanitizedOutput } = part.output as Record<string, unknown>
return { ...part, output: sanitizedOutput } as UIPart
}
/**
* 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
}
// Map into a new array rather than mutating in place — `parts` is shared by reference
// with the locally persisted message the assistant panel reads from.
if (cleanedMessage.parts) {
cleanedMessage.parts = cleanedMessage.parts.map(stripNotebookSnapshot)
}
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)
}