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} +