Skip to content
Merged
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
53 changes: 53 additions & 0 deletions e2e/follow-up-steering.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";

test("a follow-up steers the active agent run before it completes", async ({ page }) => {
await page.goto("/");
const [newChatResponse] = await Promise.all([
page.waitForResponse((response) =>
response.request().method() === "POST" && new URL(response.url()).pathname === "/api/conversations",
),
page.getByTestId("sidebar").getByRole("button", { name: "New chat", exact: true }).first().click(),
]);
expect(newChatResponse.ok()).toBe(true);
const { id: conversationId } = await newChatResponse.json() as { id: string };

const composer = page.getByTestId("composer-input");
await expect(composer).toBeEnabled({ timeout: 60_000 });
await composer.fill("follow-up-steering-hold: start a box design");
await page.getByTestId("composer-send").click();

await expect.poll(async () => {
const status = await (
await page.request.get(`/api/test/fake-model-holds?conversationId=${conversationId}`)
).json() as { held: boolean };
return status.held;
}).toBe(true);

await composer.fill("change the width to 40 mm");
await page.getByTestId("composer-send").click();
await expect(page.getByTestId("queued-message")).toContainText("change the width to 40 mm");
await expect.poll(async () => {
const result = await (
await page.request.post(`/api/test/fake-model-holds/release?conversationId=${conversationId}`)
).json() as { released: boolean };
return result.released;
}).toBe(true);

await expect(page.getByText("Correction consumed by the active run before it completed.")).toBeVisible();
await expect(page.getByTestId("queued-message")).toHaveCount(0);

const diagnostics = await (
await page.request.get(`/api/test/fake-model-requests?conversationId=${conversationId}`)
).json() as { requests: Array<{ sequence: number; messageCount: number }> };
expect(diagnostics.requests).toEqual([
expect.objectContaining({ sequence: 1, messageCount: 1 }),
expect.objectContaining({ sequence: 2, messageCount: 3 }),
]);

const rows = await (
await page.request.get(`/api/conversations/${conversationId}/messages`)
).json() as Array<{ seq: number; role: string; contentJson: string }>;
const corrections = rows.filter((row) => row.contentJson.includes("change the width to 40 mm"));
expect(corrections).toHaveLength(1);
expect(corrections[0]).toMatchObject({ seq: 2, role: "user" });
});
128 changes: 127 additions & 1 deletion e2e/params.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,39 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import sharp from "sharp";

async function maxChannelDeviation(png: Buffer): Promise<number> {
const stats = await sharp(png).removeAlpha().stats();
return Math.max(...stats.channels.map((channel) => channel.stdev));
}

async function meanPixelDifference(before: Buffer, after: Buffer): Promise<number> {
const stats = await sharp(before)
.composite([{ input: after, blend: "difference" }])
.removeAlpha()
.stats();
return Math.max(...stats.channels.map((channel) => channel.mean));
}

async function waitForViewerPaint(page: Page): Promise<void> {
await page.evaluate(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
});
}

async function dragSlider(page: Page, name: string, fraction: number): Promise<void> {
const thumb = page.getByTestId(`param-${name}`).getByRole("slider");
const track = thumb.locator("xpath=..");
const box = await track.boundingBox();
if (!box) throw new Error(`Slider track for ${name} has no bounding box`);
await thumb.hover();
await page.mouse.down();
await page.mouse.move(
box.x + Math.max(1, Math.min(box.width - 1, box.width * fraction)),
box.y + box.height / 2,
{ steps: 8 },
);
await page.mouse.up();
}

// Fake-LLM mode (CHAMFER_FAKE_LLM=1): the scripted agent turn produces a
// parametric 10x20x30 box whose dimensions are params. Committing a new width
Expand Down Expand Up @@ -56,3 +91,94 @@ test("param slider re-runs locally without a new chat message", async ({ page })
await expect(rightPanel.getByTestId("params-panel")).toHaveCount(0);
await expect(rightPanel.getByTestId("export-step")).toBeDisabled();
});

