Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions shared/parametricAgentLoop.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
67 changes: 67 additions & 0 deletions shared/parametricAgentLoop.ts
Original file line number Diff line number Diff line change
@@ -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;
}
146 changes: 145 additions & 1 deletion shared/parametricParts.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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/);
});
});
Loading