Files
supabase/apps/studio/lib/ai/generate-assistant-response.utils.ts
Charis ebd616fa90 fix(studio): auto-retry notebook updates on stale/invalid conflicts (#49323)
## Summary

- **Removed dead client-side refresh UI** in
`NotebookProposalRenderer.tsx` and its test — the diff preview is always
computed from live data, so the check was redundant with the tool's
server-side re-validation
- **Added typed `NotebookToolError`** in `notebook-tools.ts` with
structured metadata (`{ exposeToAssistant: boolean }`) validated by a
zod schema with a literal discriminant tag (`tag:
'notebook_tool_error'`) — tracks the two retryable failures: staleness
conflict and invalid operations (unknown cell id)
- **Encoded errors in `generate-v4.ts` onError** — the one place in the
pipeline that holds the live `Error` before it becomes a string in the
persisted message
- **Extracted and fixed message history filter** into new
`generate-assistant-response.utils.ts` — any tool-error whose
`errorText` decodes against the `NotebookToolError` schema is let
through (with `errorText` rewritten to plain prose so the model sees the
message, not JSON), while other errors stay filtered as before

Net effect: the assistant detects the specific, actionable rejection
reason and retries on its own with no dead button or human intervention
needed.

## Test plan

- Existing unit tests in `NotebookProposalRenderer.test.tsx` pass (dead
button test removed)
- New unit tests in `notebook-tools.test.ts` cover encode/decode
round-trips and error discrimination
- New unit tests in `generate-assistant-response.utils.test.ts` cover
message history filtering with all error states
- `pnpm typecheck` is clean
- `pnpm --filter studio run lint:ratchet` passes (no new ESLint
warnings)

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

## Summary by CodeRabbit

* **New Features**
* Notebook update errors now provide clearer, structured explanations to
the AI assistant.
* Assistant responses preserve relevant notebook error details while
filtering invalid or temporary tool states.
* **Bug Fixes**
* Improved handling of stale notebook revisions and invalid notebook
update operations.
* Notebook proposal rendering proceeds without an unnecessary refresh
step.
* **Tests**
* Expanded coverage for notebook errors, message filtering,
serialization, and error handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-20 16:08:19 -04:00

51 lines
2.0 KiB
TypeScript

import { isToolUIPart, type DynamicToolUIPart, type ToolUIPart, type UIMessage } from 'ai'
import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { decodeNotebookToolError } from '@/lib/ai/tools/notebook-tools'
import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer'
const INVALID_TOOL_STATES = [
'input-streaming',
'input-available',
'approval-requested',
'output-error',
]
/**
* Notebook tool errors are opted into model visibility explicitly (NotebookToolError,
* encoded in generate-v4.ts's onError). A successful parse against the schema — including
* its literal `tag` — is proof enough that this was a NotebookToolError; every other
* output-error stays hidden, same as before. Rewrites errorText back to the plain message
* so the model sees prose, not JSON.
*/
function exposedNotebookErrorPart(
part: ToolUIPart | DynamicToolUIPart
): ToolUIPart | DynamicToolUIPart | null {
if (part.state !== 'output-error') return null
const decoded = decodeNotebookToolError(part.errorText)
if (!decoded?.exposeToAssistant) return null
return { ...part, errorText: decoded.message }
}
/** Trims history to the last 7 messages and strips tool parts the model shouldn't see. */
export function prepareMessagesForModel(rawMessages: UIMessage[], aiOptInLevel: AiOptInLevel) {
return (rawMessages || []).slice(-7).map((msg) => {
if (msg && msg.role === 'assistant' && 'results' in msg) {
const cleanedMsg = { ...msg }
delete cleanedMsg.results
return cleanedMsg
}
if (msg && msg.role === 'assistant' && msg.parts) {
const cleanedParts = msg.parts.flatMap((part) => {
if (!isToolUIPart(part)) return [part]
if (!INVALID_TOOL_STATES.includes(part.state))
return [sanitizeMessagePart(part, aiOptInLevel)]
const exposed = exposedNotebookErrorPart(part)
return exposed ? [sanitizeMessagePart(exposed, aiOptInLevel)] : []
})
return { ...msg, parts: cleanedParts }
}
return msg
})
}