test("pointer dragging accepts responsive geometry and rejects an ineffective parameter", async ({ page }, testInfo) => {
test.setTimeout(600_000);
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");

const created = page.waitForResponse(
(response) => response.url().includes("/api/conversations") && response.request().method() === "POST",
);
await page.getByTestId("sidebar").getByRole("button", { name: "New chat", exact: true }).first().click();
const conversation = (await (await created).json()) as { id: string };

const rightPanel = page.getByTestId("right-panel");
await rightPanel.getByTestId("script-panel-toggle").click();
const scriptInput = rightPanel.getByTestId("script-input");
const measurements = rightPanel.getByTestId("measurements");
const responsiveCode = `# --- params ---
width = 10 # [10, 100] Overall width in mm
# --- end params ---
from build123d import *
result = Box(width, 20, 30)`;

await scriptInput.fill(responsiveCode);
await rightPanel.getByTestId("script-run").click();
await expect(measurements).toContainText("10 x 20 x 30", { timeout: 600_000 });
await rightPanel.getByTestId("params-panel-toggle").click();

const canvas = rightPanel.locator("canvas");
await waitForViewerPaint(page);
const beforePixels = await canvas.screenshot({ path: testInfo.outputPath("responsive-before.png") });
expect(await maxChannelDeviation(beforePixels)).toBeGreaterThan(5);

await dragSlider(page, "width", 1);
const widthSlider = rightPanel.getByTestId("param-width").getByRole("slider");
await expect(widthSlider).not.toHaveAttribute("aria-valuenow", "10");
const responsiveWidth = Number(await widthSlider.getAttribute("aria-valuenow"));
expect(responsiveWidth).toBeGreaterThan(10);
await expect(measurements).toContainText(`${responsiveWidth} x 20 x 30`, { timeout: 120_000 });
await expect(measurements).toContainText(String(responsiveWidth * 600));
await expect(rightPanel.getByTestId("viewer")).toHaveAttribute("data-has-geometry", "true");
await waitForViewerPaint(page);
const afterPixels = await canvas.screenshot({ path: testInfo.outputPath("responsive-after.png") });
expect(await maxChannelDeviation(afterPixels)).toBeGreaterThan(5);
expect(await meanPixelDifference(beforePixels, afterPixels)).toBeGreaterThan(1);

await expect.poll(async () => {
const response = await page.request.get(`/api/conversations/${conversation.id}/artifacts`);
return ((await response.json()) as unknown[]).length;
}).toBe(1);
const artifactsAfterValidDrag = await page.request.get(`/api/conversations/${conversation.id}/artifacts`);
const validArtifacts = (await artifactsAfterValidDrag.json()) as Array<{ pySource: string }>;
expect(validArtifacts).toHaveLength(1);
expect(validArtifacts[0]?.pySource).toContain(`width = ${responsiveWidth}`);

const ineffectiveCode = `# --- params ---
width = 10 # [10, 100] Overall width in mm
# --- end params ---
from build123d import *
result = Box(10, 20, 30)`;
await scriptInput.fill(ineffectiveCode);
await rightPanel.getByTestId("script-run").click();
await expect(measurements).toContainText("10 x 20 x 30", { timeout: 120_000 });
await expect(rightPanel.getByTestId("param-width").getByRole("slider")).toHaveAttribute(
"aria-valuenow",
"10",
);

await dragSlider(page, "width", 1);
const error = rightPanel.getByTestId("param-error");
await expect(error).toContainText("Parameter `width` does not change the executed geometry", {
timeout: 120_000,
});
await expect(measurements).toContainText("10 x 20 x 30");

const artifactsAfterRejectedDrag = await page.request.get(
`/api/conversations/${conversation.id}/artifacts`,
);
expect((await artifactsAfterRejectedDrag.json()) as unknown[]).toHaveLength(1);

await rightPanel.getByTestId("param-input-width").fill("10");
await rightPanel.getByTestId("param-input-width").press("Enter");
await expect(error).toHaveCount(0);
await expect(rightPanel.getByTestId("param-width").getByRole("slider")).toHaveAttribute("aria-valuenow", "10");

await page.reload();
await rightPanel.getByTestId("script-panel-toggle").click();
await expect(rightPanel.getByTestId("measurements")).toContainText(`${responsiveWidth} x 20 x 30`, {
timeout: 600_000,
});
await expect(rightPanel.getByTestId("export-step")).toBeEnabled();
});
137 changes: 137 additions & 0 deletions packages/client/public/py/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import ast
import contextlib
import io
import math
import re
import traceback

Expand All @@ -16,6 +17,8 @@
_EXPECT_REQUIRED_KEYS = ("bodies", "bbox_mm")
_EXPECT_ALLOWED_KEYS = frozenset({"bodies", "bbox_mm", "bbox_tol", "volume_mm3"})
DEFAULT_BBOX_TOL = 0.5
PARAMETER_GEOMETRY_REL_TOL = 1e-7
PARAMETER_GEOMETRY_ABS_TOL = 1e-7

CHECKS_START = "# --- checks ---"
CHECKS_END = "# --- end checks ---"
Expand Down Expand Up @@ -177,6 +180,139 @@ def set_params(source: str, values: dict[str, float]) -> str:
return "\n".join(lines)


