From ebd616fa901db16d4dd1272effa238cd022f25c9 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:08:19 -0400 Subject: [PATCH] fix(studio): auto-retry notebook updates on stale/invalid conflicts (#49323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) ## 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. --- .../NotebookProposalRenderer.test.tsx | 27 ---- .../NotebookProposalRenderer.tsx | 26 ---- .../lib/ai/generate-assistant-response.ts | 34 +---- .../generate-assistant-response.utils.test.ts | 125 ++++++++++++++++++ .../ai/generate-assistant-response.utils.ts | 50 +++++++ .../lib/ai/tools/notebook-tools.test.ts | 91 +++++++++---- apps/studio/lib/ai/tools/notebook-tools.ts | 57 +++++++- apps/studio/pages/api/ai/sql/generate-v4.ts | 4 + 8 files changed, 303 insertions(+), 111 deletions(-) create mode 100644 apps/studio/lib/ai/generate-assistant-response.utils.test.ts create mode 100644 apps/studio/lib/ai/generate-assistant-response.utils.ts diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx index 5d591f90644..df86f4795e2 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx @@ -98,33 +98,6 @@ describe('NotebookProposalRenderer', () => { expect(onApprove).toHaveBeenCalledTimes(1) }) - it('warns and withholds the diff when the notebook changed since expected_updated_at', async () => { - const onApprove = vi.fn() - mockContentItem(mockNotebookRow({ updated_at: '2024-06-01T00:00:00.000Z' })) - - render( - - ) - - expect( - await screen.findByText('This notebook changed since the assistant planned this update') - ).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() - expect(onApprove).not.toHaveBeenCalled() - }) - it('falls back to a raw-input admonition without dropping the confirm footer on a parse failure', async () => { const user = userEvent.setup() const onApprove = vi.fn() diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index 0f2c665de51..e59c5d6aa06 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx @@ -238,8 +238,6 @@ function UpdateNotebookProposal({ isLoading, isError, error, - refetch, - isFetching, } = useNotebookQuery( { projectRef: ref, id: parsedInput.success ? parsedInput.data.id : undefined }, { enabled: parsedInput.success } @@ -284,30 +282,6 @@ function UpdateNotebookProposal({ ) } - const isStale = notebook.updated_at !== parsedInput.data.expected_updated_at - - if (isStale) { - return ( - refetch()} - > -
- -
-
- ) - } - const diff = deriveNotebookDiff(toWireNotebook(notebook.content), parsedInput.data.operations) if (!diff.success) { diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index 9f457df4bc5..1488882f504 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -2,7 +2,6 @@ import * as ai from 'ai' import { convertToModelMessages, isStepCount, - isToolUIPart, type LanguageModel, type ModelMessage, type SystemModelMessage, @@ -16,6 +15,7 @@ import type { AssistantEvalInput } from '@/evals/scorer' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { buildAssistantContextMessages, NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context' import { IS_TRACING_ENABLED } from '@/lib/ai/braintrust-logger' +import { prepareMessagesForModel } from '@/lib/ai/generate-assistant-response.utils' import { CHAT_PROMPT, GENERAL_PROMPT, @@ -23,7 +23,6 @@ import { NOTEBOOKS_PROMPT, SECURITY_PROMPT, } from '@/lib/ai/prompts' -import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' const { streamText: tracedStreamText } = wrapAISDK(ai) @@ -74,36 +73,7 @@ export async function generateAssistantResponse({ const shouldTrace = allowTracing ?? IS_TRACING_ENABLED const run = async (span?: Span) => { - // Only returns last 7 messages - // Filters out tools with invalid states - // Filters out tool outputs based on opt-in level - const messages = (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 - .filter((part) => { - if (isToolUIPart(part)) { - const invalidStates = [ - 'input-streaming', - 'input-available', - 'approval-requested', - 'output-error', - ] - return !invalidStates.includes(part.state) - } - return true - }) - .map((part) => { - return sanitizeMessagePart(part, aiOptInLevel) - }) - return { ...msg, parts: cleanedParts } - } - return msg - }) + const messages = prepareMessagesForModel(rawMessages, aiOptInLevel) const schemasString = aiOptInLevel !== 'disabled' && getSchemas diff --git a/apps/studio/lib/ai/generate-assistant-response.utils.test.ts b/apps/studio/lib/ai/generate-assistant-response.utils.test.ts new file mode 100644 index 00000000000..7e5d23429df --- /dev/null +++ b/apps/studio/lib/ai/generate-assistant-response.utils.test.ts @@ -0,0 +1,125 @@ +import type { ToolUIPart, UIMessage } from 'ai' +import { describe, expect, it } from 'vitest' + +import { prepareMessagesForModel } from './generate-assistant-response.utils' +import { encodeNotebookToolError, NotebookToolError } from './tools/notebook-tools' + +function assistantMessage(parts: UIMessage['parts']): UIMessage { + return { id: 'msg-1', role: 'assistant', parts } +} + +function toolPart(overrides: Partial): ToolUIPart { + return { + type: 'tool-update_notebook', + toolCallId: 'call-1', + state: 'output-available', + input: {}, + ...overrides, + } as ToolUIPart +} + +describe('prepareMessagesForModel', () => { + it('filters out a plain output-error tool part', () => { + const messages = [ + assistantMessage([toolPart({ state: 'output-error', errorText: 'Network error' })]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('keeps a stale-update_notebook conflict and rewrites errorText to the plain message', () => { + const error = new NotebookToolError('Notebook changed since expected_updated_at', { + exposeToAssistant: true, + }) + const messages = [ + assistantMessage([ + toolPart({ state: 'output-error', errorText: encodeNotebookToolError(error)! }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([ + toolPart({ state: 'output-error', errorText: 'Notebook changed since expected_updated_at' }), + ]) + }) + + it('still filters out an unrelated update_notebook output-error', () => { + const messages = [ + assistantMessage([ + toolPart({ state: 'output-error', errorText: 'Unexpected upstream failure' }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('still filters out JSON-shaped output-error text lacking the notebook_tool_error tag', () => { + const messages = [ + assistantMessage([ + toolPart({ + state: 'output-error', + errorText: JSON.stringify({ + exposeToAssistant: true, + message: 'not a real notebook error', + }), + }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('exposes any tool part carrying a validly-tagged NotebookToolError, not just update_notebook', () => { + const error = new NotebookToolError('Notebook changed since expected_updated_at', { + exposeToAssistant: true, + }) + const messages = [ + assistantMessage([ + toolPart({ + type: 'tool-execute_sql', + state: 'output-error', + errorText: encodeNotebookToolError(error)!, + }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([ + toolPart({ + type: 'tool-execute_sql', + state: 'output-error', + errorText: 'Notebook changed since expected_updated_at', + }), + ]) + }) + + it('still filters out input-streaming, input-available, and approval-requested parts', () => { + const messages = [ + assistantMessage([ + toolPart({ state: 'input-streaming' }), + toolPart({ state: 'input-available' }), + toolPart({ state: 'approval-requested', approval: { id: 'a1' } } as Partial), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('keeps non-tool parts untouched', () => { + const messages = [assistantMessage([{ type: 'text', text: 'hello' }])] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([{ type: 'text', text: 'hello' }]) + }) +}) diff --git a/apps/studio/lib/ai/generate-assistant-response.utils.ts b/apps/studio/lib/ai/generate-assistant-response.utils.ts new file mode 100644 index 00000000000..3d2851656ec --- /dev/null +++ b/apps/studio/lib/ai/generate-assistant-response.utils.ts @@ -0,0 +1,50 @@ +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 + }) +} diff --git a/apps/studio/lib/ai/tools/notebook-tools.test.ts b/apps/studio/lib/ai/tools/notebook-tools.test.ts index 2650d326ca0..fb692afbf20 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.test.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.test.ts @@ -2,7 +2,12 @@ import { components } from 'api-types' import { HttpResponse } from 'msw' import { describe, expect, it } from 'vitest' -import { getNotebookTools } from './notebook-tools' +import { + decodeNotebookToolError, + encodeNotebookToolError, + getNotebookTools, + NotebookToolError, +} from './notebook-tools' import type { AgentNotebook } from '@/data/content/notebooks/notebook-schema' import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' @@ -432,40 +437,80 @@ describe('ai/tools/notebook-tools', () => { expect(result).toEqual({ id: 'notebook-1', name: 'Signup funnel' }) }) - it('should throw a descriptive error instead of PUTting when an operation targets an unknown cell id', async () => { + it('should throw a descriptive, assistant-exposable error instead of PUTting when an operation targets an unknown cell id', async () => { mockGetNotebook() const tools = getNotebookTools({ projectRef: 'test-project' }) if (!tools.update_notebook.execute) throw new Error('execute is undefined') - await expect( - tools.update_notebook.execute( - { - id: 'notebook-1', - expected_updated_at: '2026-01-01T00:00:00.000Z', - operations: [{ _tag: 'delete_cell', cell_id: 'missing-cell' }], - }, - { toolCallId: 'test', messages: [], context: {} } - ) - ).rejects.toThrow('No cell with id "missing-cell"') + const execute = tools.update_notebook.execute( + { + id: 'notebook-1', + expected_updated_at: '2026-01-01T00:00:00.000Z', + operations: [{ _tag: 'delete_cell', cell_id: 'missing-cell' }], + }, + { toolCallId: 'test', messages: [], context: {} } + ) + + await expect(execute).rejects.toThrow('No cell with id "missing-cell"') + await expect(execute).rejects.toBeInstanceOf(NotebookToolError) + await expect(execute).rejects.toMatchObject({ metadata: { exposeToAssistant: true } }) }) - it('should throw instead of PUTting when the notebook changed since expected_updated_at', async () => { + it('should throw an assistant-exposable error instead of PUTting when the notebook changed since expected_updated_at', async () => { mockGetNotebook() const tools = getNotebookTools({ projectRef: 'test-project' }) if (!tools.update_notebook.execute) throw new Error('execute is undefined') - await expect( - tools.update_notebook.execute( - { - id: 'notebook-1', - expected_updated_at: '2025-12-31T00:00:00.000Z', - operations: [{ _tag: 'delete_cell', cell_id: 'cell-3' }], - }, - { toolCallId: 'test', messages: [], context: {} } - ) - ).rejects.toThrow(/changed since expected_updated_at/) + const execute = tools.update_notebook.execute( + { + id: 'notebook-1', + expected_updated_at: '2025-12-31T00:00:00.000Z', + operations: [{ _tag: 'delete_cell', cell_id: 'cell-3' }], + }, + { toolCallId: 'test', messages: [], context: {} } + ) + + await expect(execute).rejects.toThrow(/changed since expected_updated_at/) + await expect(execute).rejects.toBeInstanceOf(NotebookToolError) + await expect(execute).rejects.toMatchObject({ metadata: { exposeToAssistant: true } }) + }) + }) + + describe('encodeNotebookToolError / decodeNotebookToolError', () => { + it('round-trips a NotebookToolError through JSON', () => { + const error = new NotebookToolError('Notebook changed since expected_updated_at', { + exposeToAssistant: true, + }) + + const encoded = encodeNotebookToolError(error) + expect(encoded).not.toBeNull() + + const decoded = decodeNotebookToolError(encoded!) + expect(decoded).toEqual({ + exposeToAssistant: true, + tag: 'notebook_tool_error', + message: 'Notebook changed since expected_updated_at', + }) + }) + + it('does not encode a plain Error', () => { + expect(encodeNotebookToolError(new Error('boom'))).toBeNull() + }) + + it('does not decode a plain error message', () => { + expect(decodeNotebookToolError('boom')).toBeNull() + }) + + it('does not decode unrelated JSON', () => { + expect(decodeNotebookToolError(JSON.stringify({ foo: 'bar' }))).toBeNull() + }) + + it('does not decode JSON that merely looks like a NotebookToolError but lacks the tag', () => { + expect( + decodeNotebookToolError(JSON.stringify({ exposeToAssistant: true, message: 'boom' })) + ).toBeNull() }) }) }) diff --git a/apps/studio/lib/ai/tools/notebook-tools.ts b/apps/studio/lib/ai/tools/notebook-tools.ts index 1ec84bdb88d..2c4498d459e 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.ts @@ -24,6 +24,54 @@ export type NotebookToolsContext = { authorization?: string } +const notebookToolErrorMetadataSchema = z.object({ + exposeToAssistant: z.boolean(), +}) + +export type NotebookToolErrorMetadata = z.infer + +const notebookToolErrorSchema = notebookToolErrorMetadataSchema.extend({ + tag: z.literal('notebook_tool_error'), + message: z.string(), +}) + +export type EncodedNotebookToolError = z.infer + +/** Thrown by update_notebook for failures the assistant can act on by retrying. */ +export class NotebookToolError extends Error { + readonly metadata: NotebookToolErrorMetadata + + constructor(message: string, metadata: NotebookToolErrorMetadata) { + super(message) + this.name = 'NotebookToolError' + this.metadata = notebookToolErrorMetadataSchema.parse(metadata) + } +} + +/** Called from the stream's `onError` (generate-v4.ts), which still has the live Error. */ +export function encodeNotebookToolError(error: unknown): string | null { + if (!(error instanceof NotebookToolError)) return null + return JSON.stringify( + notebookToolErrorSchema.parse({ + ...error.metadata, + tag: 'notebook_tool_error', + message: error.message, + }) + ) +} + +/** Called from the history filter (generate-assistant-response.ts) on a persisted errorText. */ +export function decodeNotebookToolError(errorText: string): EncodedNotebookToolError | null { + let parsed: unknown + try { + parsed = JSON.parse(errorText) + } catch { + return null + } + const result = notebookToolErrorSchema.safeParse(parsed) + return result.success ? result.data : null +} + export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const { projectRef, authorization } = ctx const authHeaders = authorization ? { Authorization: authorization } : undefined @@ -153,8 +201,9 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders) if (notebook.updated_at !== expected_updated_at) { - throw new Error( - `Notebook "${id}" changed since expected_updated_at (${expected_updated_at}); it is now ${notebook.updated_at}. Call get_notebook again and reissue update_notebook against the current content.` + throw new NotebookToolError( + `Notebook "${id}" changed since expected_updated_at (${expected_updated_at}); it is now ${notebook.updated_at}. Call get_notebook again and reissue update_notebook against the current content.`, + { exposeToAssistant: true } ) } @@ -165,7 +214,9 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const result = applyNotebookOperations(wireNotebook, operations) if (!result.success) { - throw new Error(describeNotebookOperationError(result.error)) + throw new NotebookToolError(describeNotebookOperationError(result.error), { + exposeToAssistant: true, + }) } // Same promotion as create_notebook above, inlined here for the same auditability diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index d8900ab88db..1b056af7c64 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -26,6 +26,7 @@ import { type AssistantModelId, } from '@/lib/ai/model.utils' import { getTools } from '@/lib/ai/tools' +import { encodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' import { apiWrapper } from '@/lib/api/apiWrapper' import { executeQuery } from '@/lib/api/self-hosted/query' import { getURL } from '@/lib/helpers' @@ -250,6 +251,9 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw onError: (error) => { console.error('Assistant stream error:', error) + const encoded = encodeNotebookToolError(error) + if (encoded !== null) return encoded + if (error == null) { return 'unknown error' }