From 7da254cb57adff5a7b395eb4f9452080c557d152 Mon Sep 17 00:00:00 2001 From: Jonathan Darko Adoo Date: Fri, 12 Jun 2026 12:06:25 +0000 Subject: [PATCH] Fix empty preview when parametric turns finish without SCAD (#181) --- shared/parametricAgentLoop.test.ts | 87 +++++++++++++++ shared/parametricAgentLoop.ts | 67 ++++++++++++ shared/parametricParts.test.ts | 146 ++++++++++++++++++++++++- shared/parametricParts.ts | 147 +++++++++++++++++++++++++- src/components/chat/ChatSession.tsx | 141 ++++++++++++++++-------- src/components/chat/MessageBubble.tsx | 27 +++++ src/server/aiChat.ts | 85 +++++++++++---- src/views/EditorView.tsx | 26 ++++- 8 files changed, 656 insertions(+), 70 deletions(-) create mode 100644 shared/parametricAgentLoop.test.ts create mode 100644 shared/parametricAgentLoop.ts diff --git a/shared/parametricAgentLoop.test.ts b/shared/parametricAgentLoop.test.ts new file mode 100644 index 00000000..2f9bc626 --- /dev/null +++ b/shared/parametricAgentLoop.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { AppUIMessage } from './chatAi.ts'; +import { shouldAutoContinueParametricBuild } from './parametricAgentLoop.ts'; + +function assistant(parts: AppUIMessage['parts']): AppUIMessage[] { + return [{ id: 'a1', role: 'assistant', parts, metadata: {} }]; +} + +describe('shouldAutoContinueParametricBuild', () => { + it('continues after a successful build awaiting inspection', () => { + assert.equal( + shouldAutoContinueParametricBuild( + assistant([ + { + type: 'tool-build_parametric_model', + toolCallId: 't1', + state: 'output-available', + input: { + title: 'Stand', + version: 'v1', + code: 'module phone_stand() { cube([10, 20, 30]); } phone_stand();', + }, + output: { status: 'success', message: 'ok' }, + }, + ]), + ), + true, + ); + }); + + it('continues after answer_user is rejected before any successful build', () => { + assert.equal( + shouldAutoContinueParametricBuild( + assistant([ + { + type: 'tool-answer_user', + toolCallId: 't1', + state: 'output-error', + input: { message: 'Done.' }, + errorText: 'build first', + }, + ]), + ), + true, + ); + }); + + it('stops once answer_user succeeds', () => { + assert.equal( + shouldAutoContinueParametricBuild( + assistant([ + { + type: 'tool-build_parametric_model', + toolCallId: 't1', + state: 'output-available', + input: { + title: 'Stand', + version: 'v1', + code: 'module phone_stand() { cube([10, 20, 30]); } phone_stand();', + }, + output: { status: 'success', message: 'ok' }, + }, + { + type: 'tool-answer_user', + toolCallId: 't2', + state: 'output-available', + input: { message: 'Done.' }, + output: { message: 'Done.' }, + }, + ]), + ), + false, + ); + }); + + it('does not continue on plain text with no tool calls', () => { + assert.equal( + shouldAutoContinueParametricBuild( + assistant([ + { type: 'text', text: 'Here is a phone stand.', state: 'done' }, + ]), + ), + false, + ); + }); +}); diff --git a/shared/parametricAgentLoop.ts b/shared/parametricAgentLoop.ts new file mode 100644 index 00000000..adcd5c25 --- /dev/null +++ b/shared/parametricAgentLoop.ts @@ -0,0 +1,67 @@ +import type { AppUIMessage } from './chatAi.ts'; +import { hasSuccessfulParametricBuild } from './parametricParts.ts'; + +type ToolMessagePart = Extract< + AppUIMessage['parts'][number], + { state: string } +>; + +function isToolMessagePart( + part: AppUIMessage['parts'][number], +): part is ToolMessagePart { + return part.type.startsWith('tool-') && 'state' in part; +} + +/** + * Whether the parametric agent loop should auto-resubmit after the latest + * assistant message. Mirrors the client `sendAutomaticallyWhen` gate in + * ChatSession so the behavior stays unit-testable. + */ +export function shouldAutoContinueParametricBuild( + messages: AppUIMessage[], +): boolean { + const message = messages[messages.length - 1]; + if (!message || message.role !== 'assistant') return false; + + if ( + message.parts.some( + (part) => + part.type === 'tool-answer_user' && part.state === 'output-available', + ) + ) { + return false; + } + + const lastStepStartIndex = message.parts.reduce( + (lastIndex, part, index) => + part.type === 'step-start' ? index : lastIndex, + -1, + ); + const toolParts = message.parts + .slice(lastStepStartIndex + 1) + .filter(isToolMessagePart); + + if (toolParts.length === 0) return false; + + const allResolved = toolParts.every( + (part) => + part.state === 'output-available' || part.state === 'output-error', + ); + if (!allResolved) return false; + + const answerUserPart = toolParts.find( + (part) => part.type === 'tool-answer_user', + ); + const hasBuildPart = toolParts.some( + (part) => part.type === 'tool-build_parametric_model', + ); + + if ( + answerUserPart?.state === 'output-error' && + !hasSuccessfulParametricBuild(message.parts) + ) { + return true; + } + + return hasBuildPart && !answerUserPart && allResolved; +} diff --git a/shared/parametricParts.test.ts b/shared/parametricParts.test.ts index f3683e86..4728dadc 100644 --- a/shared/parametricParts.test.ts +++ b/shared/parametricParts.test.ts @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { cleanAssistantText } from './parametricParts.ts'; +import { + cleanAssistantText, + extractScadFromText, + getAssistantSalvageText, + isParametricArtifact, + parametricTurnMissingBuild, + shouldReportMissingParametricBuild, +} from './parametricParts.ts'; describe('parametric assistant text cleanup', () => { it('removes leaked view metadata before final prose', () => { @@ -19,3 +26,140 @@ describe('parametric assistant text cleanup', () => { ); }); }); + +describe('parametric build detection', () => { + it('rejects artifacts with code shorter than 20 characters', () => { + assert.equal( + isParametricArtifact({ title: 'Stand', version: 'v1', code: 'cube(1);' }), + false, + ); + }); + + it('accepts valid artifacts', () => { + assert.equal( + isParametricArtifact({ + title: 'Phone stand', + version: 'v1', + code: 'module phone_stand() { cube([10, 20, 30]); } phone_stand();', + }), + true, + ); + }); + + it('detects turns that finished without any build tool call', () => { + assert.equal( + parametricTurnMissingBuild([ + { type: 'text', text: 'Here is a phone stand design.', state: 'done' }, + ]), + true, + ); + assert.equal( + parametricTurnMissingBuild([ + { + type: 'tool-build_parametric_model', + state: 'output-available', + input: { + title: 'Stand', + version: 'v1', + code: 'module phone_stand() { cube([10, 20, 30]); } phone_stand();', + }, + }, + ]), + false, + ); + }); + + it('does not report missing build while a client tool is still pending', () => { + assert.equal( + shouldReportMissingParametricBuild([ + { + type: 'tool-build_parametric_model', + toolCallId: 't1', + state: 'input-available', + input: { + title: 'Stand', + version: 'v1', + code: 'module phone_stand() { cube([10, 20, 30]); } phone_stand();', + }, + }, + ]), + false, + ); + }); + + it('reports when every build attempt failed with invalid artifacts', () => { + assert.equal( + shouldReportMissingParametricBuild([ + { + type: 'tool-build_parametric_model', + toolCallId: 't1', + state: 'output-error', + input: { title: 'Stand', version: 'v1', code: 'bad' }, + errorText: 'invalid artifact', + }, + ]), + true, + ); + }); + + it('does not report when a failed build still has viewable code', () => { + assert.equal( + shouldReportMissingParametricBuild([ + { + type: 'tool-build_parametric_model', + toolCallId: 't1', + state: 'output-error', + input: { + title: 'Stand', + version: 'v1', + code: 'module phone_stand() { cube([10, 20, 30]); } phone_stand();', + }, + errorText: 'compile failed', + }, + ]), + false, + ); + }); +}); + +describe('extractScadFromText', () => { + it('extracts fenced OpenSCAD code blocks', () => { + const code = + 'module phone_stand() {\n cube([40, 60, 10]);\n}\nphone_stand();'; + assert.equal( + extractScadFromText( + `Here is the model:\n\`\`\`openscad\n${code}\n\`\`\``, + ), + code, + ); + }); + + it('ignores non-OpenSCAD fenced blocks', () => { + assert.equal( + extractScadFromText('```python\nprint("hello")\n```'), + undefined, + ); + }); + + it('extracts generic fenced blocks that look like OpenSCAD', () => { + const code = + 'module phone_stand() {\n cube([40, 60, 10]);\n}\nphone_stand();'; + assert.equal(extractScadFromText(`\`\`\`\n${code}\n\`\`\``), code); + }); + + it('reads salvage text from answer_user tool messages', () => { + const text = getAssistantSalvageText([ + { + type: 'tool-answer_user', + toolCallId: 't1', + state: 'output-available', + input: { message: 'Done.' }, + output: { + message: + '```\nmodule phone_stand() { cube([10, 20, 30]); } phone_stand();\n```', + }, + }, + ]); + assert.match(text, /module phone_stand/); + }); +}); diff --git a/shared/parametricParts.ts b/shared/parametricParts.ts index 388e1334..aec3ef61 100644 --- a/shared/parametricParts.ts +++ b/shared/parametricParts.ts @@ -63,6 +63,46 @@ export function getParametricText(parts: unknown): string { .join(''); } +function answerUserPartText( + part: AppUIMessage['parts'][number], +): string | undefined { + if (part.type !== 'tool-answer_user') return undefined; + const fromOutput = + part.state === 'output-available' && + 'output' in part && + typeof part.output === 'object' && + part.output !== null && + 'message' in part.output && + typeof part.output.message === 'string' + ? part.output.message + : undefined; + const fromInput = + 'input' in part && + typeof part.input === 'object' && + part.input !== null && + 'message' in part.input && + typeof part.input.message === 'string' + ? part.input.message + : undefined; + const message = fromOutput ?? fromInput; + return message ? cleanAssistantText(message) : undefined; +} + +/** Text that may contain salvageable OpenSCAD outside the build tool. */ +export function getAssistantSalvageText(parts: unknown): string { + const chunks: string[] = []; + for (const part of asParametricParts(parts)) { + if (part.type === 'text') { + const text = cleanAssistantText(part.text).trim(); + if (text) chunks.push(text); + continue; + } + const answerText = answerUserPartText(part); + if (answerText?.trim()) chunks.push(answerText.trim()); + } + return chunks.join('\n\n'); +} + export function cleanAssistantText(text: string): string { text = text.replace(/!\[[^\]]*]\([^)]+\)/g, ''); @@ -181,6 +221,111 @@ export function isParametricArtifact( // and `parts` is optional. Parameters are derived client-side from // `code` via `parseParameters` so we don't check for them here either. return ( - typeof artifact.title === 'string' && typeof artifact.code === 'string' + typeof artifact.title === 'string' && + artifact.title.trim().length > 0 && + typeof artifact.code === 'string' && + artifact.code.trim().length >= 20 + ); +} + +export function hasParametricBuildAttempt(parts: unknown): boolean { + return asParametricParts(parts).some( + (part) => part.type === 'tool-build_parametric_model', + ); +} + +export function hasSuccessfulParametricBuild(parts: unknown): boolean { + return asParametricParts(parts).some( + (part) => + part.type === 'tool-build_parametric_model' && + part.state === 'output-available' && + isParametricArtifact(part.input), + ); +} + +export function hasPendingClientToolCall(parts: unknown): boolean { + return asParametricParts(parts).some( + (part) => + part.type.startsWith('tool-') && + 'state' in part && + part.state === 'input-available', + ); +} + +export function parametricTurnMissingBuild(parts: unknown): boolean { + const parametricParts = asParametricParts(parts); + if (parametricParts.length === 0) return false; + if (hasSuccessfulParametricBuild(parametricParts)) return false; + if (hasPendingBuildParametricModel(parametricParts)) return false; + return !hasParametricBuildAttempt(parametricParts); +} + +export function hasViewableParametricArtifact(parts: unknown): boolean { + return asParametricParts(parts).some( + (part) => + part.type === 'tool-build_parametric_model' && + part.state !== 'input-streaming' && + 'input' in part && + isParametricArtifact(part.input), ); } + +export function canSalvageParametricBuildFromText(parts: unknown): boolean { + return !!extractScadFromText(getAssistantSalvageText(parts)); +} + +/** True when a parametric turn ended with no model and no in-flight tool work. */ +export function shouldReportMissingParametricBuild(parts: unknown): boolean { + if ( + hasPendingClientToolCall(parts) || + hasPendingBuildParametricModel(parts) + ) { + return false; + } + if ( + hasSuccessfulParametricBuild(parts) || + hasViewableParametricArtifact(parts) + ) { + return false; + } + if (canSalvageParametricBuildFromText(parts)) { + return false; + } + return parametricTurnMissingBuild(parts) || hasParametricBuildAttempt(parts); +} + +const OPENSCAD_HINT = + /\b(module|cube|cylinder|sphere|difference|union|intersection|linear_extrude|rotate_extrude|import|color|hull|minkowski)\s*\(/i; + +function looksLikeOpenSCAD(code: string): boolean { + return OPENSCAD_HINT.test(code); +} + +/** + * Recover OpenSCAD the model pasted into markdown despite the tool-only rule. + * Used as a salvage path when `build_parametric_model` was never called. + */ +export function extractScadFromText(text: string): string | undefined { + const fencePatterns = [ + /```(?:openscad|scad|open-scad)?\s*\n([\s\S]*?)```/gi, + /```\s*\n([\s\S]*?)```/gi, + ]; + for (const fencePattern of fencePatterns) { + for (const match of text.matchAll(fencePattern)) { + const code = match[1]?.trim(); + if (code && code.length >= 20 && looksLikeOpenSCAD(code)) { + return code; + } + } + } + return undefined; +} + +export function extractScadFromAssistantParts( + parts: unknown, +): string | undefined { + return extractScadFromText(getAssistantSalvageText(parts)); +} + +export const MISSING_BUILD_NOTICE = + "I wasn't able to generate a 3D model for this request. Please try again or rephrase your prompt."; diff --git a/src/components/chat/ChatSession.tsx b/src/components/chat/ChatSession.tsx index 681f24c7..bda57b52 100644 --- a/src/components/chat/ChatSession.tsx +++ b/src/components/chat/ChatSession.tsx @@ -27,7 +27,13 @@ import { lastAssistantMessageIsCompleteWithToolCalls, } from 'ai'; import Tree from '@shared/Tree'; -import { isParametricArtifact } from '@shared/parametricParts'; +import { shouldAutoContinueParametricBuild } from '@shared/parametricAgentLoop'; +import { + extractScadFromAssistantParts, + hasSuccessfulParametricBuild, + isParametricArtifact, + shouldReportMissingParametricBuild, +} from '@shared/parametricParts'; import type { Conversation, Message, @@ -78,47 +84,6 @@ interface ChatSessionProps { onLoadingChange?: (isLoading: boolean) => void; } -type ToolMessagePart = Extract< - AppUIMessage['parts'][number], - { state: string } ->; - -function isToolMessagePart( - part: AppUIMessage['parts'][number], -): part is ToolMessagePart { - return part.type.startsWith('tool-') && 'state' in part; -} - -function lastAssistantMessageIsCompleteWithParametricBuild({ - messages, -}: { - messages: AppUIMessage[]; -}) { - const message = messages[messages.length - 1]; - if (!message || message.role !== 'assistant') return false; - if (message.parts.some((part) => part.type === 'tool-answer_user')) { - return false; - } - - const lastStepStartIndex = message.parts.reduce( - (lastIndex, part, index) => - part.type === 'step-start' ? index : lastIndex, - -1, - ); - const toolParts = message.parts - .slice(lastStepStartIndex + 1) - .filter(isToolMessagePart); - - return ( - toolParts.some((part) => part.type === 'tool-build_parametric_model') && - !toolParts.some((part) => part.type === 'tool-answer_user') && - toolParts.every( - (part) => - part.state === 'output-available' || part.state === 'output-error', - ) - ); -} - function answerUserInput(input: unknown): { message: string } | null { if (!input || typeof input !== 'object' || Array.isArray(input)) return null; const message = (input as { message?: unknown }).message; @@ -283,6 +248,43 @@ export function ChatSession({ findAssistant(messagesRef.current); if (toolCall.toolName === 'answer_user') { + if ( + conversation.type === 'parametric' && + assistant && + !hasSuccessfulParametricBuild(assistant.parts) + ) { + const errorText = + 'You must call build_parametric_model with complete OpenSCAD code before answer_user.'; + const errorPart = { + type: 'tool-answer_user', + toolCallId: toolCall.toolCallId, + state: 'output-error', + input: toolCall.input, + errorText, + } as AppUIMessage['parts'][number]; + const nextParts = assistant.parts.map((existing) => + existing.type === 'tool-answer_user' && + existing.toolCallId === toolCall.toolCallId + ? errorPart + : existing, + ) as AppUIMessage['parts']; + try { + await onToolOutput(assistant.id, nextParts); + } catch (persistError) { + console.warn( + 'Failed to persist premature answer_user error to DB:', + persistError, + ); + } + chat.addToolOutput({ + state: 'output-error', + tool: 'answer_user', + toolCallId: toolCall.toolCallId, + errorText, + }); + return; + } + const output = answerUserInput(toolCall.input); if (!output) { const errorText = 'answer_user input was missing a message.'; @@ -568,7 +570,14 @@ export function ChatSession({ ); } }, - [conversation.id, onToolOutput, onViewArtifact, toast, user?.id], + [ + conversation.id, + conversation.type, + onToolOutput, + onViewArtifact, + toast, + user?.id, + ], ); // ─────────────────────────────────────────────────────────────────────── @@ -586,7 +595,7 @@ export function ChatSession({ sendAutomaticallyWhen: (ctx) => { if (persistFailedRef.current) return false; return conversation.type === 'parametric' - ? lastAssistantMessageIsCompleteWithParametricBuild(ctx) + ? shouldAutoContinueParametricBuild(ctx.messages) : lastAssistantMessageIsCompleteWithToolCalls(ctx); }, // Out-of-band conversation-level signals (title + suggestions) arrive @@ -641,6 +650,50 @@ export function ChatSession({ old ? { ...old, current_message_leaf_id: message.id } : old, ); } + + if ( + conversation.type === 'parametric' && + message?.role === 'assistant' && + shouldReportMissingParametricBuild(message.parts) + ) { + const recoveredCode = extractScadFromAssistantParts(message.parts); + if (recoveredCode) { + void (async () => { + try { + await previewScadColoredViaToolWorker(recoveredCode); + onViewArtifact( + { + title: 'Recovered model', + version: 'v1', + code: recoveredCode, + }, + message.id, + ); + toast({ + title: 'Model code recovered', + description: + 'OpenSCAD was found in the reply text and loaded into the preview.', + }); + } catch (error) { + console.warn('Recovered OpenSCAD failed to compile:', error); + toast({ + title: 'Found code but it did not compile', + description: + 'OpenSCAD was in the reply but could not be rendered. Try retrying the prompt.', + variant: 'destructive', + }); + } + })(); + } else { + toast({ + title: 'No model was generated', + description: + 'Adam replied without OpenSCAD code. Try again or retry with a different model.', + variant: 'destructive', + }); + } + } + queryClient.invalidateQueries({ queryKey: ['messages', conversation.id], }); diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx index d1a13867..0885943d 100644 --- a/src/components/chat/MessageBubble.tsx +++ b/src/components/chat/MessageBubble.tsx @@ -31,6 +31,7 @@ import type { TreeNode } from '@shared/Tree'; import { cleanAssistantText, isParametricArtifact, + shouldReportMissingParametricBuild, } from '@shared/parametricParts'; import { imageIdFromFilename } from '@shared/imageRefs'; import type React from 'react'; @@ -469,6 +470,11 @@ function AssistantBubble({ () => message.parts.some((part) => !!answerUserMessageText(part)?.trim()), [message.parts], ); + const missingBuild = + conversation.type === 'parametric' && + !isLoading && + isLastMessage && + shouldReportMissingParametricBuild(message.parts); const branchIndex = message.siblings.findIndex((b) => b.id === message.id); const leafNodes = useMemo( () => @@ -654,6 +660,27 @@ function AssistantBubble({ return null; })} + {missingBuild ? ( +
+

+ No 3D model was generated for this request. Retry this message or + switch models using the controls below. +

+ {onRetry && currentModel ? ( + + ) : null} +
+ ) : null} + {/* Suppress the rating/retry/copy/restore strip while the latest assistant message is still streaming — those controls don't make sense on a half-rendered response. Older messages keep diff --git a/src/server/aiChat.ts b/src/server/aiChat.ts index b580a254..2421cc50 100644 --- a/src/server/aiChat.ts +++ b/src/server/aiChat.ts @@ -2,7 +2,13 @@ import { createAnthropic } from '@ai-sdk/anthropic'; import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { chatTools, type AppUIMessage, type AppTools } from '@shared/chatAi'; -import { cleanAssistantText, getParametricText } from '@shared/parametricParts'; +import { + cleanAssistantText, + extractScadFromAssistantParts, + getParametricText, + hasParametricBuildAttempt, + MISSING_BUILD_NOTICE, +} from '@shared/parametricParts'; import { imageIdFromFilename, imageStoragePath } from '@shared/imageRefs'; import { normalizeConversationSuggestions } from '@shared/suggestions'; import type { Conversation, Message, MeshFileType, Model } from '@shared/types'; @@ -89,6 +95,8 @@ const USD_PER_BILLING_TOKEN = 0.01; const PARAMETRIC_AGENT_PROMPT = `You are Adam, an agentic AI CAD editor that creates and modifies OpenSCAD models. The user can see a live preview of the model on the right while you work. +CRITICAL: For any CAD modeling request you MUST call build_parametric_model with complete, valid OpenSCAD code before ending the turn. Never finish a CAD request with only prose, partial notes, or markdown code fences — the preview only renders code delivered through build_parametric_model. + Use build_parametric_model whenever the user asks for a CAD model, an edit to a CAD model, or a fix for OpenSCAD code. The tool input is the model shown to the user, so do not paste OpenSCAD into normal reply text. Use answer_user for final user-facing text and for normal non-CAD replies. Never say you created, designed, generated, updated, or fixed a model unless you used build_parametric_model in that turn. @@ -557,11 +565,39 @@ function finalizeStreamingParts( function dropTextFromParametricBuildMessage( parts: AppUIMessage['parts'], +): AppUIMessage['parts']; +function dropTextFromParametricBuildMessage( + parts: AppUIMessage['parts'], + options: { leafRole: 'user' | 'assistant' }, +): AppUIMessage['parts']; +function dropTextFromParametricBuildMessage( + parts: AppUIMessage['parts'], + options?: { leafRole: 'user' | 'assistant' }, ): AppUIMessage['parts'] { const hasBuild = parts.some( (part) => part.type === 'tool-build_parametric_model', ); - if (!hasBuild) return parts; + if (!hasBuild) { + if ( + options?.leafRole === 'user' && + !hasParametricBuildAttempt(parts) && + !extractScadFromAssistantParts(parts) + ) { + const hasNotice = parts.some( + (part) => + part.type === 'text' && + 'text' in part && + part.text.includes(MISSING_BUILD_NOTICE), + ); + if (!hasNotice) { + return [ + ...parts, + { type: 'text', text: MISSING_BUILD_NOTICE, state: 'done' }, + ] as AppUIMessage['parts']; + } + } + return parts; + } return parts.filter((part) => part.type !== 'text') as AppUIMessage['parts']; } @@ -1181,15 +1217,15 @@ export async function handleAiChatRequest(req: Request) { thinking: thinkingEnabled, }; + const isFreshParametricTurn = + conversation.type === 'parametric' && leafRole === 'user'; // Parametric step 0 normally pins `build_parametric_model` via a forced // tool_choice. Models that reject forced tool use (Claude 5 — Fable/Mythos) // fall back to auto tool choice, where the model *might* answer with text // instead of building. Track that fallback so we can detect — and log — a // turn that finished without ever calling the build tool. const usingAutoToolChoiceFallback = - conversation.type === 'parametric' && - leafRole === 'user' && - !supportsForcedToolChoice(actualModelId); + isFreshParametricTurn && !supportsForcedToolChoice(actualModelId); const result = streamText({ model: chatLanguageModel, @@ -1207,16 +1243,22 @@ export async function handleAiChatRequest(req: Request) { // that accept a forced tool_choice get it pinned; models that reject // forced tool use (Claude 5 — Fable/Mythos) fall back to auto and rely // on the system prompt to call build_parametric_model. + // Claude 5 (Fable/Mythos) rejects forced tool_choice. The AI SDK only + // maps auto | required | tool | none to Anthropic — there is no `any` + // type, and both `required` and `{ type: 'any' }` have caused API + // errors on Claude 5. Restrict activeTools to the build tool and steer + // via PARAMETRIC_AGENT_PROMPT; client-side guards handle missing builds. + if (supportsForcedToolChoice(actualModelId)) { + return { + activeTools: ['build_parametric_model' as never], + toolChoice: { + type: 'tool' as const, + toolName: 'build_parametric_model' as never, + }, + }; + } return { activeTools: ['build_parametric_model' as never], - ...(supportsForcedToolChoice(actualModelId) - ? { - toolChoice: { - type: 'tool' as const, - toolName: 'build_parametric_model' as never, - }, - } - : {}), }; } return {}; @@ -1257,12 +1299,10 @@ export async function handleAiChatRequest(req: Request) { }, }); }, - // Observability for the auto-tool-choice fallback (Claude 5 / Fable / - // Mythos): without a forced tool_choice the model can finish a parametric - // turn as plain text, leaving the user with no built model and no error. - // Surface that degraded outcome so it's measurable instead of silent. + // Without a build tool call the user sees an empty preview. Log every + // fresh parametric turn that finishes this way so it's measurable. onFinish: ({ steps }) => { - if (!usingAutoToolChoiceFallback) return; + if (!isFreshParametricTurn) return; const calledBuildTool = steps.some((step) => step.toolCalls?.some( (call) => call.toolName === 'build_parametric_model', @@ -1271,7 +1311,9 @@ export async function handleAiChatRequest(req: Request) { if (!calledBuildTool) { logError( new Error( - 'Parametric turn finished without calling build_parametric_model under auto tool-choice fallback', + usingAutoToolChoiceFallback + ? 'Parametric turn finished without calling build_parametric_model under auto tool-choice fallback' + : 'Parametric turn finished without calling build_parametric_model', ), { functionName: 'ai-chat', @@ -1280,7 +1322,9 @@ export async function handleAiChatRequest(req: Request) { conversationId: logContext.conversationId, additionalContext: { ...logContext, - operation: 'forced_tool_choice_fallback', + operation: usingAutoToolChoiceFallback + ? 'forced_tool_choice_fallback' + : 'missing_parametric_build', modelId: actualModelId, }, }, @@ -1370,6 +1414,7 @@ export async function handleAiChatRequest(req: Request) { conversation.type === 'parametric' ? dropTextFromParametricBuildMessage( finalizeStreamingParts(responseMessage.parts), + { leafRole }, ) : finalizeStreamingParts(responseMessage.parts); diff --git a/src/views/EditorView.tsx b/src/views/EditorView.tsx index bed7cd7d..414570ef 100644 --- a/src/views/EditorView.tsx +++ b/src/views/EditorView.tsx @@ -36,6 +36,7 @@ import type { AppUIMessage } from '@shared/chatAi'; import { isParametricArtifact, replaceBuildParametricModelOutput, + shouldReportMissingParametricBuild, } from '@shared/parametricParts'; import Tree from '@shared/Tree'; import type { @@ -603,6 +604,19 @@ function ConversationEditor() { const hasArtifact = activePreview?.type === 'artifact' && parameters.length > 0; + const lastAssistantMessage = useMemo( + () => + [...initialBranch] + .reverse() + .find((message) => message.role === 'assistant'), + [initialBranch], + ); + const lastTurnMissingBuild = + conversation.type === 'parametric' && + !isChatStreaming && + !activePreview && + !!lastAssistantMessage && + shouldReportMissingParametricBuild(lastAssistantMessage.parts); // `useCachedAiChat` captures `initialBranch` once at Chat construction; // if the messages query hasn't completed its first fetch yet the @@ -734,8 +748,10 @@ function ConversationEditor() { ) : activePreview?.type === 'mesh' ? ( ) : ( -
- Send a message to start creating +
+ {lastTurnMissingBuild + ? 'No model was generated. Retry in chat or try a different model.' + : 'Send a message to start creating'}
)}
@@ -756,8 +772,10 @@ function ConversationEditor() { ) : activePreview?.type === 'mesh' ? ( ) : ( -
- Send a message to start creating +
+ {lastTurnMissingBuild + ? 'No model was generated. Retry in chat or try a different model.' + : 'Send a message to start creating'}
)}