feat(whiteboard): support rotation and custom SVG paths (#1965)

Native wb_draw_shape now takes an SVG path with a viewBox, plus rotation, transparent fill and optional stroke, with strict geometry validation. Removes the rectangle/circle/triangle presets and the shape field from the Native tool; stored shapes and Legacy preset playback stay compatible. Prompt guidance states coordinate, scaling, rotation and arc principles with one-line standard parts. Adds a whiteboard-shape evaluation harness.

Closes #1853
This commit is contained in:
杨慎 authored and GitHub committed 2026-10-23 18:54:12 +08:00
1 parent ee300e00c7
commit 5cbfd0dc58
18 files changed
+1919 -59

No files matched your search

+1
View File
@@ -76,6 +76,7 @@ next-env.d.ts
# docs
/docs
# Eval results
eval/whiteboard-shapes/results/
eval/whiteboard-layout/results/
eval/outline-language/results/
eval/orchestration/results/
+142
View File
@@ -0,0 +1,142 @@
# Native whiteboard shape tools (#1853)
`wb_draw_shape` uses one SVG path interface for every shape. There is no
`shape` discriminator or rectangle/circle/triangle preset mode. It accepts
optional `rotate` (finite degrees,
clockwise around the element center, default `0`), `fillColor` (default
`#5b9bd5`, including `transparent`), and
`stroke: { color, width, style? }` (positive width, style defaults to `solid`,
also `dashed`/`dotted`). Omitted stroke preserves the existing no-outline behavior.
Every call requires both a nonblank SVG `path` **d attribute** and positive
`viewBox: [width, height]`. It never accepts a complete SVG document. Position
`x`/`y` is the upper-left corner of the **unrotated** box; element dimensions
scale viewBox coordinates independently. Use a matching aspect ratio to
preserve mathematical lengths. Plan a rotated shape's center first, then set
`x = centerX - width / 2`, `y = centerY - height / 2`. Arc radii are expressed
in viewBox units and scale with the element dimensions. Close filled shapes with `Z`; for open curves,
request transparent fill and an explicit stroke.
Path coordinates are local: `(0,0)` is the unrotated element's upper-left.
Do not put absolute whiteboard coordinates into the path or center it around
zero without offsetting it into the viewBox.
| Shape | Path | viewBox |
| --- | --- | --- |
| Rectangle | `M0 0 H1 V1 H0 Z` | `[1,1]` |
| Isosceles triangle | `M1 0 L2 1 L0 1 Z` | `[2,1]` |
| Circle / ellipse | `M1 0 A1 1 0 1 1 1 2 A1 1 0 1 1 1 0 Z` | `[2,2]` |
| Clockwise upper-right quarter circle | `M0 0 A1 1 0 0 1 1 1` | `[1,1]` |
For a circle, use equal element width/height; unequal dimensions give an
ellipse. The quarter-circle example runs from the top-left to the bottom-right
of its box. With equal width/height, its radius equals the element width,
not half the width. Use transparent fill and an explicit stroke for this arc.
For a 3–4–5 triangle, use `path: "M0 0 L4 0 L0 3 Z"`, `viewBox: [4, 3]`,
and `width:height = 4:3` to preserve its proportions.
For a curve, use `path: "M0 1 C1 0 2 0 3 1"`, `viewBox: [3, 1]`,
`fillColor: "transparent"`, `stroke: { color: "#333333", width: 2 }`.
In **every actual call**, copy `nextMutation.expectedLastSeq` exactly from the
latest `wb_read`. Read again between successful mutations. These examples
omit the sequence deliberately; `null` is valid only when the read returns null.
## Validation and persistence
Both preparation and direct execution validate before appending or creating a
session. `@openmaic/geometry` supplies the shared grammar, numeric, scaled-bounds,
and resource checks from #1851. Input limits are 100,000 path code units,
10,000 commands, and coordinate/dimension magnitudes up to 1,000,000.
Malformed or excessive input returns an error the model can correct; direct
execution returns `INVALID_WHITEBOARD_SHAPE` with an error field/code and
character position where available. There is no rectangle fallback.
The tool constructs existing `PPTShapeElement` fields (`path`, `viewBox`,
`rotate`, `fill`, `outline`) and appends the existing `element_added` event.
Operation/element identity remains host-owned, with unchanged CAS, sequence,
exact-replay and projection behavior. No DSL or runtime payload version change
is needed. Unit tests reopen BrowserRuntimeStore, replay after a later write,
retain old preset records, and verify rejected input leaves no sessions or
projection events. Historical reads retain the renderer's separate, more
permissive render budget; new producer limits do not invalidate old records.
Current whiteboard playback re-exports the SDK's `BaseShapeElement`. Tests
compare both rendering entries for path, scale, angle, transparent fill and
stroke. Existing worker tests bound malformed historical rendering.
## Legacy and cross-version compatibility
| Entry | Behavior |
| --- | --- |
| Native tool | Requires path/viewBox and persists full shape elements. Rejects the removed `shape` discriminator, Legacy `elementId`, or missing `expectedLastSeq`. |
| Legacy child action tool (`classroom-actions.ts`) | Preset-only schema with `additionalProperties: false`; execution also explicitly rejects Native fields before emitting an action. |
| Legacy JSON action parser (`call-agent.ts`) | Rejects custom mode, rotation, path/viewBox, stroke and Native sequence fields, returning an action warning. |
| ActionEngine (including persisted playback/eval actions) | Rejects unsupported shape extensions and unknown shape names **before opening/creating a board**. Keeps supported preset playback unchanged. |
| SDK/current whiteboard rendering | Reads existing shape fields, so no new storage schema is required. |
Do not convert Native calls into Legacy JSON actions. Native mutations emit
projection hints, never draw actions that an older ActionEngine might
misinterpret. Historical elements already store path/viewBox/rotation, so they
remain readable without migration. New Native calls must omit `shape` and
supply path/viewBox; older Native preset/custom arguments fail explicitly.
Older Native strict schemas reject the new path-only arguments.
Unpatched historical Legacy executors can discard rotation or turn custom
shapes into rectangles; they cannot safely execute this new interface. Use the
Native tool/projection path, or upgrade the executor to get the explicit
rejections above. This change does not claim that old binaries negotiate new
capabilities automatically.
## Live evaluation
Run with configured, authorized model IDs and credentials:
```sh
node --env-file=.env.local --conditions=import --import tsx \
eval/whiteboard-shapes/runner.ts \
--models openai:gpt-5.6-sol,openai:gpt-5.6-luna
```
The runner uses the production Native prompt, Pi agent and tool executors with
an isolated BrowserRuntimeStore. Browser visibility is settled in-process;
this is a tool/geometry eval, not an HTTP-route or full classroom UI test.
The cases cover a 3–4–5 triangle and hypotenuse square, a pentagon, an open
stroked curve, ordinary shapes drawn through paths, and error recovery. Recovery injects
`M0 0 Z1 1` into the first otherwise valid custom call; the model receives the
real error and must retry. This is an intentional fault, not a generation error.
The additional `composition` case requests a 5–12–13 triangle and outward squares
on all three sides. Its task gives only the triangle vertices, not square
coordinates, paths, rotation angles or a construction recipe. Four fixed variants
change scale, position and orientation; repetitions cycle through them. Scoring
checks all four closed contours after viewBox scaling and rotation, with a 1 px
vertex tolerance and correct edge order, exterior placement and canvas bounds.
Separate elements and compound paths are both accepted. Missing, duplicate, extra,
invisible or malformed contours fail. Hand-computed fixtures cover known correct
and incorrect compositions independently of the reference-square generator.
Filled scenarios require a valid static color with nonzero alpha and element
opacity; SVG `none` and zero-alpha colors do not count as visible fills. The curve
case checks one open cubic contour, its unrotated box and rotation, and a visible
dark 2px stroke, rather than treating the presence of `C` in a path as sufficient.
For a small composition batch:
```sh
node --env-file=.env.local --conditions=import --import tsx \
eval/whiteboard-shapes/runner.ts --models deepseek:deepseek-flash \
--cases composition --repetitions 12 --output /tmp/whiteboard-composition
```
The runner uses production `runNativeChild`, with thinking enabled, a 4,096 output
token limit, 5 provider transports and a 60-second timeout by default. Options
`--thinking enabled|disabled`, `--max-provider-transports N` and `--timeout-ms N`
make the budget explicit. A diagnostic run with `--max-provider-transports 12`
can separate geometry failures from insufficient requests for several sequential
read/mutation pairs; it is not a production-budget completion score. No production
runtime limits are changed. `--cases all` runs every case (the default); comma-separated
case IDs and `--repetitions N` select a subset and repeat it.
Results include the exact system/task prompts, Native stop reason, geometry
assessment, persisted shapes and tool traces. A run passes only if the Native child
completes and the persisted geometry is correct. Reports go to `results/report.json`
and one JSON file per trial by default, all ignored by Git.
Publish observed results, logs and screenshots in PR comments; do not commit
run artifacts.
@@ -0,0 +1,33 @@
import { expectedComposition, type TriangleVertices } from './geometry-checks';
/** Four fixed placement/scale variants, generated before any model trials. */
export function compositionScenario(repetition: number) {
const variant = repetition % 4;
const scale = [14, 16, 12, 15][variant];
const angle = ([0, 23, -17, 90][variant] * Math.PI) / 180;
const transform = ([x, y]: number[]): [number, number] => [
Math.round((x * Math.cos(angle) - y * Math.sin(angle)) * 100) / 100,
Math.round((x * Math.sin(angle) + y * Math.cos(angle)) * 100) / 100,
];
const rotated: TriangleVertices = [
transform([0, 0]),
transform([12 * scale, 0]),
transform([0, 5 * scale]),
];
// Center the entire target figure on the canvas, including the outward squares.
const points = expectedComposition(rotated).flat();
const centerX = (Math.min(...points.map((p) => p[0])) + Math.max(...points.map((p) => p[0]))) / 2;
const centerY = (Math.min(...points.map((p) => p[1])) + Math.max(...points.map((p) => p[1]))) / 2;
const vertices = rotated.map(([x, y]) => [
Math.round((x + 500 - centerX) * 100) / 100,
Math.round((y + 281.25 - centerY) * 100) / 100,
]) as TriangleVertices;
return {
variant,
vertices,
request:
`请在白板上实际绘制一幅组合图:一个边长比为5–12–13的直角三角形ABC,` +
`顶点A=(${vertices[0]}), B=(${vertices[1]}), C=(${vertices[2]}),` +
'并在三条边上分别向三角形外侧作一个正方形。三角形和三个正方形都要有填充,完整放在画布内,不加文字、标注或其他图形。',
};
}
+303
View File
@@ -0,0 +1,303 @@
import type { PPTShapeElement } from '@openmaic/dsl';
import { validateSvgPath } from '@openmaic/geometry';
import { SVGPathData } from 'svg-pathdata';
import tinycolor from 'tinycolor2';
const EPSILON = 1e-6;
const distance = (a: number[], b: number[]) => Math.hypot(a[0] - b[0], a[1] - b[1]);
const cross = (a: number[], b: number[], c: number[]) =>
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
export function hasVisibleFill(s: PPTShapeElement): boolean {
const color = tinycolor(s.fill);
return color.isValid() && color.getAlpha() > 0 && (s.opacity ?? 1) > 0;
}
export function isCurveScenarioShape(s: PPTShapeElement): boolean {
const fill = tinycolor(s.fill);
const stroke = tinycolor(s.outline?.color ?? '');
const unfilled =
s.fill.trim().toLowerCase() === 'none' || (fill.isValid() && fill.getAlpha() === 0);
const angleDifference = Math.abs((s.rotate - 12.5) % 360);
if (
!unfilled ||
!stroke.isValid() ||
stroke.getAlpha() <= 0 ||
!stroke.isDark() ||
s.outline?.width !== 2 ||
!((s.opacity ?? 1) > 0) ||
![s.left - 100, s.top - 300, s.width - 240, s.height - 80].every(
(difference) => Math.abs(difference) < EPSILON,
) ||
!(angleDifference < EPSILON || 360 - angleDifference < EPSILON) ||
!s.viewBox.every((v) => Number.isFinite(v) && v > 0)
)
return false;
const parsed = validateSvgPath(s.path);
if (!parsed.ok || !parsed.bounds) return false;
const commands = new SVGPathData(parsed.commands).toAbs().normalizeHVZ().normalizeST().commands;
const first = commands[0];
const last = commands[commands.length - 1];
if (
first?.type !== SVGPathData.MOVE_TO ||
last?.type !== SVGPathData.CURVE_TO ||
!commands.slice(1).every((c) => c.type === SVGPathData.CURVE_TO) ||
distance([first.x, first.y], [last.x, last.y]) < EPSILON
)
return false;
const sx = s.width / s.viewBox[0];
const sy = s.height / s.viewBox[1];
return (
parsed.bounds.minX * sx >= -1 &&
parsed.bounds.minY * sy >= -1 &&
parsed.bounds.maxX * sx <= s.width + 1 &&
parsed.bounds.maxY * sy <= s.height + 1
);
}
function polygonContours(s: PPTShapeElement): number[][][] {
const result = validateSvgPath(s.path);
if (!result.ok) return [];
const commands = new SVGPathData(result.commands).toAbs().normalizeHVZ().commands;
if (
commands.some(
(c) =>
c.type !== SVGPathData.MOVE_TO &&
c.type !== SVGPathData.LINE_TO &&
c.type !== SVGPathData.CLOSE_PATH,
)
)
return [];
const angle = (s.rotate * Math.PI) / 180;
const cx = s.left + s.width / 2,
cy = s.top + s.height / 2;
const contours: number[][][] = [];
let points: number[][] = [];
for (const c of commands) {
if (c.type === SVGPathData.MOVE_TO && points.length) {
const vertices = closedPolygonVertices(points);
if (!vertices.length) return [];
contours.push(vertices);
points = [];
}
if ('x' in c && 'y' in c) {
const x = (c.x * s.width) / s.viewBox[0] - s.width / 2;
const y = (c.y * s.height) / s.viewBox[1] - s.height / 2;
points.push([
cx + x * Math.cos(angle) - y * Math.sin(angle),
cy + x * Math.sin(angle) + y * Math.cos(angle),
]);
} else if (c.type === SVGPathData.CLOSE_PATH && points.length) {
points.push(points[0]);
}
}
const vertices = closedPolygonVertices(points);
if (!vertices.length) return [];
contours.push(vertices);
return contours;
}
function closedPolygonVertices(points: number[][]): number[][] {
const vertices = points.filter((p, i) => i === 0 || distance(p, points[i - 1]) > EPSILON);
if (vertices.length < 4 || vertices.some((p) => p.some((v) => !Number.isFinite(v)))) return [];
const closed = distance(vertices[0], vertices[vertices.length - 1]) < EPSILON;
if (!closed) return [];
vertices.pop();
// Collinear intermediate points do not add polygon corners. Keep reversals:
// they must not hide a backtracking edge from the checks below.
return vertices.filter((p, i) => {
const before = vertices[(i + vertices.length - 1) % vertices.length];
const after = vertices[(i + 1) % vertices.length];
const dot = (p[0] - before[0]) * (after[0] - p[0]) + (p[1] - before[1]) * (after[1] - p[1]);
return dot <= 0 || Math.abs(cross(before, p, after)) > EPSILON;
});
}
function polygonVertices(s: PPTShapeElement): number[][] {
const contours = polygonContours(s);
return contours.length === 1 ? contours[0] : [];
}
function matchesVertices(actual: number[][], expected: number[][]): boolean {
if (actual.length !== expected.length) return false;
const indices = actual.map((p) => expected.findIndex((e) => distance(p, e) < 1));
if (indices.includes(-1) || new Set(indices).size !== expected.length) return false;
const step = (indices[1] - indices[0] + indices.length) % indices.length;
return (
(step === 1 || step === indices.length - 1) &&
indices.every(
(n, i) => (indices[(i + 1) % indices.length] - n + indices.length) % indices.length === step,
)
);
}
function matchesPolygon(s: PPTShapeElement, expected: number[][]): boolean {
return matchesVertices(polygonVertices(s), expected);
}
export type TriangleVertices = [[number, number], [number, number], [number, number]];
/** Independent expected vertices; never include these square coordinates in the model prompt. */
export function expectedComposition(vertices: TriangleVertices): number[][][] {
return [
vertices,
...vertices.map((a, i) => {
const b = vertices[(i + 1) % 3];
const opposite = vertices[(i + 2) % 3];
const sign = cross(a, b, opposite) > 0 ? 1 : -1;
const nx = sign * (b[1] - a[1]);
const ny = sign * (a[0] - b[0]);
return [a, b, [b[0] + nx, b[1] + ny], [a[0] + nx, a[1] + ny]];
}),
];
}
export function assessComposition(shapes: PPTShapeElement[], vertices: TriangleVertices) {
const expected = expectedComposition(vertices);
const elementContours = shapes.map(polygonContours);
const contours = elementContours.flat();
const matches = contours.map((p) => expected.findIndex((e) => matchesVertices(p, e)));
const withinCanvas = contours.every((p) =>
p.every(([x, y]) => x >= -1 && y >= -1 && x <= 1001 && y <= 563.5),
);
const validElements =
shapes.length > 0 && shapes.every((s, i) => hasVisibleFill(s) && elementContours[i].length > 0);
const matchedContours = [...new Set(matches.filter((i) => i >= 0))];
return {
passed: validElements && withinCanvas && contours.length === 4 && matchedContours.length === 4,
elementCount: shapes.length,
contourCount: contours.length,
matchedContours,
unmatchedContours: matches.filter((i) => i < 0).length,
validElements,
withinCanvas,
};
}
export function isRightTriangle(s: PPTShapeElement): boolean {
return matchesPolygon(s, [
[100, 100],
[340, 100],
[100, 280],
]);
}
export function squareOnHypotenuse(s: PPTShapeElement): boolean {
return matchesPolygon(s, [
[340, 100],
[100, 280],
[280, 520],
[520, 340],
]);
}
export function isPentagon(s: PPTShapeElement): boolean {
const vertices = polygonVertices(s);
if (vertices.length !== 5) return false;
for (let i = 0; i < vertices.length; i++) {
const a = vertices[i],
b = vertices[(i + 1) % vertices.length];
if (Math.abs(cross(a, b, vertices[(i + 2) % vertices.length])) < EPSILON) return false;
for (let j = i + 1; j < vertices.length; j++) {
if (j === i + 1 || (i === 0 && j === vertices.length - 1)) continue;
const c = vertices[j],
d = vertices[(j + 1) % vertices.length];
const side = (n: number) => (n > EPSILON ? 1 : n < -EPSILON ? -1 : 0);
const overlaps =
Math.max(Math.min(a[0], b[0]), Math.min(c[0], d[0])) <=
Math.min(Math.max(a[0], b[0]), Math.max(c[0], d[0])) + EPSILON &&
Math.max(Math.min(a[1], b[1]), Math.min(c[1], d[1])) <=
Math.min(Math.max(a[1], b[1]), Math.max(c[1], d[1])) + EPSILON;
if (
overlaps &&
side(cross(a, b, c)) * side(cross(a, b, d)) <= 0 &&
side(cross(c, d, a)) * side(cross(c, d, b)) <= 0
)
return false;
}
}
return true;
}
export function isCircle(s: PPTShapeElement, cx: number, cy: number, radius: number): boolean {
const result = validateSvgPath(s.path);
if (!result.ok) return false;
try {
const commands = new SVGPathData(result.commands)
.toAbs()
.normalizeHVZ()
.normalizeST()
.qtToC()
.aToC()
.scale(s.width / s.viewBox[0], s.height / s.viewBox[1])
.translate(s.left, s.top)
.rotate((s.rotate * Math.PI) / 180, s.left + s.width / 2, s.top + s.height / 2).commands;
const points: number[][] = [];
let x = 0,
y = 0,
moves = 0;
for (const c of commands) {
if (c.type === SVGPathData.MOVE_TO) {
points.push([c.x, c.y]);
moves++;
} else if (c.type === SVGPathData.LINE_TO || c.type === SVGPathData.CURVE_TO) {
for (let i = 1; i <= 32; i++) {
const t = i / 32,
u = 1 - t;
points.push(
c.type === SVGPathData.LINE_TO
? [x + t * (c.x - x), y + t * (c.y - y)]
: [
u ** 3 * x + 3 * u ** 2 * t * c.x1 + 3 * u * t ** 2 * c.x2 + t ** 3 * c.x,
u ** 3 * y + 3 * u ** 2 * t * c.y1 + 3 * u * t ** 2 * c.y2 + t ** 3 * c.y,
],
);
}
} else return false;
x = c.x;
y = c.y;
}
if (moves !== 1 || points.length < 33) return false;
const first = points[0],
last = points[points.length - 1];
if (Math.hypot(first[0] - last[0], first[1] - last[1]) > 1) return false;
const area =
Math.abs(
points.reduce((sum, p, i) => {
const next = points[(i + 1) % points.length];
return sum + p[0] * next[1] - next[0] * p[1];
}, 0),
) / 2;
return (
Math.abs(area / (Math.PI * radius ** 2) - 1) < 0.02 &&
points.every(([px, py]) => Math.abs(Math.hypot(px - cx, py - cy) - radius) < 1)
);
} catch {
return false;
}
}
export function areBasicShapes(shapes: PPTShapeElement[]): boolean {
if (shapes.length !== 3 || shapes.some((s) => s.rotate !== 0 || !hasVisibleFill(s))) return false;
return [80, 340, 600].every((x, i) => {
const s = shapes.find(
(v) => v.left === x && v.top === 100 && v.width === 120 && v.height === 120,
);
if (!s) return false;
if (i === 1) return isCircle(s, x + 60, 160, 60);
return matchesPolygon(
s,
i === 0
? [
[x, 100],
[x + 120, 100],
[x + 120, 220],
[x, 220],
]
: [
[x + 60, 100],
[x + 120, 220],
[x, 220],
],
);
});
}
+299
View File
@@ -0,0 +1,299 @@
/** Live Native shape-tool eval; see README.md for configuration and limits. */
import { mkdirSync, writeFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb';
import type { PPTShapeElement } from '@openmaic/dsl';
import { BrowserRuntimeStore } from '@openmaic/storage';
import { createCallLlmStreamFn } from '@/lib/agent/runtime/stream-fn';
import { runNativeChild } from '@/lib/agent/runtime/run-native-child';
import { buildNativeChildPrompt, buildNativeChildTurnPrompt } from '@/lib/chat/pi/prompts';
import { buildNativeWhiteboardTools } from '@/lib/chat/pi/tools/native-whiteboard';
import { settleWhiteboardVisibility } from '@/lib/chat/pi/whiteboard-visibility';
import type { AgentConfig } from '@/lib/orchestration/registry/types';
import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators';
import type { StatelessChatRequest } from '@/lib/types/chat';
import { createWhiteboardRuntimeService } from '@/lib/whiteboard/runtime/store';
import { resolveModel } from '@/lib/server/resolve-model';
import {
areBasicShapes,
assessComposition,
hasVisibleFill,
isCurveScenarioShape,
isPentagon,
isRightTriangle,
squareOnHypotenuse,
type TriangleVertices,
} from './geometry-checks';
import { compositionScenario } from './composition-scenarios';
interface Scenario {
id: string;
request: string;
check: (shapes: PPTShapeElement[]) => boolean;
injectError?: boolean;
variant?: number;
vertices?: TriangleVertices;
}
interface RunOptions {
thinking: 'enabled' | 'disabled';
timeoutMs: number;
maxProviderTransports: number;
}
const teacher: AgentConfig = {
id: 'shape-eval',
name: 'Geometry teacher',
role: 'teacher',
persona: 'Draw mathematically accurate diagrams.',
avatar: '',
color: '#3366ff',
allowedActions: ['wb_draw_shape'],
priority: 10,
createdAt: new Date(0),
updatedAt: new Date(0),
isDefault: true,
};
const body: StatelessChatRequest = {
apiKey: '',
messages: [],
storeState: {
stage: null,
scenes: [],
currentSceneId: null,
mode: 'playback',
whiteboardOpen: false,
},
config: { agentIds: [teacher.id] },
};
const cases: Scenario[] = [
{
id: 'pythagoras',
request:
'Draw exactly two shapes: a filled 3–4–5 right triangle with vertices A=(100,100), B=(340,100), C=(100,280), then an outward square of side 300 on its hypotenuse BC. Use a custom triangle path and a rotated rectangle for the square. Keep both inside the board.',
check: (s: PPTShapeElement[]) =>
s.length === 2 &&
s.every(hasVisibleFill) &&
s.some((v) => isRightTriangle(v)) &&
s.some(squareOnHypotenuse),
},
{
id: 'polygon',
request:
'Draw one filled irregular pentagon using a custom SVG path with 5 vertices. Place it in x=100,y=100,width=240,height=180. No other elements.',
check: (s: PPTShapeElement[]) => s.length === 1 && hasVisibleFill(s[0]) && isPentagon(s[0]),
},
{
id: 'curve',
request:
'Draw one open cubic Bezier curve, transparent fill, dark 2px stroke, with an unrotated box at x=100,y=300,width=240,height=80 and rotation 12.5 degrees around its center. Keep the path within that unrotated box. No other elements.',
check: (s: PPTShapeElement[]) => s.length === 1 && isCurveScenarioShape(s[0]),
},
{
id: 'basic-paths',
request:
'Draw exactly three filled shapes in a row using SVG paths: rectangle, complete circle, and top-centered isosceles triangle. Use x=80,340,600; y=100; width=height=120. No rotation or extra elements.',
check: areBasicShapes,
},
{
id: 'correction',
request:
'Draw a single filled custom 3–4–5 triangle at x=100,y=100,width=240,height=180. If the drawing tool returns a path error, fix the input and retry.',
injectError: true,
check: (s: PPTShapeElement[]) =>
s.length === 1 && hasVisibleFill(s[0]) && isRightTriangle(s[0]),
},
];
async function runCase(modelString: string, scenario: Scenario, options: RunOptions) {
const started = Date.now();
const resolved = await resolveModel({ modelString });
const store = new BrowserRuntimeStore({
indexedDB: new IDBFactory(),
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
});
const service = createWhiteboardRuntimeService({
store,
resolveLearnerKey: () => 'eval',
withMaintenanceLock: (work) => work(),
});
const calls: Array<{ name: string; args: unknown; result?: unknown; error?: string }> = [];
let injected = false;
const tools = buildNativeWhiteboardTools({
agent: teacher,
messageId: `${modelString}:${scenario.id}`,
service,
stageId: 'shape-eval',
learnerKey: 'eval',
requestStartManualVisibilityRevision: 0,
send: async (event) => {
if (event.type === 'whiteboard' && event.data.kind === 'visibility_query')
settleWhiteboardVisibility({
queryId: event.data.queryId,
stageId: 'shape-eval',
learnerKey: 'eval',
visibility: 'open',
});
},
}).map((tool) => ({
...tool,
prepareArguments: (args: unknown) => {
try {
return tool.prepareArguments ? tool.prepareArguments(args) : args;
} catch (error) {
calls.push({
name: tool.name,
args,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
execute: async (id: string, args: unknown, signal?: AbortSignal) => {
const input =
scenario.injectError && !injected && tool.name === 'wb_draw_shape'
? ((injected = true), { ...(args as Record<string, unknown>), path: 'M0 0 Z1 1' })
: args;
const result = await tool.execute(id, input, signal);
calls.push({ name: tool.name, args: input, result });
return result;
},
}));
const systemPrompt = buildNativeChildPrompt(
body,
teacher,
[],
tools.map((t) => t.name),
);
const native = await runNativeChild({
systemPrompt,
prompt: buildNativeChildTurnPrompt(scenario.request, teacher.role),
streamFn: createCallLlmStreamFn({
languageModel: resolved.model,
source: 'pi-chat-native-child',
thinkingConfig: { mode: options.thinking, enabled: options.thinking === 'enabled' },
maxOutputTokens: 4096,
}),
tools,
allowedToolNames: new Set(tools.map((t) => t.name)),
timeoutMs: options.timeoutMs,
maxProviderTransports: options.maxProviderTransports,
});
const elements = (await service.read('shape-eval')).whiteboard?.elements ?? [];
const shapes = elements.filter((s): s is PPTShapeElement => s.type === 'shape');
const failures = calls.filter((c) => c.error || (c.result as { isError?: boolean })?.isError);
const geometryPassed = elements.length === shapes.length && scenario.check(shapes);
return {
model: modelString,
scenario: scenario.id,
variant: scenario.variant,
vertices: scenario.vertices,
request: scenario.request,
systemPrompt,
options,
elapsedMs: Date.now() - started,
passed: native.status === 'completed' && geometryPassed,
geometryPassed,
assessment: scenario.vertices ? assessComposition(shapes, scenario.vertices) : undefined,
native,
injected,
failedCalls: failures.length,
corrected: failures.length > 0 && geometryPassed,
calls,
shapes,
};
}
function positiveInteger(value: string, option: string): number {
const n = Number(value);
if (!Number.isSafeInteger(n) || n <= 0) throw new Error(`${option} must be a positive integer`);
return n;
}
async function main() {
const { values } = parseArgs({
options: {
models: { type: 'string' },
output: { type: 'string', default: 'eval/whiteboard-shapes/results' },
cases: { type: 'string', default: 'all' },
repetitions: { type: 'string', default: '1' },
thinking: { type: 'string', default: 'enabled' },
'timeout-ms': { type: 'string', default: '60000' },
'max-provider-transports': { type: 'string', default: '5' },
},
});
const models = (values.models || process.env.EVAL_SHAPE_MODELS || '').split(',').filter(Boolean);
if (!models.length)
throw new Error('Pass --models provider:model,provider:model or set EVAL_SHAPE_MODELS');
if (values.thinking !== 'enabled' && values.thinking !== 'disabled')
throw new Error('--thinking must be enabled or disabled');
const options: RunOptions = {
thinking: values.thinking,
timeoutMs: positiveInteger(values['timeout-ms']!, '--timeout-ms'),
maxProviderTransports: positiveInteger(
values['max-provider-transports']!,
'--max-provider-transports',
),
};
const repetitions = positiveInteger(values.repetitions!, '--repetitions');
const selected =
values.cases === 'all' ? [...cases.map((c) => c.id), 'composition'] : values.cases!.split(',');
if (selected.some((id) => id !== 'composition' && !cases.some((c) => c.id === id)))
throw new Error(`Unknown case in --cases ${values.cases}`);
Object.defineProperty(globalThis, 'IDBKeyRange', { value: IDBKeyRange, configurable: true });
const results = [];
mkdirSync(values.output!, { recursive: true });
for (const model of models)
for (let repetition = 0; repetition < repetitions; repetition++)
for (const id of selected) {
const composition = compositionScenario(repetition);
const scenario =
id === 'composition'
? {
id,
...composition,
check: (shapes: PPTShapeElement[]) =>
assessComposition(shapes, composition.vertices).passed,
}
: cases.find((c) => c.id === id)!;
const result = await runCase(model, scenario, options);
const trial = { ...result, repetition };
results.push(trial);
console.log(
JSON.stringify({
model,
scenario: scenario.id,
repetition,
variant: scenario.variant,
passed: result.passed,
geometryPassed: result.geometryPassed,
assessment: result.assessment,
status: result.native.status,
stopReason: result.native.stopReason,
providerTransports: result.native.providerTransportCount,
failedCalls: result.failedCalls,
corrected: result.corrected,
elapsedMs: result.elapsedMs,
}),
);
writeFileSync(
`${values.output}/${model.replace(/[^a-z0-9-]/gi, '_')}-${id}-${repetition}.json`,
JSON.stringify(trial, null, 2),
);
writeFileSync(
`${values.output}/report.json`,
JSON.stringify(
{ date: new Date().toISOString(), options, models, selected, repetitions, results },
null,
2,
),
);
if (result.native.status === 'failed' && result.calls.length === 0)
throw new Error(`Provider failed before any tool call: ${result.native.stopReason}`);
}
if (results.some((r) => !r.passed)) process.exitCode = 1;
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+7 -1
View File
@@ -56,6 +56,7 @@ import {
import katex from 'katex';
import { safeKatexOptions } from '@openmaic/dsl';
import { createLogger } from '@/lib/logger';
import { legacyShapeCompatibilityError } from '@/lib/whiteboard/legacy-shape';
const log = createLogger('ActionEngine');
@@ -225,6 +226,11 @@ export class ActionEngine {
}
}
if (action.type === 'wb_draw_shape') {
const error = legacyShapeCompatibilityError(action);
if (error) throw new Error(error);
}
// Auto-open whiteboard if a draw/clear/delete action is attempted while it's closed
if (action.type.startsWith('wb_') && action.type !== 'wb_open' && action.type !== 'wb_close') {
await this.ensureWhiteboardOpen(options);
@@ -518,7 +524,7 @@ export class ActionEngine {
id: action.elementId || '',
type: 'shape',
viewBox: [1000, 1000] as [number, number],
path: SHAPE_PATHS[action.shape] ?? SHAPE_PATHS.rectangle,
path: SHAPE_PATHS[action.shape],
left: action.x,
top: action.y,
width: action.width,
+16
View File
@@ -258,6 +258,22 @@ export function buildNativeChildPrompt(
'# Available Native tools',
nativeToolInventory,
...nativeWhiteboardGuidance,
...(availableTools.includes('wb_draw_shape')
? [
'# Native whiteboard shapes',
'- Geometry: every shape requires SVG path d data and viewBox:[w,h] with positive dimensions, never complete SVG markup. Element width/height scale the viewBox independently per axis; keep the viewBox ratio equal to width:height when proportions matter.',
'- Path/viewBox coordinates are local: origin (0,0) is the unrotated element upper-left. Never put absolute whiteboard x/y into path data or center a path around zero without offsetting it into the viewBox.',
'- x/y is the upper-left BEFORE rotation; rotate is a finite angle in degrees around the box center, positive clockwise (default 0). Work out the intended center first, then x=centerX-width/2 and y=centerY-height/2. Keep the rotated shape inside the canvas too.',
'- Arc radii are in viewBox units and scale with element width/height; unequal axis scales turn circular arcs into elliptical arcs.',
'- Close filled shapes with Z. Open curves use fillColor:"transparent" and stroke:{color,width,style?}; style defaults to solid (also dashed/dotted).',
'- Examples below omit expectedLastSeq only for readability: in EVERY actual call copy it exactly from the latest wb_read; read again between successful mutations. Never copy a placeholder sequence.',
'- Right triangle: path:"M0 0 L4 0 L0 3 Z", viewBox:[4,3]; use width:height=4:3 to preserve its proportions.',
'- Open cubic curve: path:"M0 1 C1 0 2 0 3 1", viewBox:[3,1], fillColor:"transparent", stroke:{color:"#333333",width:2}.',
'- Complete circle/ellipse: path:"M1 0 A1 1 0 1 1 1 2 A1 1 0 1 1 1 0 Z", viewBox:[2,2]. Equal element width/height gives a circle; different dimensions give an ellipse.',
'- Clockwise upper-right quarter circle, top-left to bottom-right: path:"M0 0 A1 1 0 0 1 1 1", viewBox:[1,1]; for width=height its radius equals width.',
'- On an invalid shape error, correct the reported field/path position or reduce path complexity and retry. Do not claim success until the mutation succeeds. Native shapes persist as PPTShapeElement records, never Legacy JSON actions.',
]
: []),
'',
'# Length & Style (CRITICAL)',
buildLengthGuidelines(agent.role),
+1 -1
View File
@@ -37,7 +37,7 @@ export function getActionDescriptions(allowedActions: string[]): string {
wb_draw_text:
'Add text to the whiteboard. Use for writing steps or key points. Use wb_draw_latex for mathematical equations and scientific notation. Parameters: { content: string, x: number, y: number, width?: number, height?: number, fontSize?: number, color?: string, elementId?: string }',
wb_draw_shape:
'Add a shape to the whiteboard. Use for diagrams and visual explanations. Parameters: { shape: "rectangle"|"circle"|"triangle", x: number, y: number, width: number, height: number, fillColor?: string, elementId?: string }',
'Legacy preset-only shape drawing. Rotation, custom paths and strokes require the Native tool; these parameters are rejected here. Parameters: { shape: "rectangle"|"circle"|"triangle", x: number, y: number, width: number, height: number, fillColor?: string, elementId?: string }',
wb_draw_chart:
'Add a chart to the whiteboard. Use for data visualization (bar charts, line graphs, pie charts, etc.). Parameters: { chartType: "bar"|"column"|"line"|"pie"|"ring"|"area"|"radar"|"scatter", x: number, y: number, width: number, height: number, data: { labels: string[], legends: string[], series: number[][] }, themeColors?: string[], elementId?: string }',
wb_draw_latex:
+2 -4
View File
@@ -37,6 +37,7 @@ import {
} from './classroom-actions';
import { buildNativeSpotlightTool } from './native-spotlight';
import { buildNativeWhiteboardTools } from './native-whiteboard';
import { legacyShapeCompatibilityError } from '@/lib/whiteboard/legacy-shape';
import type { WhiteboardRuntimeService } from '@/lib/whiteboard/runtime/store';
import type { ElementReferenceEvidence } from '../element-reference';
@@ -76,7 +77,6 @@ type ActionWarning = {
message: string;
};
const SHAPE_TYPES = new Set(['rectangle', 'circle', 'triangle']);
const CHART_TYPES = new Set(['bar', 'column', 'line', 'pie', 'ring', 'area', 'radar', 'scatter']);
const LINE_STYLES = new Set(['solid', 'dashed']);
const CODE_EDIT_OPERATIONS = new Set([
@@ -294,9 +294,7 @@ function validateActionParams(action: ParsedAction): string | null {
if (action.actionName === 'wb_draw_shape') {
return (
(typeof params.shape === 'string' && SHAPE_TYPES.has(params.shape)
? null
: 'wb_draw_shape requires params.shape rectangle|circle|triangle') ??
legacyShapeCompatibilityError(params) ??
validateRequiredPosition(params, 'wb_draw_shape', ['x', 'y', 'width', 'height']) ??
optionalString(params, 'fillColor', 'wb_draw_shape') ??
validateOptionalElementId(params, 'wb_draw_shape')
+27 -10
View File
@@ -6,6 +6,7 @@ import { getEffectiveActions } from '@/lib/chat/pi/tool-schemas';
import type { WhiteboardActionRecord } from '@/lib/orchestration/types';
import type { StatelessChatRequest } from '@/lib/types/chat';
import type { SendEvent } from '../types';
import { legacyShapeCompatibilityError } from '@/lib/whiteboard/legacy-shape';
const SpotlightParams = Type.Object({
elementId: Type.String(),
@@ -44,15 +45,22 @@ const WbDrawTextParams = Type.Object({
});
type WbDrawTextParams = Static<typeof WbDrawTextParams>;
const WbDrawShapeParams = Type.Object({
shape: Type.Union([Type.Literal('rectangle'), Type.Literal('circle'), Type.Literal('triangle')]),
x: Type.Number(),
y: Type.Number(),
width: Type.Number(),
height: Type.Number(),
fillColor: Type.Optional(Type.String()),
elementId: Type.Optional(Type.String()),
});
const WbDrawShapeParams = Type.Object(
{
shape: Type.Union([
Type.Literal('rectangle'),
Type.Literal('circle'),
Type.Literal('triangle'),
]),
x: Type.Number(),
y: Type.Number(),
width: Type.Number(),
height: Type.Number(),
fillColor: Type.Optional(Type.String()),
elementId: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
type WbDrawShapeParams = Static<typeof WbDrawShapeParams>;
const WbDrawChartParams = Type.Object({
@@ -338,6 +346,15 @@ export function buildChildActionTools(opts: {
parameters,
executionMode: 'sequential',
execute: async (_toolCallId, params) => {
if (name === 'wb_draw_shape') {
const error = legacyShapeCompatibilityError(params);
if (error)
return {
content: [{ type: 'text', text: error }],
details: { skipped: true, reason: 'unsupported_legacy_shape' },
isError: true,
};
}
if (
name === 'spotlight' &&
opts.authorizedSpotlightElementIds &&
@@ -695,7 +712,7 @@ export function buildChildActionTools(opts: {
makeActionTool(
'wb_draw_shape',
'Draw whiteboard shape',
'Draw a rectangle, circle, or triangle on the whiteboard.',
'Legacy preset-only shape drawing. Rotation, custom paths and strokes require the Native wb_draw_shape tool; never send those fields here.',
WbDrawShapeParams,
),
);
+115 -33
View File
@@ -16,6 +16,7 @@ import { Value } from 'typebox/value';
import { createHash } from 'node:crypto';
import katex from 'katex';
import { safeKatexOptions } from '@openmaic/dsl';
import { INPUT_PATH_LIMITS, validateShapeGeometry } from '@openmaic/geometry';
import type { AgentConfig } from '@/lib/orchestration/registry/types';
import {
@@ -61,16 +62,46 @@ const NativeWhiteboardDrawTextParams = Type.Object(
const NativeWhiteboardDrawShapeParams = Type.Object(
{
expectedLastSeq: ExpectedLastSeq,
shape: Type.Union([
Type.Literal('rectangle'),
Type.Literal('circle'),
Type.Literal('triangle'),
]),
x: Type.Number(),
y: Type.Number(),
width: Type.Number({ exclusiveMinimum: 0 }),
height: Type.Number({ exclusiveMinimum: 0 }),
fillColor: Type.Optional(Type.String({ minLength: 1 })),
rotate: Type.Optional(
Type.Number({
description:
'Finite angle in degrees around the box center; defaults to 0. Positive is clockwise.',
}),
),
fillColor: Type.Optional(
Type.String({ minLength: 1, description: 'Use transparent for unfilled paths.' }),
),
stroke: Type.Optional(
Type.Object(
{
color: Type.String({ minLength: 1 }),
width: Type.Number({ exclusiveMinimum: 0, maximum: INPUT_PATH_LIMITS.maxCoordinate }),
style: Type.Optional(
Type.Union([Type.Literal('solid'), Type.Literal('dashed'), Type.Literal('dotted')]),
),
},
{ additionalProperties: false },
),
),
path: Type.String({
minLength: 1,
maxLength: INPUT_PATH_LIMITS.maxLength,
pattern: '\\S',
description: 'SVG path d data only, beginning with M or m; never a complete SVG document.',
}),
// Homogeneous items works with providers that reject the older tuple items array.
viewBox: Type.Unsafe<[number, number]>({
type: 'array',
items: Type.Number({ exclusiveMinimum: 0 }),
minItems: 2,
maxItems: 2,
description:
'Positive [width, height] in path coordinates. The element width/height scale these coordinates independently.',
}),
},
{ additionalProperties: false },
);
@@ -265,12 +296,15 @@ const DRAW_TEXT_KEYS = new Set([
]);
const DRAW_SHAPE_KEYS = new Set([
'expectedLastSeq',
'shape',
'x',
'y',
'width',
'height',
'fillColor',
'rotate',
'path',
'viewBox',
'stroke',
]);
const DRAW_CHART_KEYS = new Set([
'expectedLastSeq',
@@ -325,11 +359,52 @@ const EDIT_CODE_KEYS = new Set([
'content',
]);
const SHAPE_PATHS = {
rectangle: 'M 0 0 L 1000 0 L 1000 1000 L 0 1000 Z',
circle: 'M 500 0 A 500 500 0 1 1 499 0 Z',
triangle: 'M 500 0 L 1000 1000 L 0 1000 Z',
} as const;
class WhiteboardShapeInputError extends Error {
constructor(
readonly geometryError: { code: string; field: string; message: string; position?: number },
) {
super(`wb_draw_shape ${geometryError.field}: ${geometryError.message} (${geometryError.code})`);
}
}
function prepareShapeArguments(args: unknown): NativeWhiteboardDrawShapeParams {
let params: NativeWhiteboardDrawShapeParams;
try {
params = strictArguments<NativeWhiteboardDrawShapeParams>(
NativeWhiteboardDrawShapeParams,
args,
DRAW_SHAPE_KEYS,
);
} catch {
if (
typeof args === 'object' &&
args !== null &&
'path' in args &&
typeof args.path === 'string' &&
args.path.length > INPUT_PATH_LIMITS.maxLength
) {
throw new WhiteboardShapeInputError({
code: 'resource_limit',
field: 'path',
message: `Path exceeds ${INPUT_PATH_LIMITS.maxLength} code units; shorten the path.`,
});
}
throw new WhiteboardShapeInputError({
code: 'invalid_arguments',
field: 'params',
message:
'Native whiteboard arguments must match the strict schema. Every shape requires a non-empty SVG path and positive [width, height] viewBox; omit the removed shape field. Copy expectedLastSeq from wb_read; x/y/rotate must be finite, width/height positive, and stroke requires color and positive width. No extra fields are allowed.',
});
}
const geometry = validateShapeGeometry({
path: params.path,
viewBox: params.viewBox,
width: params.width,
height: params.height,
});
if (!geometry.ok) throw new WhiteboardShapeInputError(geometry.error);
return params;
}
const DEFAULT_CHART_THEME = ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'];
@@ -631,7 +706,12 @@ function elementMutationTool<TParams extends ElementMutationParams>(
let element: PPTElement;
try {
element = config.createElement(params, invocationDigest);
} catch {
} catch (error) {
if (error instanceof WhiteboardShapeInputError) {
return failedResult('INVALID_WHITEBOARD_SHAPE', error.message, {
error: error.geometryError,
});
}
return failedResult(
'WHITEBOARD_ELEMENT_BUILD_FAILED',
'Whiteboard element construction failed; no mutation was accepted.',
@@ -777,27 +857,29 @@ export function buildNativeWhiteboardTools(opts: NativeWhiteboardToolOptions): A
name: 'wb_draw_shape',
label: 'Draw whiteboard shape',
description:
'Append one rectangle, circle, or triangle to the authoritative learner whiteboard using nextMutation.expectedLastSeq from the latest wb_read result.',
'Append an SVG path to the authoritative learner whiteboard. Copy nextMutation.expectedLastSeq from wb_read. rotate is a finite angle in degrees about the box center (default 0); x/y locate the unrotated upper-left corner. Every shape requires path d data and positive viewBox [w,h]; width/height scale path coordinates. Default fill is #5b9bd5 with no stroke; use fillColor:"transparent" and stroke:{color,width,style?} for open curves. Close filled polygons with Z. Invalid paths return a field, code and optional character position; correct the input and retry.',
parameters: NativeWhiteboardDrawShapeParams,
prepare: (args) =>
strictArguments<NativeWhiteboardDrawShapeParams>(
NativeWhiteboardDrawShapeParams,
args,
DRAW_SHAPE_KEYS,
),
createElement: (params, invocationDigest): PPTShapeElement => ({
id: `native-wb-element:${invocationDigest}`,
type: 'shape',
viewBox: [1000, 1000],
path: SHAPE_PATHS[params.shape],
left: params.x,
top: params.y,
width: params.width,
height: params.height,
rotate: 0,
fill: params.fillColor ?? '#5b9bd5',
fixedRatio: false,
}),
prepare: prepareShapeArguments,
createElement: (args, invocationDigest): PPTShapeElement => {
// Also validate direct executor calls before append or session creation.
const params = prepareShapeArguments(args);
return {
id: `native-wb-element:${invocationDigest}`,
type: 'shape',
viewBox: params.viewBox,
path: params.path,
left: params.x,
top: params.y,
width: params.width,
height: params.height,
rotate: params.rotate ?? 0,
fill: params.fillColor ?? '#5b9bd5',
...(params.stroke
? { outline: { ...params.stroke, style: params.stroke.style ?? 'solid' } }
: {}),
fixedRatio: false,
};
},
}),
);
}
+12
View File
@@ -0,0 +1,12 @@
/** Legacy actions cannot carry Native shape extensions. Validate before any side effects. */
export function legacyShapeCompatibilityError(params: object): string | null {
const shape = 'shape' in params ? params.shape : undefined;
const extensions = ['rotate', 'path', 'viewBox', 'stroke', 'expectedLastSeq'];
if (shape === 'custom' || extensions.some((key) => key in params)) {
return 'Legacy wb_draw_shape does not support Native rotation/custom path/stroke parameters. Use the Native wb_draw_shape tool and RuntimeStore projection; do not send a Legacy action.';
}
if (shape !== 'rectangle' && shape !== 'circle' && shape !== 'triangle') {
return 'Legacy wb_draw_shape requires shape rectangle|circle|triangle; unknown shapes cannot fall back to rectangles.';
}
return null;
}
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import type { StageStore } from '@/lib/api/stage-api';
import type { Action } from '@/lib/types/action';
import { ActionEngine } from '@/lib/action/engine';
const mocks = vi.hoisted(() => ({ get: vi.fn(), addElement: vi.fn(), setWhiteboardOpen: vi.fn() }));
vi.mock('@/lib/api/stage-api', () => ({
createStageAPI: () => ({ whiteboard: { get: mocks.get, addElement: mocks.addElement } }),
}));
vi.mock('@/lib/store/canvas', () => ({
useCanvasStore: {
getState: () => ({ whiteboardOpen: false, setWhiteboardOpen: mocks.setWhiteboardOpen }),
},
}));
describe('Legacy ActionEngine shape compatibility', () => {
it.each([
{ shape: 'custom', path: 'M0 0 L4 0 L0 3 Z', viewBox: [4, 3] },
{ shape: 'rectangle', rotate: 30 },
{ shape: 'rectangle', stroke: { color: '#111', width: 2 } },
{ shape: 'unknown' },
{ shape: 'rectangle', expectedLastSeq: null },
])('rejects unsupported shape $shape before opening or creating a board', async (params) => {
const engine = new ActionEngine({} as StageStore);
// Deliberately cross the wire-version boundary, bypassing the persisted Action type.
const action = {
id: 'legacy',
type: 'wb_draw_shape',
x: 10,
y: 10,
width: 100,
height: 100,
...params,
} as unknown as Action;
await expect(engine.execute(action, { silent: true })).rejects.toThrow('Legacy wb_draw_shape');
expect(mocks.get).not.toHaveBeenCalled();
expect(mocks.addElement).not.toHaveBeenCalled();
expect(mocks.setWhiteboardOpen).not.toHaveBeenCalled();
engine.dispose();
});
});
@@ -0,0 +1,55 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { parseHTML } from 'linkedom';
import { describe, expect, it } from 'vitest';
import type { PPTShapeElement } from '@openmaic/dsl';
import { BaseShapeElement as WhiteboardShape } from '@/components/slide-renderer/components/element/ShapeElement/BaseShapeElement';
import { BaseShapeElement as SDKShape } from '@openmaic/renderer/elements';
const base: PPTShapeElement = {
id: 'custom',
type: 'shape',
left: 100,
top: 100,
width: 240,
height: 180,
rotate: -36.86989765,
path: 'M 0 0 L 4 0 L 0 3 Z',
viewBox: [4, 3],
fill: '#5b9bd5',
fixedRatio: false,
};
describe('current whiteboard and shared SDK shape rendering', () => {
it.each([
base,
{ ...base, path: 'M0 0 L4 0 L4 2 L2 3 L0 2 Z' },
{
...base,
path: 'M0 1 C1 0 2 0 3 1',
viewBox: [3, 1] as [number, number],
rotate: 12.5,
fill: 'transparent',
outline: { color: '#333333', width: 2, style: 'dashed' as const },
},
])('renders path $path with identical scaling, rotation, fill and stroke', (element) => {
const current = renderToStaticMarkup(createElement(WhiteboardShape, { elementInfo: element }));
expect(current).toBe(renderToStaticMarkup(createElement(SDKShape, { elementInfo: element })));
const { document } = parseHTML(current);
expect(document.querySelector('.rotate-wrapper')?.getAttribute('style')).toContain(
`rotate(${element.rotate}deg)`,
);
const group = document.querySelector('svg g')!;
expect(group.getAttribute('transform')).toContain(
`scale(${element.width / element.viewBox[0]}, ${element.height / element.viewBox[1]})`,
);
const path = document.querySelector('svg path')!;
expect(path.getAttribute('d')).toBe(element.path);
expect(path.getAttribute('fill')).toBe(element.fill);
expect(path.getAttribute('stroke-width')).toBe(String(element.outline?.width ?? 0));
if (element.outline) {
expect(path.getAttribute('stroke')).toBe(element.outline.color);
expect(path.getAttribute('stroke-dasharray')).toBe('9 4');
}
expect(current).not.toContain('data-shape-geometry-error');
});
});
@@ -1697,6 +1697,72 @@ describe('Pi call_agent JSON action output', () => {
});
});
it('rejects Native shape extensions through both Legacy tool and JSON entry points', async () => {
const { buildChildActionTools } = await import('@/lib/chat/pi/tools/classroom-actions');
const events: StatelessEvent[] = [];
const send = async (event: StatelessEvent) => {
events.push(event);
};
const onActionDone = vi.fn();
const params = [
{ shape: 'rectangle', rotate: -30 },
{ shape: 'custom', path: 'M0 0 L4 0 L0 3 Z', viewBox: [4, 3] },
{ shape: 'circle', stroke: { color: '#111', width: 2 } },
{ shape: 'triangle', expectedLastSeq: null },
].map((extra) => ({ x: 10, y: 20, width: 100, height: 80, ...extra }));
const draw = buildChildActionTools({
body: makeBody(),
agent: slideTeacher,
messageId: 'legacy',
send,
onActionDone,
maxActionsPerAgent: 8,
enableWhiteboardTools: true,
}).find((t) => t.name === 'wb_draw_shape')!;
expect(draw.parameters).toMatchObject({ additionalProperties: false });
for (const input of params)
await expect(draw.execute('legacy-shape', input)).resolves.toMatchObject({
isError: true,
details: { reason: 'unsupported_legacy_shape' },
});
mockChildWithJsonOutput(
JSON.stringify(params.map((params) => ({ type: 'action', name: 'wb_draw_shape', params }))),
);
const { buildCallAgentTool } = await import('@/lib/chat/pi/tools/call-agent');
const tool = buildCallAgentTool({
body: makeBody(),
agentConfigs: [slideTeacher],
send,
languageModel: {} as never,
onAgentDone: vi.fn(),
onActionDone,
thinkingConfig: { mode: 'disabled', enabled: false },
abortSignal: new AbortController().signal,
maxAgentTurns: 6,
getAgentTurnCount: () => 0,
getAgentResponses: () => [],
getWhiteboardLedger: () => [],
maxActionsPerAgent: 8,
enableWhiteboardTools: true,
});
const result = await tool.execute('call', {
agentId: slideTeacher.id,
instruction: 'Draw these shapes.',
});
expect(result.details).toMatchObject({
// The JSON dispatcher deduplicates equal warnings across the four rejected calls.
actionWarnings: [
expect.objectContaining({
actionName: 'wb_draw_shape',
reason: 'invalid_params',
message: expect.stringContaining('Legacy wb_draw_shape does not support'),
}),
],
});
expect(events.filter((e) => e.type === 'action')).toEqual([]);
expect(onActionDone).not.toHaveBeenCalled();
});
it('supports the full in-scope whiteboard action surface', async () => {
mockChildWithJsonOutput(
JSON.stringify([
+493 -10
View File
@@ -1,4 +1,5 @@
import type { AgentConfig } from '@/lib/orchestration/registry/types';
import type { PPTShapeElement } from '@openmaic/dsl';
import { BrowserRuntimeStore, RuntimeAppendConflictError } from '@openmaic/storage';
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb';
import { describe, expect, it, vi } from 'vitest';
@@ -239,7 +240,14 @@ describe('Native RuntimeStore whiteboard tools', () => {
it('keeps expectedLastSeq required and host-owned identity across every additive schema', () => {
const tools = build(service()).tools;
const samples: Record<string, Record<string, unknown>> = {
wb_draw_shape: { shape: 'rectangle', x: 1, y: 2, width: 30, height: 40 },
wb_draw_shape: {
path: 'M0 0 H1 V1 H0 Z',
viewBox: [1, 1],
x: 1,
y: 2,
width: 30,
height: 40,
},
wb_draw_chart: {
chartType: 'bar',
x: 1,
@@ -256,10 +264,15 @@ describe('Native RuntimeStore whiteboard tools', () => {
for (const [name, sample] of Object.entries(samples)) {
const tool = tools.find((candidate) => candidate.name === name)!;
expect(
(tool.parameters as { properties?: { expectedLastSeq?: { description?: string } } })
.properties?.expectedLastSeq?.description,
).toContain('Copy nextMutation.expectedLastSeq exactly');
const schema = tool.parameters as {
properties?: { expectedLastSeq?: { description?: string } };
anyOf?: Array<{ properties?: { expectedLastSeq?: { description?: string } } }>;
};
for (const variant of schema.anyOf ?? [schema]) {
expect(variant.properties?.expectedLastSeq?.description).toContain(
'Copy nextMutation.expectedLastSeq exactly',
);
}
expect(() => tool.prepareArguments?.(sample)).toThrow('strict schema');
expect(() =>
tool.prepareArguments?.({ ...sample, expectedLastSeq: null, elementId: 'model-owned' }),
@@ -346,11 +359,12 @@ describe('Native RuntimeStore whiteboard tools', () => {
});
});
it('commits every remaining additive draw type through the existing element_added contract', async () => {
it('preserves mixed elements across custom shape draws, rejection, reopen and replay', async () => {
vi.stubGlobal('IDBKeyRange', IDBKeyRange);
try {
const indexedDB = new IDBFactory();
const store = new BrowserRuntimeStore({
indexedDB: new IDBFactory(),
indexedDB,
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
});
const runtime = createWhiteboardRuntimeService({
@@ -363,7 +377,8 @@ describe('Native RuntimeStore whiteboard tools', () => {
{
name: 'wb_draw_shape',
params: {
shape: 'circle',
path: 'M1 0 A1 1 0 1 1 1 2 A1 1 0 1 1 1 0 Z',
viewBox: [2, 2],
x: 40,
y: 50,
width: 120,
@@ -447,8 +462,8 @@ describe('Native RuntimeStore whiteboard tools', () => {
top: 50,
width: 120,
height: 120,
viewBox: [1000, 1000],
path: 'M 500 0 A 500 500 0 1 1 499 0 Z',
viewBox: [2, 2],
path: 'M1 0 A1 1 0 1 1 1 2 A1 1 0 1 1 1 0 Z',
fill: '#123456',
fixedRatio: false,
}),
@@ -531,6 +546,75 @@ describe('Native RuntimeStore whiteboard tools', () => {
expect.stringMatching(/^native-wb-code-line:[0-9a-f]{64}:0$/u),
expect.stringMatching(/^native-wb-code-line:[0-9a-f]{64}:1$/u),
]);
const shape = tools.find((tool) => tool.name === 'wb_draw_shape')!;
const custom = {
expectedLastSeq: 5,
path: 'M0 0 L4 0 L0 3 Z',
viewBox: [4, 3],
x: 50,
y: 80,
width: 120,
height: 90,
rotate: 12.5,
fillColor: '#5b9bd5',
};
await expect(shape.execute('mixed-custom', custom)).resolves.toMatchObject({
details: { committedSeq: 6, replayed: false },
});
await expect(
tools
.find((tool) => tool.name === 'wb_draw_text')!
.execute('mixed-label', {
expectedLastSeq: 6,
content: 'Preserve the formula, line, chart, table and code.',
x: 50,
y: 190,
}),
).resolves.toMatchObject({ details: { committedSeq: 7 } });
await expect(
shape.execute('mixed-curve', {
...custom,
expectedLastSeq: 7,
path: 'M0 1 C1 0 2 0 3 1',
viewBox: [3, 1],
fillColor: 'transparent',
stroke: { color: '#333333', width: 2, style: 'dashed' },
}),
).resolves.toMatchObject({ details: { committedSeq: 8 } });
const mixed = await runtime.read('stage-1');
expect(mixed.whiteboard?.elements.slice(0, draws.length)).toEqual(state.whiteboard?.elements);
expect(mixed.whiteboard?.elements.map((element) => element.type)).toEqual([
'shape',
'chart',
'latex',
'table',
'line',
'code',
'shape',
'text',
'shape',
]);
await expect(
shape.execute('mixed-invalid', { ...custom, expectedLastSeq: 8, path: 'M0 0 Z1 1' }),
).resolves.toMatchObject({ isError: true, details: { code: 'INVALID_WHITEBOARD_SHAPE' } });
await expect(shape.execute('mixed-custom', custom)).resolves.toMatchObject({
details: { committedSeq: 6, lastSeq: 8, replayed: true },
});
await expect(runtime.read('stage-1')).resolves.toEqual(mixed);
const reopened = createWhiteboardRuntimeService({
store: new BrowserRuntimeStore({
indexedDB,
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
}),
resolveLearnerKey: () => 'learner-1',
withMaintenanceLock: (work) => work(),
});
await expect(reopened.read('stage-1')).resolves.toEqual(mixed);
const sessions = await store.listSessions('stage-1', 'learner-1');
await expect(store.listRecords(sessions[0]!.id)).resolves.toHaveLength(9);
} finally {
vi.unstubAllGlobals();
}
@@ -1735,3 +1819,402 @@ describe('Native RuntimeStore whiteboard tools', () => {
expect(runtime.append).not.toHaveBeenCalled();
});
});
const customTriangle = {
expectedLastSeq: null,
path: 'M 0 0 L 4 0 L 0 3 Z',
viewBox: [4, 3],
x: 100,
y: 100,
width: 240,
height: 180,
};
const invalidShapes = [
{ ...customTriangle, path: undefined },
{ ...customTriangle, viewBox: undefined },
{ ...customTriangle, path: '' },
{ ...customTriangle, path: ' ' },
{ ...customTriangle, path: 'M0 0 Z1 1' },
{ ...customTriangle, path: 'M0 0 L' },
{ ...customTriangle, path: '<svg><path d="M0 0"/></svg>' },
{ ...customTriangle, viewBox: [0, 3] },
{ ...customTriangle, viewBox: [-1, 3] },
{ ...customTriangle, viewBox: [4, Infinity] },
{ ...customTriangle, viewBox: [4] },
{ ...customTriangle, viewBox: [4, 3, 1] },
{ ...customTriangle, path: 'M0 0 L1e999 0' },
{ ...customTriangle, path: 'M0 0 L1000001 0' },
{ ...customTriangle, path: 'M0 0' + ' l100000 0'.repeat(11) },
{ ...customTriangle, path: 'M0 0 L10000 0', viewBox: [0.01, 3] },
{ ...customTriangle, path: 'M0 0' + ' L1 1'.repeat(10001) },
{ ...customTriangle, path: 'M0 0' + ' '.repeat(100001) },
{ ...customTriangle, width: 0 },
{ ...customTriangle, height: -1 },
{ ...customTriangle, width: 1000001 },
{ ...customTriangle, rotate: NaN },
{ ...customTriangle, rotate: Infinity },
{ ...customTriangle, rotate: '30' },
{ ...customTriangle, stroke: { color: '#111', width: -1 } },
{ ...customTriangle, stroke: { color: '#111', width: Infinity } },
{ ...customTriangle, stroke: { color: '#111', width: 2, style: 'unknown' } },
{ ...customTriangle, stroke: { color: '#111', width: 2, extra: true } },
{ ...customTriangle, elementId: 'model-owned' },
{ ...customTriangle, shape: 'rectangle' },
{ ...customTriangle, shape: 'circle' },
{ ...customTriangle, shape: 'triangle' },
{ ...customTriangle, shape: 'custom' },
{ ...customTriangle, shape: 'unknown' },
];
describe('Native whiteboard rotation and custom geometry', () => {
it('advertises one path-only schema and rejects removed shape discriminators', () => {
const draw = build(service()).tools.find((tool) => tool.name === 'wb_draw_shape')!;
expect(draw.parameters).toMatchObject({
type: 'object',
additionalProperties: false,
required: expect.arrayContaining(['expectedLastSeq', 'path', 'viewBox']),
});
expect(draw.parameters).not.toHaveProperty('anyOf');
expect(draw.parameters).not.toHaveProperty('properties.shape');
for (const shape of ['rectangle', 'circle', 'triangle', 'custom']) {
expect(() => draw.prepareArguments?.({ ...customTriangle, shape })).toThrow(
'omit the removed shape field',
);
}
});
it('publishes provider-compatible array items schemas', () => {
const invalidPaths: string[] = [];
function visit(value: unknown, path: string) {
if (!value || typeof value !== 'object') return;
for (const [key, child] of Object.entries(value)) {
if (key === 'items' && Array.isArray(child)) invalidPaths.push(`${path}.items`);
if (Array.isArray(child))
child.forEach((item, index) => visit(item, `${path}.${key}[${index}]`));
else visit(child, `${path}.${key}`);
}
}
for (const tool of build(service()).tools) visit(tool.parameters, tool.name);
expect(invalidPaths).toEqual([]);
});
it.each([
{ name: 'rectangle', path: 'M0 0 H1 V1 H0 Z', viewBox: [1, 1] },
{
name: 'circle',
path: 'M1 0 A1 1 0 1 1 1 2 A1 1 0 1 1 1 0 Z',
viewBox: [2, 2],
},
{ name: 'isosceles triangle', path: 'M1 0 L2 1 L0 1 Z', viewBox: [2, 1] },
])('draws a $name using path-only arguments and default styling', async ({ path, viewBox }) => {
const runtime = service();
const draw = build(runtime).tools.find((t) => t.name === 'wb_draw_shape')!;
await draw.execute('basic-shape', {
expectedLastSeq: null,
path,
viewBox,
x: 1,
y: 2,
width: 30,
height: 40,
});
expect(runtime.append).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
operation: {
kind: 'element_added',
element: expect.objectContaining({
path,
viewBox,
rotate: 0,
fill: '#5b9bd5',
fixedRatio: false,
}),
},
}),
}),
);
const op = vi.mocked(runtime.append).mock.calls[0]![0].payload.operation;
if (op.kind !== 'element_added' || op.element.type !== 'shape')
throw new Error('missing shape');
expect(op.element).not.toHaveProperty('outline');
expect(op.element).not.toHaveProperty('shape');
});
it.each([45, -36.86989765, 12.5])(
'persists rotation %s without changing unrotated coordinates',
async (rotate) => {
const runtime = service();
const draw = build(runtime).tools.find((t) => t.name === 'wb_draw_shape')!;
const params = {
expectedLastSeq: null,
path: 'M0 0 H1 V1 H0 Z',
viewBox: [1, 1],
x: 520,
y: 180,
width: 150,
height: 150,
rotate,
};
expect(draw.prepareArguments?.(params)).toBe(params);
await draw.execute('rotate', params);
expect(runtime.append).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
operation: {
kind: 'element_added',
element: expect.objectContaining({
left: 520,
top: 180,
width: 150,
height: 150,
rotate,
}),
},
}),
}),
);
},
);
it('retains historical preset geometry after path-only draws and storage reopen', async () => {
vi.stubGlobal('IDBKeyRange', IDBKeyRange);
try {
const indexedDB = new IDBFactory();
const createRuntime = () =>
createWhiteboardRuntimeService({
store: new BrowserRuntimeStore({
indexedDB,
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
}),
resolveLearnerKey: () => 'learner-1',
withMaintenanceLock: (work) => work(),
});
const historical = [
'M 0 0 L 1000 0 L 1000 1000 L 0 1000 Z',
'M 500 0 A 500 500 0 1 1 499 0 Z',
'M 500 0 L 1000 1000 L 0 1000 Z',
].map(
(path, index): PPTShapeElement => ({
id: `historical-shape-${index}`,
type: 'shape',
path,
viewBox: [1000, 1000],
left: index * 100,
top: 40,
width: 80,
height: 80,
rotate: 0,
fill: '#5b9bd5',
fixedRatio: false,
}),
);
const runtime = createRuntime();
await runtime.append({
stageId: 'stage-1',
expectedLastSeq: null,
payload: {
payloadVersion: WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
operationId: 'historical-shape-import',
operation: {
kind: 'legacy_snapshot_imported',
source: {
kind: LEGACY_WHITEBOARD_SOURCE_KIND,
fingerprint: `sha256:${'1'.repeat(64)}`,
},
whiteboard: {
id: 'historical-board',
viewportSize: 1000,
viewportRatio: 0.5625,
elements: historical,
},
},
},
});
const draw = build(runtime).tools.find((tool) => tool.name === 'wb_draw_shape')!;
await expect(
draw.execute('new-path', { ...customTriangle, expectedLastSeq: 0 }),
).resolves.toMatchObject({ details: { committedSeq: 1 } });
const reopened = await createRuntime().read('stage-1');
expect(reopened.whiteboard?.elements.slice(0, 3)).toEqual(historical);
expect(reopened.whiteboard?.elements).toHaveLength(4);
expect(reopened.whiteboard?.elements[3]).toMatchObject({ path: customTriangle.path });
} finally {
vi.unstubAllGlobals();
}
});
it.each([
{ ...customTriangle, rotate: -12.5 },
{
...customTriangle,
path: 'M0 0 L4 0 L4 2 L2 3 L0 2 Z',
fillColor: 'transparent',
stroke: { color: '#111111', width: 2, style: 'dashed' },
},
{
...customTriangle,
path: 'M0 1 C1 0 2 0 3 1',
viewBox: [3, 1],
fillColor: 'transparent',
stroke: { color: '#333333', width: 2 },
},
{
...customTriangle,
path: 'M1 0 A1 1 0 1 1 1 2 A1 1 0 1 1 1 0 Z',
viewBox: [2, 2],
},
{
...customTriangle,
path: 'M0 0 A1 1 0 0 1 1 1',
viewBox: [1, 1],
fillColor: 'transparent',
stroke: { color: '#000000', width: 2 },
},
])('keeps custom path $path and optional stroke in PPTShapeElement', async (params) => {
const runtime = service();
const { tools, send } = build(runtime);
const draw = tools.find((t) => t.name === 'wb_draw_shape')!;
expect(draw.prepareArguments?.(params)).toBe(params);
const result = await draw.execute('geometry', params);
expect(result).toMatchObject({
details: {
committedSeq: 0,
affected: {
element: {
type: 'shape',
path: params.path,
viewBox: params.viewBox,
rotate: 'rotate' in params ? params.rotate : 0,
},
},
},
});
if ('stroke' in params)
expect(result.details).toMatchObject({
affected: {
element: {
outline: {
...params.stroke,
style: 'style' in params.stroke ? params.stroke.style : 'solid',
},
},
},
});
expect(send).toHaveBeenCalledExactlyOnceWith({
type: 'whiteboard',
data: { kind: 'projection', stageId: 'stage-1', lastSeq: 0 },
});
});
it('rejects invalid inputs in preparation and direct execution without records, sessions or events', async () => {
vi.stubGlobal('IDBKeyRange', IDBKeyRange);
try {
const store = new BrowserRuntimeStore({
indexedDB: new IDBFactory(),
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
});
const runtime = createWhiteboardRuntimeService({
store,
resolveLearnerKey: () => 'learner-1',
withMaintenanceLock: (work) => work(),
});
const append = vi.fn(runtime.append);
const { tools, send } = build({ ...runtime, append });
const draw = tools.find((t) => t.name === 'wb_draw_shape')!;
for (const params of invalidShapes) {
expect(() => draw.prepareArguments?.(params)).toThrow(/wb_draw_shape/);
await expect(draw.execute('invalid', params)).resolves.toMatchObject({
isError: true,
details: { code: 'INVALID_WHITEBOARD_SHAPE' },
});
}
expect(append).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
await expect(store.listSessions('stage-1', 'learner-1')).resolves.toEqual([]);
await expect(runtime.read('stage-1')).resolves.toMatchObject({
lastSeq: null,
whiteboard: null,
});
const corrected = await draw.execute('corrected', customTriangle);
expect(corrected).toMatchObject({ details: { committedSeq: 0 } });
} finally {
vi.unstubAllGlobals();
}
});
it('reports a useful grammar error and character position before append', async () => {
const runtime = service();
const draw = build(runtime).tools.find((t) => t.name === 'wb_draw_shape')!;
await expect(
draw.execute('bad-path', { ...customTriangle, path: 'M0 0 Z1 1' }),
).resolves.toMatchObject({
isError: true,
details: { error: { code: 'syntax', field: 'path', position: expect.any(Number) } },
content: [expect.objectContaining({ text: expect.stringContaining('wb_draw_shape path:') })],
});
expect(runtime.append).not.toHaveBeenCalled();
});
it('retains custom paths and angles after reopening storage, enforces CAS, and exactly replays once', async () => {
vi.stubGlobal('IDBKeyRange', IDBKeyRange);
const indexedDB = new IDBFactory();
const createRuntime = () => {
const store = new BrowserRuntimeStore({
indexedDB,
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
});
const runtime = createWhiteboardRuntimeService({
store,
resolveLearnerKey: () => 'learner-1',
withMaintenanceLock: (work) => work(),
});
return { store, runtime };
};
try {
const { runtime } = createRuntime();
const params = {
...customTriangle,
rotate: -36.86989765,
fillColor: 'transparent',
stroke: { color: '#111', width: 2 },
};
const draw = build(runtime).tools.find((t) => t.name === 'wb_draw_shape')!;
await expect(draw.execute('same-shape', params)).resolves.toMatchObject({
details: { committedSeq: 0, replayed: false },
});
const original = await runtime.read('stage-1');
const reopened = createRuntime();
await expect(reopened.runtime.read('stage-1')).resolves.toEqual(original);
const { tools } = build(reopened.runtime);
const replay = tools.find((t) => t.name === 'wb_draw_shape')!;
await expect(replay.execute('same-shape', params)).resolves.toMatchObject({
details: { committedSeq: 0, replayed: true },
});
await expect(replay.execute('stale-shape', params)).resolves.toMatchObject({
isError: true,
details: { code: 'STALE_STATE', actualLastSeq: 0 },
});
await expect(
replay.execute('next-shape', {
expectedLastSeq: 0,
path: 'M1 0 L2 1 L0 1 Z',
viewBox: [2, 1],
x: 10,
y: 20,
width: 30,
height: 40,
}),
).resolves.toMatchObject({ details: { committedSeq: 1 } });
await expect(replay.execute('same-shape', params)).resolves.toMatchObject({
details: { committedSeq: 0, lastSeq: 1, replayed: true },
});
const final = await reopened.runtime.read('stage-1');
expect(final.whiteboard?.elements[0]).toEqual(original.whiteboard?.elements[0]);
const sessions = await reopened.store.listSessions('stage-1', 'learner-1');
await expect(reopened.store.listRecords(sessions[0]!.id)).resolves.toHaveLength(2);
} finally {
vi.unstubAllGlobals();
}
});
});
+25
View File
@@ -66,6 +66,31 @@ function makeBody(overrides: Partial<StatelessChatRequest> = {}): StatelessChatR
}
describe('Pi director prompt closure routing', () => {
it('describes Native shape geometry only when its tool is available', () => {
const withShapes = buildNativeChildPrompt(makeBody(), agents[0], [], ['wb_draw_shape']);
expect(withShapes).toContain('viewBox:[w,h]');
expect(withShapes).toContain('viewBox:[4,3]');
expect(withShapes).toContain('M0 1 C1 0 2 0 3 1');
expect(withShapes).toContain('fillColor:"transparent"');
expect(withShapes).toContain('origin (0,0) is the unrotated element upper-left');
expect(withShapes).toContain('M1 0 A1 1 0 1 1 1 2 A1 1 0 1 1 1 0 Z');
expect(withShapes).toContain('M0 0 A1 1 0 0 1 1 1');
expect(withShapes).toContain('keep the viewBox ratio equal to width:height');
expect(withShapes).toContain('x=centerX-width/2 and y=centerY-height/2');
expect(withShapes).toContain('Arc radii are in viewBox units');
expect(withShapes).toContain('Close filled shapes with Z');
expect(withShapes).not.toContain('hypotenuse');
expect(withShapes).not.toContain('-36.86989765');
expect(withShapes).not.toContain('shape:"');
expect(withShapes).not.toContain('Presets:');
expect(buildNativeChildPrompt(makeBody(), agents[0], [], ['wb_read'])).not.toContain(
'# Native whiteboard shapes',
);
expect(buildChildPrompt(makeBody(), agents[0], [], [], ['wb_draw_shape'])).not.toContain(
'Rotated square on the hypotenuse',
);
});
it('keeps a navigable outline and requires scene evidence before delegation', () => {
const body = makeBody({
storeState: {
@@ -0,0 +1,282 @@
import type { PPTShapeElement } from '@openmaic/dsl';
import { describe, expect, it } from 'vitest';
import {
areBasicShapes,
assessComposition,
hasVisibleFill,
isCircle,
isCurveScenarioShape,
isPentagon,
isRightTriangle,
squareOnHypotenuse,
type TriangleVertices,
} from '@/eval/whiteboard-shapes/geometry-checks';
import { compositionScenario } from '@/eval/whiteboard-shapes/composition-scenarios';
function shape(overrides: Partial<PPTShapeElement> = {}): PPTShapeElement {
return {
id: 'eval-shape',
type: 'shape',
path: 'M0 0 H1 V1 H0 Z',
viewBox: [1, 1],
left: 80,
top: 100,
width: 120,
height: 120,
rotate: 0,
fill: '#5b9bd5',
fixedRatio: false,
...overrides,
};
}
const triangle = shape({
path: 'M0 0 L4 0 L0 3 Z',
viewBox: [4, 3],
left: 100,
width: 240,
height: 180,
});
const hypotenuseSquare = shape({
left: 160,
top: 160,
width: 300,
height: 300,
rotate: -36.86989765,
});
const pentagon = shape({
path: 'M0 0 L4 0 L4 2 L2 3 L0 2 Z',
viewBox: [4, 3],
width: 240,
height: 180,
});
const circle = shape({
path: 'M1 0 A1 1 0 1 1 1 2 A1 1 0 1 1 1 0 Z',
viewBox: [2, 2],
left: 340,
});
const curve = shape({
path: 'M0 1 C1 0 2 0 3 1',
viewBox: [3, 1],
left: 100,
top: 300,
width: 240,
height: 80,
rotate: 12.5,
fill: 'transparent',
outline: { color: '#333333', width: 2, style: 'solid' },
});
describe('whiteboard shape evaluation geometry', () => {
it.each([
'none',
'transparent',
'#0000',
'#00000000',
'rgba(12, 34, 56, 0)',
'hsla(0, 0%, 0%, 0)',
])('rejects an invisible SVG fill: %s', (fill) => {
expect(hasVisibleFill(shape({ fill }))).toBe(false);
const shapes = [shape({ fill }), circle, shape({ left: 600, path: 'M0.5 0 L1 1 L0 1 Z' })];
expect(areBasicShapes(shapes)).toBe(false);
});
it.each(['red', '#123', '#123456', '#12345680', 'rgba(12, 34, 56, 0.5)'])(
'accepts visible static SVG colors: %s',
(fill) => expect(hasVisibleFill(shape({ fill }))).toBe(true),
);
it('rejects invalid colors and fully transparent elements', () => {
expect(hasVisibleFill(shape({ fill: 'not-a-color' }))).toBe(false);
expect(hasVisibleFill(shape({ opacity: 0 }))).toBe(false);
});
it('accepts the requested open cubic geometry with transparent fill and visible dark stroke', () => {
expect(isCurveScenarioShape(curve)).toBe(true);
expect(isCurveScenarioShape({ ...curve, fill: 'none' })).toBe(true);
expect(isCurveScenarioShape({ ...curve, rotate: 372.5 })).toBe(true);
});
it.each([
{ path: 'M0 0 C1 1 2 1 3 0 Z' },
{ path: 'M0 0 C1 1 2 1 0 0' },
{ path: 'M0 1 L3 1' },
{ path: 'M0 1 C1 0 2 0 3 1 M0 0 C1 0 2 0 3 0' },
{ path: 'M0 1 C1 5 2 5 3 1' },
{ left: 10000, top: 10000 },
{ width: 1, height: 1 },
{ rotate: -12.5 },
{ fill: '#333333' },
{ opacity: 0 },
{ outline: { color: 'transparent', width: 2, style: 'solid' as const } },
{ outline: { color: '#00000000', width: 2, style: 'solid' as const } },
{ outline: { color: '#ffffff', width: 2, style: 'solid' as const } },
{ outline: { color: '#333333', width: 1, style: 'solid' as const } },
])('rejects a curve that violates the requested geometry or style: %j', (overrides) => {
expect(isCurveScenarioShape({ ...curve, ...overrides })).toBe(false);
});
it('scores the actual triangle after applying element rotation', () => {
expect(isRightTriangle(triangle)).toBe(true);
expect(isRightTriangle({ ...triangle, rotate: 90 })).toBe(false);
expect(isRightTriangle({ ...triangle, path: 'M0 0 H4 V3 H0 Z' })).toBe(false);
});
it('accepts the actual outward hypotenuse square in either winding direction', () => {
expect(squareOnHypotenuse(hypotenuseSquare)).toBe(true);
expect(squareOnHypotenuse({ ...hypotenuseSquare, path: 'M0 0 V1 H1 V0 Z' })).toBe(true);
expect(squareOnHypotenuse({ ...hypotenuseSquare, rotate: 36.86989765 })).toBe(false);
});
it.each([
'M0 0 L1 0 L0 1 Z',
'M0.5 0 A0.5 0.5 0 1 1 0.5 1 A0.5 0.5 0 1 1 0.5 0 Z',
'M0 0 L1 1 L1 0 L0 1 Z',
'M0 0 H0.5 V1 H0 Z',
'M0 0 H1 V1 H0',
])('rejects a non-square path in the correct hypotenuse box: %s', (path) => {
expect(squareOnHypotenuse({ ...hypotenuseSquare, path })).toBe(false);
});
it('counts real pentagon corners and tolerates repeated closing endpoints', () => {
expect(isPentagon(pentagon)).toBe(true);
expect(isPentagon({ ...pentagon, path: 'M0 0 L4 0 L4 2 L2 3 L0 2 L0 0 Z' })).toBe(true);
});
it.each([
'M0 0 H4 V3 H0 Z',
'M0 0 L4 0 L4 3 L0 3 L0 0 Z',
'M0 0 L2 0 L4 0 L4 3 L0 3 Z',
'M0 0 L4 2 L0 2 L4 0 L2 3 Z',
'M0 0 L4 0 L0 3 Z M1 1 L2 1 L1 2 Z',
])('rejects duplicate, collinear or intersecting pseudo-pentagons: %s', (path) => {
expect(isPentagon({ ...pentagon, path })).toBe(false);
});
it('accepts complete circles and rejects displaced or non-circular paths', () => {
expect(isCircle(circle, 400, 160, 60)).toBe(true);
expect(isCircle({ ...circle, top: 40 }, 400, 160, 60)).toBe(false);
expect(isCircle({ ...circle, path: 'M0 0 H2 V2 H0 Z' }, 400, 160, 60)).toBe(false);
});
it('checks rectangle edge order instead of accepting any four matching corners', () => {
const shapes = [shape(), circle, shape({ left: 600, path: 'M0.5 0 L1 1 L0 1 Z' })];
expect(areBasicShapes(shapes)).toBe(true);
expect(
areBasicShapes([{ ...shapes[0], path: 'M0 0 L1 1 L1 0 L0 1 Z' }, ...shapes.slice(1)]),
).toBe(false);
});
});
describe('unseen 5–12–13 composition evaluation', () => {
const vertices: TriangleVertices = [
[300, 220],
[492, 220],
[300, 300],
];
// Hand-computed reference contours, independent of the expected-square generator.
const paths = [
'M300 220 L492 220 L300 300 Z',
'M300 220 L492 220 L492 28 L300 28 Z',
'M492 220 L300 300 L380 492 L572 412 Z',
'M300 300 L300 220 L220 220 L220 300 Z',
];
const fromPath = (path: string) =>
shape({
path,
viewBox: [1000, 562.5],
left: 0,
top: 0,
width: 1000,
height: 562.5,
});
const shapes = paths.map(fromPath);
it('accepts correct geometry as separate elements or one compound path, in either winding', () => {
expect(assessComposition(shapes, vertices).passed).toBe(true);
expect(assessComposition([fromPath(paths.join(' '))], vertices).passed).toBe(true);
expect(assessComposition(shapes, [vertices[0], vertices[2], vertices[1]]).passed).toBe(true);
});
it('scores actual transformed vertices rather than element boxes', () => {
const rotated = shapes.map((s) => ({ ...s, rotate: 90 }));
const rotatedVertices = vertices.map(([x, y]) => [
500 - (y - 281.25),
281.25 + x - 500,
]) as TriangleVertices;
expect(assessComposition(rotated, rotatedVertices).passed).toBe(true);
expect(assessComposition(rotated, vertices).passed).toBe(false);
});
it('rejects an inward square, crossed edges, and an unclosed contour', () => {
for (const wrong of [
'M300 220 L492 220 L492 412 L300 412 Z',
'M492 220 L380 492 L300 300 L572 412 Z',
'M492 220 L300 300 L380 492 L572 412',
]) {
const invalid = [shapes[0], shapes[1], fromPath(wrong), shapes[3]];
expect(assessComposition(invalid, vertices).passed).toBe(false);
expect(
assessComposition([fromPath(invalid.map((s) => s.path).join(' '))], vertices).passed,
).toBe(false);
}
});
it('rejects missing, duplicate, extra, or invisible geometry', () => {
expect(assessComposition(shapes.slice(0, 3), vertices).passed).toBe(false);
expect(assessComposition([shapes[0], shapes[1], shapes[2], shapes[2]], vertices).passed).toBe(
false,
);
expect(assessComposition([...shapes, shape()], vertices).passed).toBe(false);
expect(assessComposition([...shapes, fromPath('M0 0 L1 1')], vertices).passed).toBe(false);
expect(
assessComposition(
shapes.map((s) => ({ ...s, fill: 'transparent' })),
vertices,
).passed,
).toBe(false);
});
it('rejects an otherwise correct composition outside the canvas', () => {
const shifted = shapes.map((s) => ({ ...s, left: 1000 }));
const shiftedVertices = vertices.map(([x, y]) => [x + 1000, y]) as TriangleVertices;
expect(assessComposition(shifted, shiftedVertices).withinCanvas).toBe(false);
expect(assessComposition(shifted, shiftedVertices).passed).toBe(false);
});
it.each(['none', '#0000', '#00000000', 'rgba(12, 34, 56, 0)', 'hsla(0, 0%, 0%, 0)'])(
'rejects geometrically correct compositions with invisible fill %s',
(fill) => {
expect(
assessComposition(
shapes.map((s) => ({ ...s, fill })),
vertices,
).passed,
).toBe(false);
expect(assessComposition([{ ...fromPath(paths.join(' ')), fill }], vertices).passed).toBe(
false,
);
},
);
it('rejects geometrically correct but fully transparent elements', () => {
expect(
assessComposition(
shapes.map((s) => ({ ...s, opacity: 0 })),
vertices,
).passed,
).toBe(false);
});
it.each([0, 1, 2, 3])(
'keeps the 5–12–13 ratio in variant %i without disclosing square placements',
(variant) => {
const scenario = compositionScenario(variant);
const [a, b, c] = scenario.vertices;
const length = (p: number[], q: number[]) => Math.hypot(p[0] - q[0], p[1] - q[1]);
expect(length(a, b) / length(a, c)).toBeCloseTo(12 / 5, 3);
expect(length(b, c) / length(a, c)).toBeCloseTo(13 / 5, 3);
expect(scenario.request).not.toMatch(/viewBox|path:|rotate:|正方形.*\d/);
},
);
});