def _parameter_probe_values(value, lo, hi):
"""Return deterministic interior probe values for one visible parameter."""
if not all(_is_number(number) and math.isfinite(number) for number in (value, lo, hi)):
return []
if lo >= hi or value < lo or value > hi:
return []

integral = all(isinstance(number, int) for number in (value, lo, hi))
span = hi - lo
raw_candidates = (lo + span * 0.25, lo + span * 0.75)
if integral:
candidates = [int(round(candidate)) for candidate in raw_candidates]
candidates.extend((lo, hi))
else:
candidates = list(raw_candidates)

unique = []
for candidate in candidates:
if candidate != value and candidate not in unique and lo <= candidate <= hi:
unique.append(candidate)
return sorted(unique, key=lambda candidate: (-abs(candidate - value), candidate))


def _geometry_signature(shape):
"""Executed geometry evidence used to compare a parameter probe with its baseline."""
bb = shape.bounding_box()
center = shape.center()
vertices, _triangles = shape.tessellate(tolerance=0.1)
points = sorted((float(v.X), float(v.Y), float(v.Z)) for v in vertices)
return {
"bbox": (
float(bb.min.X), float(bb.min.Y), float(bb.min.Z),
float(bb.max.X), float(bb.max.Y), float(bb.max.Z),
),
"center": (float(center.X), float(center.Y), float(center.Z)),
"volume": float(shape.volume),
"area": float(shape.area),
"points": points,
}


def _numbers_close(left, right, scale=1.0):
return math.isclose(
left,
right,
rel_tol=PARAMETER_GEOMETRY_REL_TOL,
abs_tol=max(PARAMETER_GEOMETRY_ABS_TOL, abs(scale) * PARAMETER_GEOMETRY_REL_TOL),
)


def _same_executed_geometry(left, right):
scale = max(
(abs(value) for value in left["bbox"] + right["bbox"]),
default=1.0,
)
if len(left["points"]) != len(right["points"]):
return False
if not all(
_numbers_close(a, b, scale)
for a, b in zip(left["bbox"] + left["center"], right["bbox"] + right["center"])
):
return False
if not _numbers_close(left["volume"], right["volume"], max(abs(left["volume"]), abs(right["volume"]), 1.0)):
return False
if not _numbers_close(left["area"], right["area"], max(abs(left["area"]), abs(right["area"]), 1.0)):
return False
return all(
_numbers_close(a, b, scale)
for left_point, right_point in zip(left["points"], right["points"])
for a, b in zip(left_point, right_point)
)


def _parameter_responsiveness_checks(source, shape):
baseline = _geometry_signature(shape)
checks = []
for spec in parse_params(source):
name = spec["name"]
probes = _parameter_probe_values(spec["value"], spec["min"], spec["max"])
if not probes:
checks.append(
_gate_check(
f"parameter_{name}",
False,
f"Parameter `{name}` needs a valid adjustable range containing its current value; "
f"found value {spec['value']} with range [{spec['min']}, {spec['max']}].",
)
)
continue

probe_errors = []
responsive_at = None
for probe in probes:
try:
probe_source = set_params(source, {name: probe})
probe_result, _stdout = _execute(probe_source)
probe_shape = _to_shape(probe_result)
if not _same_executed_geometry(baseline, _geometry_signature(probe_shape)):
responsive_at = probe
break
except Exception as error:
probe_errors.append(f"{probe}: {error}")

if responsive_at is not None:
checks.append(
_gate_check(
f"parameter_{name}",
True,
f"Parameter `{name}` changes the executed geometry at probe value {responsive_at}.",
)
)
elif probe_errors and len(probe_errors) == len(probes):
checks.append(
_gate_check(
f"parameter_{name}",
False,
f"Parameter `{name}` could not be verified because every in-range probe failed: "
+ "; ".join(probe_errors),
)
)
else:
checks.append(
_gate_check(
f"parameter_{name}",
False,
f"Parameter `{name}` does not change the executed geometry at deterministic "
f"in-range probes {probes}. Use `{name}` to derive a dimension, feature, or placement "
"of `result`, or remove it from the params block.",
)
)
return checks


def _is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)

Expand Down Expand Up @@ -909,6 +1045,7 @@ def _run_gate_checks(source, shape):
_gate_check("valid", shape.is_valid, "B-rep validity (is_valid)"),
_gate_check("nondegenerate", volume > 0, f"total volume {volume:.6g} mm^3 must be > 0"),
]
checks.extend(_parameter_responsiveness_checks(source, shape))
try:
parse_component(source)
except ValueError as e:
Expand Down
Loading
Loading