diff --git a/e2e/plan-flow.spec.ts b/e2e/plan-flow.spec.ts new file mode 100644 index 0000000..143046e --- /dev/null +++ b/e2e/plan-flow.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; + +/** + * Full plan-artifact loop against the scripted fake LLM (CHAMFER_FAKE_LLM=1): + * the agent plans two components, builds the base, records it done + * (evidence-checked against the gate), stops prematurely, gets the + * deterministic plan nudge, builds the assembly, and completes the plan. + */ +test("plan artifact loop: plan card, evidence-checked progress, stop-gate nudge", async ({ page }) => { + test.setTimeout(600_000); + await page.goto("/"); + await page.getByTestId("sidebar").getByRole("button", { name: "New chat", exact: true }).first().click(); + const composer = page.getByTestId("composer-input"); + await expect(composer).toBeEnabled(); + await composer.fill("plan-flow: build a base plate with a lid resting on it"); + await page.getByTestId("composer-send").click(); + + // The accepted plan renders as the live card with both components pending. + const planCard = page.getByTestId("plan-card"); + await expect(planCard).toBeVisible({ timeout: 600_000 }); + await expect(page.getByTestId("plan-progress")).toHaveText("0/2 components"); + + // Base run passes its gate and update_plan records it done (evidence-checked). + await expect(page.getByTestId("plan-progress")).toHaveText("1/2 components", { timeout: 600_000 }); + + // The premature stop draws the deterministic plan nudge as a system chip. + await expect(page.getByTestId("plan-nudge-chip")).toBeVisible({ timeout: 600_000 }); + + // The nudge forces the assembly run; the plan completes and the turn ends. + await expect(page.getByTestId("plan-progress")).toHaveText("2/2 components", { timeout: 600_000 }); + await expect(page.getByText("Assembly complete", { exact: false }).first()).toBeVisible({ timeout: 600_000 }); + + // The conversation badge reflects the assembly's passing gate. + await expect(page.getByTestId("verify-chip")).toHaveAttribute("data-status", "passed"); + + // Expanded card: both components ticked done. + await page.getByTestId("plan-card-toggle").click(); + const components = page.getByTestId("plan-component"); + await expect(components).toHaveCount(2); + await expect(components.nth(0)).toHaveAttribute("data-status", "done"); + await expect(components.nth(1)).toHaveAttribute("data-status", "done"); + + // Replay: the plan card and statuses survive a reload (derived from the transcript). + await page.reload(); + await expect(page.getByTestId("plan-card")).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId("plan-progress")).toHaveText("2/2 components"); +}); diff --git a/package-lock.json b/package-lock.json index 70ba193..c271a92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1629,6 +1629,41 @@ "@opentelemetry/api": "^1.9.0" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@mediapipe/tasks-vision": { "version": "0.10.17", "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", @@ -12159,6 +12194,7 @@ "@chamfer/shared": "*", "@earendil-works/pi-agent-core": "^0.80.3", "@earendil-works/pi-ai": "^0.80.3", + "@lezer/python": "^1.1.19", "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-label": "^2.1.11", "@radix-ui/react-select": "^2.3.3", diff --git a/packages/client/package.json b/packages/client/package.json index 08defe6..2cd5feb 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -13,6 +13,7 @@ "@chamfer/shared": "*", "@earendil-works/pi-agent-core": "^0.80.3", "@earendil-works/pi-ai": "^0.80.3", + "@lezer/python": "^1.1.19", "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-label": "^2.1.11", "@radix-ui/react-select": "^2.3.3", diff --git a/packages/client/public/py/harness.py b/packages/client/public/py/harness.py index 5c2bd83..139c129 100644 --- a/packages/client/public/py/harness.py +++ b/packages/client/public/py/harness.py @@ -23,6 +23,13 @@ DEFAULT_CHECK_TOL = 0.5 DEFAULT_SYMMETRY_TOL_PCT = 1.0 +COMPONENT_START = "# --- component ---" +COMPONENT_END = "# --- end component ---" +# Component ids double as plan component ids and Compound child labels; "probe" +# marks a diagnostic run that never advances the plan. +_COMPONENT_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$") +PROBE_COMPONENT = "probe" + # Only the trailing `# [min, max] description` comment is regex-parsed; the # assignment structure itself comes from ast.parse (comments are invisible # to the AST, so a regex is the only option for them). @@ -259,15 +266,17 @@ def parse_expect(source: str) -> dict: # Each kind maps to {required keys, optional keys}; "kind" itself is implicit. _CHECK_KEYS = { - "hole_through": ({"diameter", "count"}, {"tol"}), - "hole_blind": ({"diameter", "count"}, {"tol"}), - "clearance": ({"a", "b", "min_mm"}, set()), + "hole_through": ({"diameter", "count"}, {"tol", "target"}), + "hole_blind": ({"diameter", "count"}, {"tol", "target"}), + "hole_internal": ({"diameter", "count"}, {"tol", "target"}), + "clearance": ({"a", "b", "min_mm"}, {"max_mm"}), "bbox": ({"size_mm"}, {"target", "tol"}), "volume": ({"range_mm3"}, {"target"}), "count_faces": ({"count"}, {"target"}), "count_edges": ({"count"}, {"target"}), "symmetric": ({"plane"}, {"tol_pct"}), } +_HOLE_CHECK_KINDS = {"hole_through": "through", "hole_blind": "blind", "hole_internal": "internal"} _SYMMETRY_PLANES = ("XY", "XZ", "YZ") @@ -309,7 +318,7 @@ def _validate_check(index, raw): raise _check_error(index, f"{kind} has unknown keys: {sorted(unknown)}") spec = {"kind": kind} - if kind in ("hole_through", "hole_blind"): + if kind in _HOLE_CHECK_KINDS: d = raw["diameter"] if not _is_number(d) or d <= 0: raise _check_error(index, "diameter must be a positive number.") @@ -319,6 +328,7 @@ def _validate_check(index, raw): spec.update( diameter=float(d), tol=float(tol), count=_validate_count(index, raw["count"], allow_range=False), + target=_target(index, raw), ) elif kind == "clearance": for key in ("a", "b"): @@ -328,6 +338,11 @@ def _validate_check(index, raw): if not _is_number(gap) or gap < 0: raise _check_error(index, "min_mm must be a number >= 0.") spec.update(a=raw["a"], b=raw["b"], min_mm=float(gap)) + if "max_mm" in raw: + max_mm = raw["max_mm"] + if not _is_number(max_mm) or max_mm < gap: + raise _check_error(index, "max_mm must be a number >= min_mm.") + spec.update(max_mm=float(max_mm)) elif kind == "bbox": size = raw["size_mm"] if ( @@ -389,19 +404,11 @@ def parse_checks(source: str): tree = ast.parse(source) except SyntaxError as e: raise ValueError(f"checks block: script does not parse: {e}") - node = next( - ( - n - for n in tree.body - if start < n.lineno - 1 < end - and isinstance(n, ast.Assign) - and len(n.targets) == 1 - and isinstance(n.targets[0], ast.Name) - and n.targets[0].id == "CHECKS" - ), - None, - ) - if node is None: + nodes = _assignment_nodes(tree, "CHECKS") + if len(nodes) != 1: + raise ValueError("checks block must contain exactly one `CHECKS = [...]` assignment.") + node = nodes[0] + if not start < node.lineno - 1 < end: raise ValueError("checks block must contain a single `CHECKS = [...]` assignment.") try: raw = ast.literal_eval(node.value) @@ -414,6 +421,75 @@ def parse_checks(source: str): return [_validate_check(i, entry) for i, entry in enumerate(raw)] +def _assignment_nodes(tree, name): + return [ + node + for node in tree.body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == name + ] + + +def _single_assignment(source, name, context): + """The literal value of a unique top-level `NAME = ` assignment, + or None when the script has no such assignment.""" + try: + tree = ast.parse(source) + except SyntaxError as e: + raise ValueError(f"{context}: script does not parse: {e}") + nodes = _assignment_nodes(tree, name) + if not nodes: + return None + if len(nodes) != 1: + raise ValueError(f"{context}: {name} must have exactly one top-level assignment.") + node = nodes[0] + try: + return ast.literal_eval(node.value) + except ValueError: + raise ValueError(f"{context}: {name} must be a literal value.") + + +def parse_component(source: str): + """Parse the optional COMPONENT declaration (plan evidence link). + + Returns None when the script declares nothing, the declared string, or the + declared list of id strings. Raises ValueError with a user-facing message + on a malformed declaration; the gate surfaces that as a failed check. + """ + raw = _single_assignment(source, "COMPONENT", "component declaration") + if raw is None: + return None + ids = [raw] if isinstance(raw, str) else list(raw) if isinstance(raw, (list, tuple)) else None + if not ids or not all(isinstance(i, str) for i in ids): + raise ValueError("COMPONENT must be a non-empty string or list of strings.") + for component_id in ids: + if not _COMPONENT_ID_RE.match(component_id): + raise ValueError( + f"COMPONENT id {component_id!r} must be a lowercase slug " + "(letters, digits, _ or -, starting with a letter)." + ) + if PROBE_COMPONENT in ids and len(ids) > 1: + raise ValueError('COMPONENT "probe" marks a diagnostic run and cannot be combined with component ids.') + return raw if isinstance(raw, str) else ids + + +def checks_literal(source: str): + """The raw CHECKS entries exactly as the script wrote them, or None. + + Echoed in measurements so the plan's evidence rule can compare an accepted + plan's per-component checks against what a gate-passed run actually ran. + Raises ValueError on an unparseable script or non-list CHECKS. + """ + raw = _single_assignment(source, "CHECKS", "checks block") + if raw is None: + return None + if not isinstance(raw, (list, tuple)): + raise ValueError("CHECKS must be a list of check dicts.") + return list(raw) + + # ---------- geometric diagnostics ---------- _HOLE_PROBE_EPS_MM = 0.1 # how far past a hole's ends to probe for material @@ -587,12 +663,17 @@ def _gate_check(name, passed, detail): def _resolve_target(shape, target): - """The shape a check applies to: the whole result, or one child by label. + """The shape a check applies to: the whole result, one child by label, or + the result itself when its own label matches (so a single-component run + with `result.label = "base"` satisfies the same targeted checks as the + assembly's "base" child does later). Returns (shape, error_detail); exactly one is None. """ if target is None: return shape, None + if (getattr(shape, "label", "") or "") == target: + return shape, None labeled = _children_with_labels(shape) for label, child in labeled: if label == target: @@ -601,8 +682,8 @@ def _resolve_target(shape, target): return None, f"no child labeled {target!r}; available labels: {labels}" -def _eval_hole_check(spec, holes): - wanted_kind = "through" if spec["kind"] == "hole_through" else "blind" +def _eval_hole_check(spec, holes, label): + wanted_kind = _HOLE_CHECK_KINDS[spec["kind"]] d, tol = spec["diameter"], spec["tol"] matching = [h for h in holes if abs(h["diameterMm"] - d) <= tol] found = sum(1 for h in matching if h["kind"] == wanted_kind) @@ -612,8 +693,9 @@ def _eval_hole_check(spec, holes): if kind != wanted_kind } other_text = ", ".join(f"{n} {kind}" for kind, n in others.items() if n) + scope = f" in {label}" if label else "" detail = ( - f"{wanted_kind} holes d={d:g}±{tol:g} mm: expected {spec['count']}, found {found}" + f"{wanted_kind} holes{scope} d={d:g}±{tol:g} mm: expected {spec['count']}, found {found}" + (f" (also {other_text} at this diameter)" if other_text else "") ) return found == spec["count"], detail @@ -632,9 +714,17 @@ def _eval_clearance_check(spec, shape): f"{verdict['overlapMm3']:.6g} mm^3 (required gap >= {spec['min_mm']:g} mm)" ) distance = verdict["distanceMm"] - return distance >= spec["min_mm"], ( + max_mm = spec.get("max_mm") + if max_mm is None: + return distance >= spec["min_mm"], ( + f"clearance {spec['a']}/{spec['b']}: measured {distance:.6g} mm, " + f"required >= {spec['min_mm']:g} mm" + ) + # A bounded gap: max_mm 0 asserts contact ("touching"), a [min, max] range + # asserts a controlled fit. Interpenetration failed above either way. + return spec["min_mm"] <= distance <= max_mm, ( f"clearance {spec['a']}/{spec['b']}: measured {distance:.6g} mm, " - f"required >= {spec['min_mm']:g} mm" + f"required {spec['min_mm']:g}..{max_mm:g} mm" ) @@ -648,8 +738,17 @@ def _eval_check(spec, shape, holes): """(passed, detail) for one normalized check spec. May raise; the caller converts exceptions into a failed check.""" kind = spec["kind"] - if kind in ("hole_through", "hole_blind"): - return _eval_hole_check(spec, holes()) + if kind in _HOLE_CHECK_KINDS: + target_label = spec.get("target") + if target_label is None: + return _eval_hole_check(spec, holes(), None) + # A targeted hole check runs the census on that child in isolation, so a + # bore capped or occupied by a neighbouring part still classifies by the + # component's own geometry (through), not the assembly's (blind/internal). + target, err = _resolve_target(shape, target_label) + if err: + return False, f"{kind} on {target_label}: {err}" + return _eval_hole_check(spec, _hole_census(target), target_label) if kind == "clearance": return _eval_clearance_check(spec, shape) @@ -737,6 +836,10 @@ 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"), ] + try: + parse_component(source) + except ValueError as e: + checks.append(_gate_check("component_block", False, str(e))) try: expect = parse_expect(source) except ValueError as e: @@ -835,7 +938,19 @@ def _measure(shape): pass if len(labeled) >= 2: try: - m["clearances"] = _clearance_matrix(labeled) + matrix = _clearance_matrix(labeled) + m["clearances"] = matrix + # A child in contact with nothing (every pairwise state "apart") is + # floating: unsupported, unlocated, unassemblable. Listed only when + # non-empty so single-part and fully-connected results are unchanged. + in_contact = set() + for entry in matrix: + if entry["state"] != "apart": + in_contact.add(entry["a"]) + in_contact.add(entry["b"]) + floating = [label for label, _ in labeled if label not in in_contact] + if floating: + m["floating"] = floating except Exception: pass return m @@ -870,9 +985,25 @@ def run_script(source: str) -> dict: vertices, triangles = shape.tessellate(tolerance=0.1) positions = [c for v in vertices for c in (v.X, v.Y, v.Z)] indices = [i for tri in triangles for i in tri] + measurements = _measure(shape) + # Plan evidence echo: which component(s) this run declared, and the raw + # CHECKS entries it ran. Malformed declarations surface through the gate + # (component_block / checks_block checks), not here. + try: + component = parse_component(source) + if component is not None: + measurements["component"] = component + except ValueError: + pass + try: + raw_checks = checks_literal(source) + if raw_checks is not None: + measurements["checks"] = raw_checks + except ValueError: + pass return { "stdout": stdout_text, - "measurements": _measure(shape), + "measurements": measurements, "positions": positions, "indices": indices, "gate": evaluate_gate(source, shape), diff --git a/packages/client/src/agent/compaction.test.ts b/packages/client/src/agent/compaction.test.ts index 601aec9..2f75136 100644 --- a/packages/client/src/agent/compaction.test.ts +++ b/packages/client/src/agent/compaction.test.ts @@ -115,6 +115,29 @@ describe("findCompactionCut", () => { const messages = [assistant("only assistant")]; expect(findCompactionCut(messages, 0, 10)).toBe(0); }); + + it("never cuts past the newest accepted plan snapshot", () => { + const planResult = { + role: "toolResult", + toolCallId: "plan-1", + toolName: "update_plan", + content: [{ type: "text", text: "Plan accepted" }], + details: { plan: { goal: "g", components: [], interfaces: [] } }, + isError: false, + timestamp: 1, + }; + const messages = [ + user("turn 1"), + assistant("reply 1"), + user("plan it"), + planResult, + bigUser(30_000, "later chatter"), + assistant("reply"), + ]; + // Budget alone would keep only the tail; the plan result pulls the cut back + // to the start of its turn so the plan of record stays in context verbatim. + expect(findCompactionCut(messages, 0, 1_000)).toBe(2); + }); }); describe("runCompaction", () => { diff --git a/packages/client/src/agent/compaction.ts b/packages/client/src/agent/compaction.ts index 708c762..ca92362 100644 --- a/packages/client/src/agent/compaction.ts +++ b/packages/client/src/agent/compaction.ts @@ -8,6 +8,7 @@ import { import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; import type { Api, Message, Model } from "@earendil-works/pi-ai"; import { compactionBoundary, transformLlmContext, type CompactionMessage } from "./contextPolicy"; +import { isPlanToolResult } from "./plan"; /** * Domain-aware context compaction for long design sessions, built on pi's compaction @@ -65,9 +66,10 @@ export function needsCompaction(messages: AgentMessage[], model: Model): { /** * First transcript index of the kept tail: walking back from the end until roughly * `keepRecentTokens` are retained, snapped back to the start of a user turn so - * assistant/toolResult pairs are never split, and never past the newest verify-gate - * result (the latest gate + measurements always stay verbatim). Returns a value - * <= `floor` when there is nothing worth summarizing. + * assistant/toolResult pairs are never split, never past the newest verify-gate + * result (the latest gate + measurements always stay verbatim), and never past the + * newest accepted plan snapshot (the plan of record must stay in context verbatim). + * Returns a value <= `floor` when there is nothing worth summarizing. */ export function findCompactionCut( messages: readonly unknown[], @@ -82,14 +84,14 @@ export function findCompactionCut( if (accumulated >= keepRecentTokens) break; } - let latestGate = -1; - for (let index = messages.length - 1; index >= floor; index -= 1) { - if (hasGateVerdict(messages[index])) { - latestGate = index; - break; + for (const keep of [hasGateVerdict, isPlanToolResult]) { + for (let index = messages.length - 1; index >= floor; index -= 1) { + if (keep(messages[index])) { + if (index < candidate) candidate = index; + break; + } } } - if (latestGate >= 0 && latestGate < candidate) candidate = latestGate; for (let index = Math.min(candidate, messages.length - 1); index >= floor; index -= 1) { if (isUserMessage(messages[index])) return index; diff --git a/packages/client/src/agent/componentDeclaration.ts b/packages/client/src/agent/componentDeclaration.ts new file mode 100644 index 0000000..1f86abc --- /dev/null +++ b/packages/client/src/agent/componentDeclaration.ts @@ -0,0 +1,54 @@ +import { parser } from "@lezer/python"; +import type { SyntaxNode } from "@lezer/common"; + +const COMPONENT_ID_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/; + +function stringValue(code: string, node: SyntaxNode): string | undefined { + if (node.name !== "String") return undefined; + const literal = code.slice(node.from, node.to); + const match = /^(?:[ru]*)?(["'])([a-z][a-z0-9_-]{0,31})\1$/i.exec(literal); + return match && COMPONENT_ID_PATTERN.test(match[2] ?? "") ? match[2] : undefined; +} + +function assignmentValue(code: string, assignment: SyntaxNode): string[] | undefined { + const children: SyntaxNode[] = []; + const cursor = assignment.cursor(); + if (cursor.firstChild()) { + do children.push(cursor.node); + while (cursor.nextSibling()); + } + const expression = children.at(-1); + if (!expression) return undefined; + const single = stringValue(code, expression); + if (single) return [single]; + if (expression.name !== "ArrayExpression") return undefined; + + const ids: string[] = []; + const itemCursor = expression.cursor(); + if (!itemCursor.firstChild()) return undefined; + do { + if (itemCursor.name === "String") { + const id = stringValue(code, itemCursor.node); + if (!id) return undefined; + ids.push(id); + } else if (!new Set(["[", "]", ","]).has(itemCursor.name)) { + return undefined; + } + } while (itemCursor.nextSibling()); + return ids.length > 0 ? ids : undefined; +} + +/** + * Parse one unique top-level literal COMPONENT assignment for preflight budget + * attribution. Ambiguous or malformed declarations are deliberately unclassified; + * the Python harness remains the evidence authority and reports the gate error. + */ +export function parseComponentDeclaration(code: string): string[] | undefined { + const tree = parser.parse(code); + const assignments = tree.topNode.getChildren("AssignStatement").filter((node) => { + const name = node.getChild("VariableName"); + return name !== null && code.slice(name.from, name.to) === "COMPONENT"; + }); + if (assignments.length !== 1) return undefined; + return assignmentValue(code, assignments[0] as SyntaxNode); +} diff --git a/packages/client/src/agent/gateSummary.test.ts b/packages/client/src/agent/gateSummary.test.ts index adee537..f1e2322 100644 --- a/packages/client/src/agent/gateSummary.test.ts +++ b/packages/client/src/agent/gateSummary.test.ts @@ -38,4 +38,20 @@ describe("latestGateSummary", () => { expect(latestGateSummary([{ role: "toolResult", details: { gate: "oops" } }])).toBeUndefined(); expect(latestGateSummary([null, 42, "x"])).toBeUndefined(); }); + + it("skips probe runs: their verdicts never become the conversation badge", () => { + const probe = { + role: "toolResult", + toolCallId: "tc-probe", + content: [], + details: { + gate: { status: "passed", checks: [{ name: "c0", passed: true, detail: "d" }] }, + measurements: { component: "probe" }, + }, + }; + const messages = [runResult("failed", [{ passed: false }]), probe]; + // The newest non-probe verdict (the failure) wins over the newer probe pass. + expect(latestGateSummary(messages)).toEqual({ status: "failed", passedChecks: 0, totalChecks: 1 }); + expect(latestGateSummary([probe])).toBeUndefined(); + }); }); diff --git a/packages/client/src/agent/gateSummary.ts b/packages/client/src/agent/gateSummary.ts index 22a380a..8b17cfb 100644 --- a/packages/client/src/agent/gateSummary.ts +++ b/packages/client/src/agent/gateSummary.ts @@ -1,4 +1,5 @@ import type { Gate } from "@chamfer/shared"; +import { PROBE_COMPONENT, runComponentIds } from "./plan"; export interface GateSummary { status: Gate["status"]; @@ -10,8 +11,15 @@ const GATE_STATUSES: ReadonlySet = new Set(["passed", "failed", "error"] function gateOf(message: unknown): Gate | undefined { if (typeof message !== "object" || message === null) return undefined; - const m = message as { role?: unknown; details?: { gate?: unknown } }; + const m = message as { + role?: unknown; + details?: { gate?: unknown; measurements?: { component?: unknown } }; + }; if (m.role !== "toolResult") return undefined; + // Probe runs are diagnostics, not deliverables: their verdicts must never + // become the conversation's verification badge. + const ids = runComponentIds(m.details?.measurements); + if (ids.length === 1 && ids[0] === PROBE_COMPONENT) return undefined; const gate = m.details?.gate; if (typeof gate !== "object" || gate === null) return undefined; const status = (gate as { status?: unknown }).status; @@ -19,7 +27,7 @@ function gateOf(message: unknown): Gate | undefined { return gate as Gate; } -/** Verdict of the most recent verify-gate-bearing tool result, or undefined. +/** Verdict of the most recent verify-gate-bearing, non-probe tool result, or undefined. * Tolerates arbitrary message shapes: replayed history predating the gate, * lookup_docs results, and malformed content all simply yield no summary. */ export function latestGateSummary(messages: unknown[]): GateSummary | undefined { diff --git a/packages/client/src/agent/plan.test.ts b/packages/client/src/agent/plan.test.ts new file mode 100644 index 0000000..b921c4c --- /dev/null +++ b/packages/client/src/agent/plan.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from "vitest"; +import { + collectComponentEvidence, + hasAssemblyEvidence, + latestPlan, + parseComponentDeclaration, + planIncompleteComponents, + runComponentIds, + validatePlanSnapshot, + type Plan, + type PlanCheckEntry, +} from "./plan"; +import { createUpdatePlanTool } from "./tools/updatePlan"; + +function volumeCheck(id: string, lo = 5000, hi = 6000): PlanCheckEntry { + return { kind: "volume", range_mm3: [lo, hi], target: id }; +} + +function makePlan(overrides: Partial = {}): Plan { + return { + goal: "two-part housing", + components: [ + { id: "base", description: "housing base", bbox_mm: [100, 80, 30], status: "todo", checks: [volumeCheck("base")] }, + { id: "lid", description: "flat lid", bbox_mm: [100, 80, 5], status: "todo", checks: [volumeCheck("lid")] }, + ], + interfaces: [{ a: "base", b: "lid", kind: "clearance", min_mm: 0, max_mm: 0 }], + ...overrides, + }; +} + +function gatePassedRun(component: string | string[], checks: unknown[] = []): unknown { + return { + role: "toolResult", + toolName: "run_build123d", + isError: false, + content: [], + details: { gate: { status: "passed", checks: [] }, measurements: { component, checks } }, + timestamp: 1, + }; +} + +function planResult(plan: Plan): unknown { + return { + role: "toolResult", + toolName: "update_plan", + isError: false, + content: [], + details: { plan }, + timestamp: 1, + }; +} + +describe("validatePlanSnapshot", () => { + const noEvidence = new Map(); + + it("accepts a well-formed two-component plan", () => { + expect(validatePlanSnapshot({ next: makePlan(), previous: undefined, evidence: noEvidence })).toEqual([]); + }); + + it("rejects duplicate, malformed, and reserved component ids", () => { + const plan = makePlan({ + components: [ + { id: "base", description: "a", status: "todo" }, + { id: "base", description: "b", status: "todo" }, + { id: "Bad Id", description: "c", status: "todo" }, + { id: "probe", description: "d", status: "todo" }, + ], + interfaces: [], + }); + const errors = validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence }); + expect(errors.join("\n")).toMatch(/duplicate component id "base"/); + expect(errors.join("\n")).toMatch(/"Bad Id"/); + expect(errors.join("\n")).toMatch(/reserved for diagnostic runs/); + }); + + it("rejects abandoning without a reason", () => { + const plan = makePlan(); + plan.components[1]!.status = "abandoned"; + const errors = validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence }); + expect(errors.join("\n")).toMatch(/abandon_reason/); + }); + + it("rejects unknown check kinds", () => { + const plan = makePlan(); + plan.components[0]!.checks = [ + ...(plan.components[0]!.checks ?? []), + { kind: "hole_sideways", count: 2 } as unknown as PlanCheckEntry, + ]; + const errors = validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence }); + expect(errors.join("\n")).toMatch(/unknown check kind "hole_sideways"/); + }); + + it("rejects missing design evidence and malformed harness checks", () => { + const plan = makePlan(); + delete plan.components[0]!.bbox_mm; + plan.components[1]!.checks = [ + volumeCheck("lid"), + { kind: "hole_through", diameter: -4, count: 1, target: "lid", surprise: true } as unknown as PlanCheckEntry, + ]; + const errors = validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence }).join("\n"); + expect(errors).toMatch(/component "base": bbox_mm is required/); + expect(errors).toMatch(/component "lid": check 1/); + expect(errors).toMatch(/diameter must be a positive number/); + expect(errors).toMatch(/unknown keys: \["surprise"\]/); + }); + + it("accepts the volume check targeting the component regardless of check order", () => { + const plan = makePlan(); + plan.components[0]!.checks = [volumeCheck("lid"), volumeCheck("base")]; + expect(validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence })).toEqual([]); + }); + + it("requires a targeted, bounded volume check on every buildable component", () => { + const plan = makePlan(); + plan.components[0]!.checks = []; + plan.components[1]!.checks = [{ kind: "volume", range_mm3: [1000, 10000], target: "lid" }]; + const errors = validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence }); + expect(errors.join("\n")).toMatch(/component "base": checks must include a volume check/); + expect(errors.join("\n")).toMatch(/component "lid": volume range \[1000, 10000\] is too loose/); + + // An untargeted volume check does not count: it would measure the whole + // assembly in a multi-component run. + plan.components[0]!.checks = [{ kind: "volume", range_mm3: [5000, 6000] }]; + const untargeted = validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence }); + expect(untargeted.join("\n")).toMatch(/component "base": checks must include a volume check/); + + // Abandoned components are exempt. + plan.components[0]!.checks = []; + plan.components[0]!.status = "abandoned"; + plan.components[0]!.abandon_reason = "no longer needed"; + plan.components[1]!.checks = [volumeCheck("lid")]; + expect(validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence })).toEqual([]); + }); + + it("requires interface coverage for every non-free-floating component", () => { + const plan = makePlan({ interfaces: [] }); + const errors = validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence }); + expect(errors.filter((e) => e.includes("not held by any interface"))).toHaveLength(2); + }); + + it("accepts an uncovered component with a free_floating_reason", () => { + const plan = makePlan({ + components: [ + { id: "base", description: "base", bbox_mm: [10, 10, 10], status: "todo", free_floating_reason: "single part on the bench", checks: [volumeCheck("base")] }, + { id: "lid", description: "lid", bbox_mm: [10, 10, 2], status: "todo", free_floating_reason: "user wants it beside the base", checks: [volumeCheck("lid")] }, + ], + interfaces: [], + }); + expect(validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence })).toEqual([]); + }); + + it("rejects a disconnected interface graph", () => { + const plan = makePlan({ + components: [ + { id: "a1", description: "a", status: "todo" }, + { id: "a2", description: "b", status: "todo" }, + { id: "b1", description: "c", status: "todo" }, + { id: "b2", description: "d", status: "todo" }, + ], + interfaces: [ + { a: "a1", b: "a2", kind: "clearance", min_mm: 0 }, + { a: "b1", b: "b2", kind: "clearance", min_mm: 0 }, + ], + }); + const errors = validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence }); + expect(errors.join("\n")).toMatch(/disconnected/); + }); + + it("rejects clearance interfaces without min_mm and captive without endpoints", () => { + const plan = makePlan({ + interfaces: [ + { a: "base", b: "lid", kind: "clearance" }, + { a: "base", b: "ghost", kind: "captive" }, + ], + }); + const errors = validatePlanSnapshot({ next: plan, previous: undefined, evidence: noEvidence }); + expect(errors.join("\n")).toMatch(/requires min_mm/); + expect(errors.join("\n")).toMatch(/endpoints must be component ids/); + }); + + it("rejects silently dropping a previous component", () => { + const previous = makePlan(); + const next = makePlan({ + components: [{ id: "base", description: "base", status: "todo", free_floating_reason: "now single-part", checks: [volumeCheck("base")] }], + interfaces: [], + }); + const errors = validatePlanSnapshot({ next, previous, evidence: noEvidence }); + expect(errors.join("\n")).toMatch(/component "lid" from the previous plan is missing/); + }); + + it("rejects done without gate evidence and accepts it with evidence covering the planned checks", () => { + const check: PlanCheckEntry = { kind: "hole_through", diameter: 5.5, count: 4 }; + const plan = makePlan(); + plan.components[0]!.status = "done"; + plan.components[0]!.checks = [check, volumeCheck("base")]; + + const without = validatePlanSnapshot({ next: plan, previous: undefined, evidence: new Map() }); + expect(without.join("\n")).toMatch(/no gate-passed run has declared COMPONENT = "base"/); + + // Evidence with the check present in a different key order must pass. + const evidence = collectComponentEvidence([ + gatePassedRun("base", [{ count: 4, diameter: 5.5, kind: "hole_through" }, { target: "base", range_mm3: [5000, 6000], kind: "volume" }]), + ]); + expect(validatePlanSnapshot({ next: plan, previous: undefined, evidence })).toEqual([]); + + // Evidence that ran a different check set must fail. + const wrongEvidence = collectComponentEvidence([gatePassedRun("base", [{ kind: "bbox", size_mm: [1, 2, 3] }])]); + const missing = validatePlanSnapshot({ next: plan, previous: undefined, evidence: wrongEvidence }); + expect(missing.join("\n")).toMatch(/was not part of the gate-passed run/); + }); +}); + +describe("hasAssemblyEvidence", () => { + const clearanceCheck = { kind: "clearance", a: "base", b: "lid", min_mm: 0, max_mm: 0 }; + + it("is vacuously true for single-component or interface-free plans", () => { + const single = makePlan({ + components: [{ id: "base", description: "base", status: "todo", free_floating_reason: "solo" }], + interfaces: [], + }); + expect(hasAssemblyEvidence(single, [])).toBe(true); + expect(hasAssemblyEvidence(makePlan({ interfaces: [] }), [])).toBe(true); + }); + + it("rejects a run that declares all components but never ran the interface check", () => { + const plan = makePlan(); + const runWithoutCheck = gatePassedRun(["base", "lid"], [{ kind: "bbox", size_mm: [1, 2, 3], target: "base" }]); + expect(hasAssemblyEvidence(plan, [runWithoutCheck])).toBe(false); + }); + + it("accepts a run that declares all components and ran the interface check, either endpoint order", () => { + const plan = makePlan(); + expect(hasAssemblyEvidence(plan, [gatePassedRun(["base", "lid"], [clearanceCheck])])).toBe(true); + const swapped = { kind: "clearance", a: "lid", b: "base", min_mm: 0, max_mm: 0 }; + expect(hasAssemblyEvidence(plan, [gatePassedRun(["lid", "base"], [swapped])])).toBe(true); + }); + + it("rejects a run whose clearance bounds differ from the planned interface", () => { + const plan = makePlan(); + const looser = { kind: "clearance", a: "base", b: "lid", min_mm: 0 }; + expect(hasAssemblyEvidence(plan, [gatePassedRun(["base", "lid"], [looser])])).toBe(false); + }); + + it("ignores interfaces whose endpoint was abandoned and captive interfaces", () => { + const plan = makePlan({ + components: [ + { id: "base", description: "base", status: "todo", checks: [volumeCheck("base")] }, + { id: "lid", description: "lid", status: "abandoned", abandon_reason: "dropped" }, + { id: "pin", description: "pin", status: "todo", checks: [volumeCheck("pin")] }, + ], + interfaces: [ + { a: "base", b: "lid", kind: "clearance", min_mm: 0 }, + { a: "base", b: "pin", kind: "captive" }, + ], + }); + // The only live interface is captive (unmeasurable), so any gate-passed run + // declaring the active components suffices. + expect(hasAssemblyEvidence(plan, [gatePassedRun(["base", "pin"], [])])).toBe(true); + }); +}); + +describe("evidence and plan derivation from the transcript", () => { + it("collects evidence only from gate-passed runs and ignores probes and failures", () => { + const evidence = collectComponentEvidence([ + gatePassedRun("probe"), + gatePassedRun(["base", "lid"]), + { + role: "toolResult", + toolName: "run_build123d", + details: { gate: { status: "failed" }, measurements: { component: "pin" } }, + }, + ]); + expect([...evidence.keys()].sort()).toEqual(["base", "lid"]); + }); + + it("latestPlan returns the newest accepted snapshot and skips errored results", () => { + const first = makePlan({ goal: "v1" }); + const second = makePlan({ goal: "v2" }); + const messages = [ + planResult(first), + planResult(second), + { role: "toolResult", toolName: "update_plan", isError: true, content: [], timestamp: 2 }, + ]; + expect(latestPlan(messages)?.goal).toBe("v2"); + expect(latestPlan([])).toBeUndefined(); + }); + + it("planIncompleteComponents excludes done and abandoned", () => { + const plan = makePlan({ + components: [ + { id: "a1", description: "a", status: "done" }, + { id: "a2", description: "b", status: "building" }, + { id: "a3", description: "c", status: "abandoned", abandon_reason: "gone" }, + { id: "a4", description: "d", status: "todo" }, + ], + }); + expect(planIncompleteComponents(plan).map((c) => c.id)).toEqual(["a2", "a4"]); + }); + + it("runComponentIds normalizes string, array, and absent declarations", () => { + expect(runComponentIds({ component: "lid" })).toEqual(["lid"]); + expect(runComponentIds({ component: ["a", "b", 3 as unknown as string] })).toEqual(["a", "b"]); + expect(runComponentIds({})).toEqual([]); + expect(runComponentIds(undefined)).toEqual([]); + }); + + it("parses only one unambiguous top-level literal component declaration", () => { + expect(parseComponentDeclaration('COMPONENT = "lid"\nresult = Box(1, 1, 1)')).toEqual(["lid"]); + expect(parseComponentDeclaration('text = """\nCOMPONENT = "probe"\n"""\nresult = Box(1, 1, 1)')).toBeUndefined(); + expect(parseComponentDeclaration('COMPONENT = ["base", name]\nresult = Box(1, 1, 1)')).toBeUndefined(); + expect(parseComponentDeclaration('COMPONENT = "base"\nCOMPONENT = "probe"')).toBeUndefined(); + expect(parseComponentDeclaration('COMPONENT = b"probe"')).toBeUndefined(); + }); +}); + +describe("createUpdatePlanTool", () => { + it("accepts a valid snapshot and returns it in details with a compact text body", async () => { + const tool = createUpdatePlanTool({ getMessages: () => [] }); + const result = await tool.execute("t1", makePlan() as never, undefined as never, undefined as never); + expect(result.details?.plan.goal).toBe("two-part housing"); + const text = (result.content[0] as { text: string }).text; + expect(text).toMatch(/^Plan accepted: 2 components \(2 todo\), 1 interfaces\./); + expect(text).toContain('"goal":"two-part housing"'); + }); + + it("rejects an invalid snapshot with every error listed", async () => { + const tool = createUpdatePlanTool({ getMessages: () => [planResult(makePlan())] }); + const next = makePlan({ + components: [{ id: "base", description: "base", status: "done" }], + interfaces: [], + }); + await expect(tool.execute("t1", next as never, undefined as never, undefined as never)).rejects.toThrow( + /Plan rejected:[\s\S]*"lid" from the previous plan is missing[\s\S]*no gate-passed run/, + ); + }); +}); diff --git a/packages/client/src/agent/plan.ts b/packages/client/src/agent/plan.ts new file mode 100644 index 0000000..46ff6d8 --- /dev/null +++ b/packages/client/src/agent/plan.ts @@ -0,0 +1,425 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { validatePlanCheck, type PlanCheckEntry } from "./planChecks"; + +export { parseComponentDeclaration } from "./componentDeclaration"; +export type { PlanCheckEntry } from "./planChecks"; + +/** + * The plan artifact: a persisted, loop-enforced component list for multi-part designs. + * + * Snapshots are full replacements (never deltas) submitted through the update_plan + * tool. An accepted snapshot lives in that tool result's `details.plan`; the transcript + * is the storage (tool results are persisted and replayed like every other message), + * so the latest accepted snapshot is derived by scanning the message history. The + * trust model mirrors CHECKS: the agent authors the plan but cannot lie about + * progress - `done` requires gate evidence, and removing unfinished work requires an + * explicit abandon reason. + */ + +export type PlanComponentStatus = "todo" | "building" | "done" | "abandoned"; + +export const PLAN_COMPONENT_STATUSES: readonly PlanComponentStatus[] = [ + "todo", + "building", + "done", + "abandoned", +]; + +export interface PlanComponent { + /** Stable slug; must equal the Compound child label and the script COMPONENT declaration. */ + id: string; + description: string; + /** Target envelope, sorted-compare semantics like EXPECT.bbox_mm. */ + bbox_mm?: number[]; + /** CHECKS entries scoped to this component; the gate evidence for `done` must include them. */ + checks?: PlanCheckEntry[]; + status: PlanComponentStatus; + /** Required when status is "abandoned"; surfaced in the UI and the final summary. */ + abandon_reason?: string; + /** Exempts the component from interface coverage; must say why it is legitimately unattached. */ + free_floating_reason?: string; +} + +export type PlanInterfaceKind = "clearance" | "captive"; + +export const PLAN_INTERFACE_KINDS: readonly PlanInterfaceKind[] = ["clearance", "captive"]; + +/** + * A physical relation between two components. `clearance` bounds the gap (min_mm 0 = + * contact allowed; max_mm 0 = touching required); `captive` declares retention without + * contact (e.g. a hinge pin inside bores) and carries no dimensions. + */ +export interface PlanInterface { + a: string; + b: string; + kind: PlanInterfaceKind; + min_mm?: number; + max_mm?: number; +} + +export interface Plan { + goal: string; + components: PlanComponent[]; + interfaces: PlanInterface[]; +} + +/** Component ids must be label-safe slugs; "probe" is reserved for diagnostic runs. */ +const COMPONENT_ID_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/; +export const PROBE_COMPONENT = "probe"; + +/** A component's required volume check may span at most this hi/lo ratio; looser + * ranges cannot distinguish right topology from wrong (Phase 0's 200k-500k EXPECT + * happily contained both the open and the sealed housing). */ +export const VOLUME_RANGE_MAX_RATIO = 1.5; + +export const UPDATE_PLAN_TOOL_NAME = "update_plan"; + +interface ToolResultLike { + role?: unknown; + toolName?: unknown; + isError?: unknown; + details?: { + plan?: unknown; + gate?: { status?: unknown }; + measurements?: { component?: unknown; checks?: unknown }; + }; +} + +/** The newest accepted plan snapshot in the transcript, or undefined. */ +export function latestPlan(messages: readonly unknown[]): Plan | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const m = messages[index] as ToolResultLike; + if (m?.role !== "toolResult" || m.toolName !== UPDATE_PLAN_TOOL_NAME) continue; + if (m.isError) continue; + const plan = m.details?.plan; + if (plan && typeof plan === "object") return plan as Plan; + } + return undefined; +} + +/** True when the plan still has components to build (todo or building). */ +export function planIncompleteComponents(plan: Plan): PlanComponent[] { + return plan.components.filter((c) => c.status === "todo" || c.status === "building"); +} + +/** + * The CHECKS entry a clearance interface compiles to, in both endpoint orders + * (clearance is symmetric, so either ordering in the script is legitimate). + * Captive interfaces have no measurable gate check and compile to nothing. + */ +function interfaceCheckForms(iface: PlanInterface): string[] { + if (iface.kind !== "clearance") return []; + const base: Record = { kind: "clearance", min_mm: iface.min_mm }; + if (iface.max_mm !== undefined) base.max_mm = iface.max_mm; + return [canonical({ ...base, a: iface.a, b: iface.b }), canonical({ ...base, a: iface.b, b: iface.a })]; +} + +/** + * Whether some gate-passed run measured the assembly: it must declare every + * non-abandoned component at once AND its echoed CHECKS must contain each + * clearance interface's check entry - declaring the components without running + * the interface checks proves nothing about the fit. Per-component "done" + * statuses cannot certify the interfaces (Phase 6 live run: both components + * were done while nothing had verified the lid actually rests on the flange), + * so a plan with interfaces only counts as finished once this evidence exists. + */ +export function hasAssemblyEvidence(plan: Plan, messages: readonly unknown[]): boolean { + const active = new Set(plan.components.filter((c) => c.status !== "abandoned").map((c) => c.id)); + const interfaces = (plan.interfaces ?? []).filter((i) => active.has(i.a) && active.has(i.b)); + if (active.size < 2 || interfaces.length === 0) return true; + for (const message of messages) { + const m = message as ToolResultLike; + if (m?.role !== "toolResult" || m.toolName !== "run_build123d" || m.isError) continue; + if (m.details?.gate?.status !== "passed") continue; + const measurements = m.details?.measurements; + const ids = new Set(runComponentIds(measurements)); + if (![...active].every((id) => ids.has(id))) continue; + const rawChecks = Array.isArray(measurements?.checks) ? (measurements?.checks as unknown[]) : []; + const ran = new Set(rawChecks.map(canonical)); + const allInterfacesRan = interfaces.every((iface) => { + const forms = interfaceCheckForms(iface); + // Captive interfaces compile to no measurable check; the all-components + // gate-passed run itself is the best available evidence for them. + return forms.length === 0 || forms.some((form) => ran.has(form)); + }); + if (allInterfacesRan) return true; + } + return false; +} + +/** Component ids a run declared, normalized to an array; [] when undeclared or malformed. */ +export function runComponentIds(measurements: { component?: unknown } | undefined): string[] { + const declared = measurements?.component; + if (typeof declared === "string") return [declared]; + if (Array.isArray(declared)) return declared.filter((id): id is string => typeof id === "string"); + return []; +} + +/** Canonical JSON (sorted keys) so check-entry membership survives key ordering. */ +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (typeof value === "object" && value !== null) { + const entries = Object.keys(value as object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical((value as Record)[key])}`); + return `{${entries.join(",")}}`; + } + return JSON.stringify(value); +} + +export interface ComponentEvidence { + /** Canonical forms of the CHECKS entries run alongside the newest gate-passed run declaring the component. */ + checks: Set; +} + +/** + * Gate evidence per component id, derived from the transcript: the newest gate-passed + * run_build123d result that declared the component (COMPONENT echo in measurements), + * with the raw CHECKS entries the harness echoed for that run. Probe declarations + * never produce evidence. Deterministic over the message array, so a reloaded + * conversation reconstructs the same evidence. + */ +export function collectComponentEvidence(messages: readonly unknown[]): Map { + const evidence = new Map(); + for (const message of messages) { + const m = message as ToolResultLike; + if (m?.role !== "toolResult" || m.toolName !== "run_build123d" || m.isError) continue; + if (m.details?.gate?.status !== "passed") continue; + const measurements = m.details?.measurements; + const ids = runComponentIds(measurements).filter((id) => id !== PROBE_COMPONENT); + if (ids.length === 0) continue; + const rawChecks = Array.isArray(measurements?.checks) ? (measurements?.checks as unknown[]) : []; + const checks = new Set(rawChecks.map(canonical)); + for (const id of ids) evidence.set(id, { checks }); + } + return evidence; +} + +export interface ValidatePlanArgs { + next: Plan; + previous: Plan | undefined; + evidence: Map; +} + +/** + * Full validation of a plan snapshot. Returns human-readable errors; an empty array + * means the snapshot is accepted. Every rule here is part of the trust model - see + * the module doc. Rules: + * - structural: non-empty goal and components, unique label-safe ids, known check + * kinds, abandon requires a reason, interface endpoints must exist. + * - coverage: every component is held by some interface (or says why it is not), + * and the interface graph connects all covered components. + * - no silent drops: components from the previous snapshot cannot disappear. + * - evidence: "done" requires a gate-passed run that declared the component and ran + * all of its planned checks. + */ +export function validatePlanSnapshot({ next, previous, evidence }: ValidatePlanArgs): string[] { + const errors: string[] = []; + + if (typeof next.goal !== "string" || next.goal.trim() === "") { + errors.push("goal must be a non-empty string"); + } + if (!Array.isArray(next.components) || next.components.length === 0) { + errors.push("components must be a non-empty array"); + return errors; + } + const interfaces = Array.isArray(next.interfaces) ? next.interfaces : []; + + const ids = new Set(); + for (const component of next.components) { + const id = component?.id; + if (typeof id !== "string" || !COMPONENT_ID_PATTERN.test(id)) { + errors.push(`component id ${JSON.stringify(id)} must match ${COMPONENT_ID_PATTERN}`); + continue; + } + if (id === PROBE_COMPONENT) errors.push(`component id "${PROBE_COMPONENT}" is reserved for diagnostic runs`); + if (ids.has(id)) errors.push(`duplicate component id "${id}"`); + ids.add(id); + if (typeof component.description !== "string" || component.description.trim() === "") { + errors.push(`component "${id}": description must be a non-empty string`); + } + if (!PLAN_COMPONENT_STATUSES.includes(component.status)) { + errors.push(`component "${id}": unknown status ${JSON.stringify(component.status)}`); + } + if (component.status === "abandoned" && !component.abandon_reason?.trim()) { + errors.push(`component "${id}": abandoning requires a non-empty abandon_reason`); + } + if (component.status !== "abandoned" && component.bbox_mm === undefined) { + errors.push(`component "${id}": bbox_mm is required for buildable components`); + } else if (component.bbox_mm !== undefined) { + const bbox = component.bbox_mm; + if (!Array.isArray(bbox) || bbox.length !== 3 || bbox.some((v) => typeof v !== "number" || v <= 0)) { + errors.push(`component "${id}": bbox_mm must be three positive numbers`); + } + } + if (component.status !== "abandoned" && !Array.isArray(component.checks)) { + errors.push(`component "${id}": checks are required for buildable components`); + } + for (const [index, check] of (component.checks ?? []).entries()) { + for (const error of validatePlanCheck(check)) errors.push(`component "${id}": check ${index}: ${error}`); + } + // Every buildable component must carry a targeted, bounded volume check. + // Volume is the cheapest topology detector: a sealed cavity, missing pocket, + // or eaten floor shifts it immediately, and the expected range is derived + // from the component's own intended dimensions - which is exactly the + // knowledge feature-level checks fail to encode. (Phase 6 live evidence: a + // gearbox base sealed shut passed holes+bbox+symmetry checks unnoticed.) + if (component.status !== "abandoned") { + const volume = (component.checks ?? []).find( + (check): check is PlanCheckEntry & { range_mm3?: unknown; target?: unknown } => + Boolean(check) && check.kind === "volume" && check.target === id, + ); + if (!volume) { + errors.push( + `component "${id}": checks must include a volume check targeting it, e.g. {"kind": "volume", "range_mm3": [lo, hi], "target": "${id}"} - derive the range (about ±10%) from the component's intended dimensions`, + ); + } else { + const range = Array.isArray(volume.range_mm3) ? (volume.range_mm3 as unknown[]) : undefined; + const lo = typeof range?.[0] === "number" ? (range[0] as number) : undefined; + const hi = typeof range?.[1] === "number" ? (range[1] as number) : undefined; + if (lo === undefined || hi === undefined || lo <= 0 || hi < lo) { + errors.push(`component "${id}": volume check needs range_mm3 [lo, hi] with 0 < lo <= hi`); + } else if (hi > VOLUME_RANGE_MAX_RATIO * lo) { + errors.push( + `component "${id}": volume range [${lo}, ${hi}] is too loose to catch topology mistakes; keep hi <= ${VOLUME_RANGE_MAX_RATIO}x lo (about ±10% around the derived volume)`, + ); + } + } + } + } + + for (const iface of interfaces) { + const label = `interface ${JSON.stringify(iface?.a)}/${JSON.stringify(iface?.b)}`; + if (!ids.has(iface?.a as string) || !ids.has(iface?.b as string)) { + errors.push(`${label}: endpoints must be component ids`); + continue; + } + if (iface.a === iface.b) errors.push(`${label}: endpoints must differ`); + if (!PLAN_INTERFACE_KINDS.includes(iface.kind)) { + errors.push(`${label}: unknown kind ${JSON.stringify(iface.kind)}`); + continue; + } + if (iface.kind === "clearance") { + if (typeof iface.min_mm !== "number" || iface.min_mm < 0) { + errors.push(`${label}: clearance requires min_mm >= 0`); + } else if (iface.max_mm !== undefined && (typeof iface.max_mm !== "number" || iface.max_mm < iface.min_mm)) { + errors.push(`${label}: max_mm must be a number >= min_mm`); + } + } + } + + // Interface coverage + connectivity (only meaningful with two or more components). + if (next.components.length >= 2) { + const covered = new Set(); + for (const iface of interfaces) { + if (ids.has(iface?.a as string)) covered.add(iface.a); + if (ids.has(iface?.b as string)) covered.add(iface.b); + } + const mustCover = next.components.filter( + (c) => ids.has(c.id) && c.status !== "abandoned" && !c.free_floating_reason?.trim(), + ); + for (const component of mustCover) { + if (!covered.has(component.id)) { + errors.push( + `component "${component.id}" is not held by any interface: add a clearance/captive interface, or set free_floating_reason to state why it is legitimately unattached`, + ); + } + } + // Union-find connectivity over the components that interfaces must hold together. + const parent = new Map(); + const find = (x: string): string => { + let root = x; + while (parent.get(root) !== root) root = parent.get(root) as string; + let cursor = x; + while (parent.get(cursor) !== root) { + const nextUp = parent.get(cursor) as string; + parent.set(cursor, root); + cursor = nextUp; + } + return root; + }; + for (const id of ids) parent.set(id, id); + for (const iface of interfaces) { + if (ids.has(iface?.a as string) && ids.has(iface?.b as string)) { + parent.set(find(iface.a), find(iface.b)); + } + } + const anchored = mustCover.filter((c) => covered.has(c.id)); + const roots = new Set(anchored.map((c) => find(c.id))); + if (roots.size > 1) { + errors.push( + "the interface graph is disconnected: every component must be reachable from every other through interfaces, or the assembly falls apart", + ); + } + } + + // No silent drops relative to the previous snapshot. + if (previous) { + for (const prev of previous.components) { + if (!ids.has(prev.id)) { + errors.push( + `component "${prev.id}" from the previous plan is missing: keep it (status "abandoned" with abandon_reason) instead of dropping it silently`, + ); + } + } + } + + // Evidence-checked done transitions. + for (const component of next.components) { + if (component.status !== "done" || !ids.has(component.id)) continue; + const record = evidence.get(component.id); + if (!record) { + errors.push( + `component "${component.id}" cannot be "done": no gate-passed run has declared COMPONENT = "${component.id}"`, + ); + continue; + } + for (const check of component.checks ?? []) { + if (!record.checks.has(canonical(check))) { + errors.push( + `component "${component.id}" cannot be "done": its planned check ${JSON.stringify(check)} was not part of the gate-passed run that declared it`, + ); + } + } + } + + return errors; +} + +/** + * Best-effort parse of a script's COMPONENT declaration for budget attribution. + * The harness echo (measurements.component) stays the trust anchor for evidence; + * this only decides which budget bucket a run drains before it executes. + * Accepts `COMPONENT = "lid"` and `COMPONENT = ["base", "lid"]` with either quote + * style; returns undefined when no declaration is found. + */ +/** Budget bucket for a run: a component id, "assembly" for multi-component runs, "probe", or "general". */ +export function runBudgetBucket(declaration: string[] | undefined): string { + if (!declaration || declaration.length === 0) return "general"; + if (declaration.length === 1) return declaration[0] as string; + return "assembly"; +} + +/** Compact single-line rendering for tool-result text and the stop-gate nudge. */ +export function describePlanStatus(plan: Plan): string { + const byStatus = new Map(); + for (const component of plan.components) { + byStatus.set(component.status, (byStatus.get(component.status) ?? 0) + 1); + } + const parts = PLAN_COMPONENT_STATUSES.filter((s) => byStatus.has(s)).map( + (s) => `${byStatus.get(s)} ${s}`, + ); + return `${plan.components.length} components (${parts.join(", ")}), ${plan.interfaces?.length ?? 0} interfaces`; +} + +/** Type guard used by UI and tests. */ +export function isPlanToolResult(message: unknown): message is AgentMessage & { details: { plan: Plan } } { + const m = message as ToolResultLike; + return ( + m?.role === "toolResult" && + m.toolName === UPDATE_PLAN_TOOL_NAME && + !m.isError && + typeof m.details?.plan === "object" && + m.details?.plan !== null + ); +} diff --git a/packages/client/src/agent/planChecks.ts b/packages/client/src/agent/planChecks.ts new file mode 100644 index 0000000..e339211 --- /dev/null +++ b/packages/client/src/agent/planChecks.ts @@ -0,0 +1,128 @@ +import { Type, type Static } from "@earendil-works/pi-ai"; + +const positive = Type.Number({ exclusiveMinimum: 0 }); +const nonNegative = Type.Number({ minimum: 0 }); +const target = Type.Optional(Type.String({ minLength: 1 })); +const count = Type.Union([ + Type.Integer({ minimum: 0 }), + Type.Tuple([Type.Integer({ minimum: 0 }), Type.Integer({ minimum: 0 })]), +]); + +export const PLAN_CHECK_ENTRY_SCHEMA = Type.Union([ + Type.Object( + { + kind: Type.Union([Type.Literal("hole_through"), Type.Literal("hole_blind"), Type.Literal("hole_internal")]), + diameter: positive, + count: Type.Integer({ minimum: 0 }), + tol: Type.Optional(positive), + target, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("clearance"), + a: Type.String({ minLength: 1 }), + b: Type.String({ minLength: 1 }), + min_mm: nonNegative, + max_mm: Type.Optional(nonNegative), + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("bbox"), + size_mm: Type.Tuple([positive, positive, positive]), + tol: Type.Optional(positive), + target, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("volume"), + range_mm3: Type.Tuple([Type.Number(), Type.Number()]), + target, + }, + { additionalProperties: false }, + ), + Type.Object( + { kind: Type.Union([Type.Literal("count_faces"), Type.Literal("count_edges")]), count, target }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("symmetric"), + plane: Type.Union([Type.Literal("XY"), Type.Literal("XZ"), Type.Literal("YZ")]), + tol_pct: Type.Optional(positive), + }, + { additionalProperties: false }, + ), +]); + +export type PlanCheckEntry = Static; + +const CHECK_KEYS: Record = { + hole_through: { required: ["diameter", "count"], optional: ["tol", "target"] }, + hole_blind: { required: ["diameter", "count"], optional: ["tol", "target"] }, + hole_internal: { required: ["diameter", "count"], optional: ["tol", "target"] }, + clearance: { required: ["a", "b", "min_mm"], optional: ["max_mm"] }, + bbox: { required: ["size_mm"], optional: ["target", "tol"] }, + volume: { required: ["range_mm3"], optional: ["target"] }, + count_faces: { required: ["count"], optional: ["target"] }, + count_edges: { required: ["count"], optional: ["target"] }, + symmetric: { required: ["plane"], optional: ["tol_pct"] }, +}; + +function isNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function validCount(value: unknown, allowRange: boolean): boolean { + if (Number.isInteger(value) && (value as number) >= 0) return true; + return Boolean( + allowRange && + Array.isArray(value) && + value.length === 2 && + value.every((v) => Number.isInteger(v) && v >= 0) && + value[0] <= value[1], + ); +} + +/** Runtime mirror of the harness CHECKS contract, with user-facing plan errors. */ +export function validatePlanCheck(value: unknown): string[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return ["must be an object"]; + const check = value as Record; + const kind = check.kind; + if (typeof kind !== "string" || !(kind in CHECK_KEYS)) return [`unknown check kind ${JSON.stringify(kind)}`]; + const contract = CHECK_KEYS[kind] as { required: string[]; optional: string[] }; + const keys = Object.keys(check).filter((key) => key !== "kind"); + const missing = contract.required.filter((key) => !(key in check)); + const unknown = keys.filter((key) => !contract.required.includes(key) && !contract.optional.includes(key)); + const errors: string[] = []; + if (missing.length > 0) errors.push(`missing keys: ${JSON.stringify(missing)}`); + if (unknown.length > 0) errors.push(`unknown keys: ${JSON.stringify(unknown)}`); + if (missing.length > 0) return errors; + + if (kind.startsWith("hole_")) { + if (!isNumber(check.diameter) || check.diameter <= 0) errors.push("diameter must be a positive number"); + if (!validCount(check.count, false)) errors.push("count must be an integer >= 0"); + if (check.tol !== undefined && (!isNumber(check.tol) || check.tol <= 0)) errors.push("tol must be positive"); + } else if (kind === "clearance") { + if (typeof check.a !== "string" || !check.a || typeof check.b !== "string" || !check.b) errors.push("a and b must be non-empty strings"); + if (!isNumber(check.min_mm) || check.min_mm < 0) errors.push("min_mm must be a number >= 0"); + if (check.max_mm !== undefined && (!isNumber(check.max_mm) || !isNumber(check.min_mm) || check.max_mm < check.min_mm)) errors.push("max_mm must be a number >= min_mm"); + } else if (kind === "bbox") { + if (!Array.isArray(check.size_mm) || check.size_mm.length !== 3 || check.size_mm.some((v) => !isNumber(v) || v <= 0)) errors.push("size_mm must be three positive numbers"); + if (check.tol !== undefined && (!isNumber(check.tol) || check.tol <= 0)) errors.push("tol must be positive"); + } else if (kind === "volume") { + if (!Array.isArray(check.range_mm3) || check.range_mm3.length !== 2 || check.range_mm3.some((v) => !isNumber(v)) || (isNumber(check.range_mm3?.[0]) && isNumber(check.range_mm3?.[1]) && check.range_mm3[0] > check.range_mm3[1])) errors.push("range_mm3 must be [min, max] with min <= max"); + } else if (kind === "count_faces" || kind === "count_edges") { + if (!validCount(check.count, true)) errors.push("count must be an integer >= 0 or [min, max]"); + } else if (kind === "symmetric") { + if (!new Set(["XY", "XZ", "YZ"]).has(check.plane as string)) errors.push("plane must be XY, XZ, or YZ"); + if (check.tol_pct !== undefined && (!isNumber(check.tol_pct) || check.tol_pct <= 0)) errors.push("tol_pct must be positive"); + } + if (check.target !== undefined && (typeof check.target !== "string" || !check.target)) errors.push("target must be a non-empty string"); + return errors; +} diff --git a/packages/client/src/agent/prompt.ts b/packages/client/src/agent/prompt.ts index 8668781..f020d87 100644 --- a/packages/client/src/agent/prompt.ts +++ b/packages/client/src/agent/prompt.ts @@ -55,8 +55,8 @@ CHECKS = [ # --- end checks --- CHECKS must be one literal list of dicts. Available kinds: -- hole_through / hole_blind: diameter, count, optional tol (default 0.5) — counts detected cylindrical bores at that diameter. -- clearance: a, b (child labels), min_mm — minimum gap between two children; interpenetration always fails. +- hole_through / hole_blind / hole_internal: diameter, count, optional tol (default 0.5), optional target (child label) — counts detected cylindrical bores at that diameter. With target the census runs on that child alone, so a bore occupied or capped by a neighbouring part still classifies by the component's own geometry; hole_internal counts bores buried inside material at both ends. +- clearance: a, b (child labels), min_mm, optional max_mm — gap between two children; interpenetration always fails; max_mm 0 asserts touching, [min_mm, max_mm] asserts a controlled fit. - bbox: size_mm [x, y, z], optional target (child label), optional tol — sorted comparison like EXPECT. - volume: range_mm3 [min, max], optional target. - count_faces / count_edges: count (exact int or [min, max]), optional target. @@ -67,6 +67,29 @@ Give Compound children stable labels (part.label = "lid") so clearance, bbox, an A malformed checks block is a gate failure; omitting the block entirely is allowed only for trivially simple single-feature parts. Checks exist to catch your own mistakes: never weaken or delete a check to make the gate pass; change one only when it genuinely misread the request, and say so. +## Planning (multi-component designs) + +If the request contains two or more distinct components, or you expect more than one script to complete it, call update_plan BEFORE writing any geometry: restate the goal, list every component with its target bbox and CHECKS, and declare the interfaces that hold the assembly together. +Every component's checks MUST include a volume check targeting it ({"kind": "volume", "range_mm3": [lo, hi], "target": ""}) with a range about ±10% around the volume you derive from its intended dimensions (walls, floors, flanges, minus cavities and holes). +Volume is the cheapest topology detector: a cavity accidentally sealed shut, a missing pocket, or an over-deep cut shifts the measured volume immediately, while feature counts and bounding boxes stay silent. Do the arithmetic honestly - a range wide enough to cover both the right and the wrong topology is rejected. +Decompose along the interfaces: decide the mating dimensions, shared datums, and clearances first, define them once as named parameters, and derive every component from them. +Every component must be located and retained by something - contact, fastener, or captivity; if the user's request leaves a part unsupported, say so and ask instead of building it floating. +Build one component at a time: declare the component in the script (see COMPONENT below), pass its planned checks through the gate, then record the progress by calling update_plan with that component marked done. +Marking done is evidence-checked - it is accepted only after a gate-passed run declared the component and ran its planned checks - so build the evidence first; never try to talk a component into being finished. +Revise the plan when the decomposition genuinely changes, but never delete or shrink an unfinished component to escape a failing build: abandoning one requires an explicit reason the user will see. +Finish with an assembly script that declares all components, labels the Compound children, and whose CHECKS contain each plan interface's clearance entry verbatim ({"kind": "clearance", "a": ..., "b": ..., "min_mm": ..., "max_mm": ...} exactly as planned): the plan only counts as finished once one gate-passed run declared all components AND ran every interface check. + +Declare which plan component a script builds with a component block after the checks block: + +# --- component --- +COMPONENT = "lid" +# --- end component --- + +Use the component id from the plan; an assembly script lists all of them (COMPONENT = ["base", "lid"]). +Label the geometry with the same id (result.label = "base" on a single-component run, part.label on Compound children) so the component's targeted checks resolve identically in both contexts. +For a diagnostic run that only probes behavior and is not a deliverable, declare COMPONENT = "probe": probe runs never advance the plan, never replace the current model shown to the user, and do not count against a component's run budget. +Simple single-part requests need no plan and no component block; everything behaves as before. + ## Allowed API Surface Use build123d plus Python standard library modules only when needed for arithmetic or small helper functions. @@ -115,8 +138,10 @@ Use millimetres unless the user explicitly requests another unit. Preserve the user's requested coordinate system and orientation. Prefer robust, explicit build123d operations over visually guessed meshes. Extend subtractive tools slightly through the target to avoid coincident faces. +Keep subtractive tools scoped to the feature they cut: a bore that only needs to pass through two walls must not also gouge flanges, seals, or unrelated faces on its way through. Use Compound only when the requested object contains distinct non-fused components. Otherwise return a single fused Part or Shape. +In an assembly, every component must be physically held: resting contact, fasteners through aligned holes, or captive placement. A part floating in space with no interface to its neighbors is a defect even when its own dimensions are perfect. ## Verification Discipline @@ -128,9 +153,11 @@ For every successful run: - Inspect every view one at a time: isometric, front, back, left, right, top, and bottom. - Compare bboxMm, volumeMm3, areaMm2, and child measurements against the requested dimensions and component count. - Read the diagnostics in measurements: topology (face/edge/vertex/shell counts), holes (every detected bore with diameter, depth, and through/blind/internal classification), and clearances (pairwise child states). A hole the user wants to go through must report kind "through"; interpenetrating children are a defect unless the user asked for fused geometry. +- If measurements list a "floating" entry, those children touch nothing: an unsupported part is a defect unless the user explicitly wants it detached — fix the geometry or ask, never ignore it. - Numerically check each requested width, height, depth, diameter, radius, wall thickness, offset, spacing, count, and angle that can be inferred from the returned measurements. - Check visible topology: holes are open, counterbores are on the correct face, fillets and chamfers affect the intended edges, booleans did not leave extra blocks, and mirrored or repeated features are symmetric. - Before rewriting, briefly state concrete discrepancies such as missing features, wrong orientation, incorrect proportions, interference, asymmetric placement, or numeric mismatch. +- Only claim what the cited evidence can actually show: a view cannot confirm a feature another part occludes (a lid hides the cavity under it), and a loose volume range cannot confirm topology. When a requested feature is hidden in the assembly, verify it with a per-component run (COMPONENT-declared) where it is visible and measurable. - Then submit a complete corrected script, not a patch or fragment. Every run_build123d result includes a verify-gate verdict covering EXPECT, B-rep validity, and your CHECKS. diff --git a/packages/client/src/agent/session.test.ts b/packages/client/src/agent/session.test.ts index 48b556f..6b63756 100644 --- a/packages/client/src/agent/session.test.ts +++ b/packages/client/src/agent/session.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai"; import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; import * as rest from "../api/rest"; -import { classifySessionError, createSession, SELF_CHECK_MARKER, type SessionError, type SessionState } from "./session"; +import { + classifySessionError, + createSession, + PLAN_NUDGE_MARKER, + SELF_CHECK_MARKER, + type SessionError, + type SessionState, +} from "./session"; import { systemPrompt } from "./prompt"; vi.mock("../api/rest", () => ({ @@ -922,4 +929,309 @@ describe("createSession agent-loop policies", () => { ); expect(imagesInTranscript).toHaveLength(9); }); + + // --- plan enforcement --- + + function acceptedPlanResult( + components: { id: string; status: string }[], + interfaces: object[] = [], + ): unknown { + return { + role: "toolResult", + toolCallId: "plan-1", + toolName: "update_plan", + content: [{ type: "text", text: "Plan accepted" }], + details: { + plan: { + goal: "test goal", + components: components.map((c) => ({ ...c, description: c.id })), + interfaces, + }, + }, + isError: false, + timestamp: 1, + }; + } + + function toolCallWithCode(id: string, code: string): AssistantMessage { + return { + ...textMessage("", "toolUse"), + content: [{ type: "toolCall", id, name: "run_build123d", arguments: { code } }], + }; + } + + function countMarker(state: SessionState | undefined, marker: string): number { + return (state?.messages ?? []).filter((m) => { + const message = m as { role?: string; content?: { text?: string }[] }; + return ( + message.role === "user" && + Array.isArray(message.content) && + Boolean(message.content[0]?.text?.startsWith(marker)) + ); + }).length; + } + + it("reports an incomplete plan when the one allowed nudge is ignored", async () => { + const { streamFn, turnContexts } = makeScriptedStreamFn([ + textMessage("planned enough, stopping."), + textMessage("still stopping without running anything."), + ]); + + const session = createSession({ + conversationId: "conv-1", + modelJson: JSON.stringify(FAKE_MODEL), + systemPrompt, + priorMessages: [acceptedPlanResult([{ id: "base", status: "todo" }])], + __streamFn: streamFn as never, + } as unknown as Parameters[0]); + + let latest: SessionState | undefined; + session.subscribe((state) => (latest = state)); + await session.send("build it"); + + // stop -> nudge -> stop; the second stop must NOT nudge again (no run in between). + expect(turnContexts).toHaveLength(2); + expect(countMarker(latest, PLAN_NUDGE_MARKER)).toBe(1); + expect(countMarker(latest, SELF_CHECK_MARKER)).toBe(0); + expect(latest?.error?.message).toMatch(/stopped with unfinished plan work/i); + }); + + it("re-arms the plan nudge after an intervening run_build123d call", async () => { + const { tool } = gateTool("passed"); + const { streamFn, turnContexts } = makeScriptedStreamFn([ + textMessage("stopping early."), + toolCallMessage("call-1"), + textMessage("ran once, stopping again."), + textMessage("final stop."), + ]); + + const session = createSession({ + conversationId: "conv-1", + modelJson: JSON.stringify(FAKE_MODEL), + systemPrompt, + tools: [tool], + priorMessages: [acceptedPlanResult([{ id: "base", status: "todo" }])], + __streamFn: streamFn as never, + } as unknown as Parameters[0]); + + let latest: SessionState | undefined; + session.subscribe((state) => (latest = state)); + await session.send("build it"); + + // stop -> nudge -> run+stop -> nudge -> stop (no third nudge). + expect(turnContexts).toHaveLength(4); + expect(countMarker(latest, PLAN_NUDGE_MARKER)).toBe(2); + }); + + it("falls back to the prose self-check when the plan is complete", async () => { + const { tool } = gateTool("passed"); + const { streamFn } = makeScriptedStreamFn([ + toolCallMessage("call-1"), + textMessage("gate passed, stopping."), + textMessage("final summary."), + ]); + + const session = createSession({ + conversationId: "conv-1", + modelJson: JSON.stringify(FAKE_MODEL), + systemPrompt, + tools: [tool], + priorMessages: [acceptedPlanResult([{ id: "base", status: "done" }])], + __streamFn: streamFn as never, + } as unknown as Parameters[0]); + + let latest: SessionState | undefined; + session.subscribe((state) => (latest = state)); + await session.send("build it"); + + expect(countMarker(latest, PLAN_NUDGE_MARKER)).toBe(0); + expect(countMarker(latest, SELF_CHECK_MARKER)).toBe(1); + }); + + it("demands an assembly run when every component is done but the interfaces have no evidence", async () => { + const { streamFn, turnContexts } = makeScriptedStreamFn([ + textMessage("both components done, wrapping up."), + textMessage("still not running the assembly."), + ]); + + const session = createSession({ + conversationId: "conv-1", + modelJson: JSON.stringify(FAKE_MODEL), + systemPrompt, + priorMessages: [ + acceptedPlanResult( + [ + { id: "base", status: "done" }, + { id: "lid", status: "done" }, + ], + [{ a: "base", b: "lid", kind: "clearance", min_mm: 0, max_mm: 0 }], + ), + ], + __streamFn: streamFn as never, + } as unknown as Parameters[0]); + + let latest: SessionState | undefined; + session.subscribe((state) => (latest = state)); + await session.send("finish it"); + + expect(turnContexts).toHaveLength(2); + expect(countMarker(latest, PLAN_NUDGE_MARKER)).toBe(1); + const nudge = (latest?.messages ?? []).find((m) => + JSON.stringify(m).includes("interfaces are unverified"), + ); + expect(nudge).toBeDefined(); + }); + + it("skips the assembly nudge once a gate-passed run declared all components", async () => { + const { tool } = gateTool("passed"); + const assemblyEvidence = { + role: "toolResult", + toolCallId: "asm-1", + toolName: "run_build123d", + content: [{ type: "text", text: "ran" }], + details: { + gate: { status: "passed", checks: [] }, + measurements: { + component: ["base", "lid"], + // Assembly evidence requires the interface's clearance check to have + // actually run; declaring the components alone is not enough. + checks: [{ kind: "clearance", a: "base", b: "lid", min_mm: 0, max_mm: 0 }], + }, + }, + isError: false, + timestamp: 2, + }; + const { streamFn } = makeScriptedStreamFn([ + toolCallMessage("call-1"), + textMessage("assembly verified, done."), + textMessage("final summary."), + ]); + + const session = createSession({ + conversationId: "conv-1", + modelJson: JSON.stringify(FAKE_MODEL), + systemPrompt, + tools: [tool], + priorMessages: [ + acceptedPlanResult( + [ + { id: "base", status: "done" }, + { id: "lid", status: "done" }, + ], + [{ a: "base", b: "lid", kind: "clearance", min_mm: 0, max_mm: 0 }], + ), + assemblyEvidence, + ], + __streamFn: streamFn as never, + } as unknown as Parameters[0]); + + let latest: SessionState | undefined; + session.subscribe((state) => (latest = state)); + await session.send("finish it"); + + expect(countMarker(latest, PLAN_NUDGE_MARKER)).toBe(0); + expect(countMarker(latest, SELF_CHECK_MARKER)).toBe(1); + }); + + it("with an active plan, budgets runs per component bucket and aborts the bucket's overflow run", async () => { + const { tool, execute } = gateTool("passed"); + const baseRun = (i: number) => toolCallWithCode(`call-${i}`, `COMPONENT = "base"\nresult = Box(1, 1, ${i})`); + const { streamFn } = makeScriptedStreamFn([ + baseRun(1), + baseRun(2), + baseRun(3), + textMessage("should have been aborted"), + ]); + + const session = createSession({ + conversationId: "conv-1", + modelJson: JSON.stringify(FAKE_MODEL), + systemPrompt, + tools: [tool], + priorMessages: [acceptedPlanResult([{ id: "base", status: "todo" }])], + maxCadRuns: 2, + __streamFn: streamFn as never, + } as unknown as Parameters[0]); + + let latest: SessionState | undefined; + session.subscribe((state) => (latest = state)); + await session.send("build it"); + + expect(execute).toHaveBeenCalledTimes(2); + expect(latest?.error?.message).toContain('Stopped after 2 CAD runs for plan component "base"'); + }); + + it("probe runs drain only the global ceiling, never a component bucket", async () => { + const { tool, execute } = gateTool("passed"); + const { streamFn } = makeScriptedStreamFn([ + toolCallWithCode("call-1", 'COMPONENT = "probe"\nresult = Box(1, 1, 1)'), + toolCallWithCode("call-2", 'COMPONENT = "base"\nresult = Box(1, 1, 2)'), + toolCallWithCode("call-3", 'COMPONENT = "base"\nresult = Box(1, 1, 3)'), + textMessage("done"), + ]); + + const session = createSession({ + conversationId: "conv-1", + modelJson: JSON.stringify(FAKE_MODEL), + systemPrompt, + tools: [tool], + priorMessages: [acceptedPlanResult([{ id: "base", status: "todo" }])], + maxCadRuns: 1, + __streamFn: streamFn as never, + } as unknown as Parameters[0]); + + let latest: SessionState | undefined; + session.subscribe((state) => (latest = state)); + await session.send("build it"); + + // Probe + one base run execute; the second base run trips the bucket (1). + expect(execute).toHaveBeenCalledTimes(2); + expect(latest?.error?.message).toContain('plan component "base"'); + }); + + it("registers update_plan so the agent can create a plan, and the accepted snapshot persists in the tool result", async () => { + const plan = { + goal: "single spacer", + components: [ + { + id: "spacer", + description: "a spacer", + bbox_mm: [10, 10, 10], + status: "todo", + free_floating_reason: "single part", + checks: [{ kind: "volume", range_mm3: [900, 1100], target: "spacer" }], + }, + ], + interfaces: [], + }; + const { streamFn } = makeScriptedStreamFn([ + { + ...textMessage("", "toolUse"), + content: [{ type: "toolCall", id: "plan-call", name: "update_plan", arguments: plan }], + }, + textMessage("planned, stopping."), + textMessage("continuing after nudge."), + ]); + + const session = createSession({ + conversationId: "conv-1", + modelJson: JSON.stringify(FAKE_MODEL), + systemPrompt, + priorMessages: [], + __streamFn: streamFn as never, + } as unknown as Parameters[0]); + + let latest: SessionState | undefined; + session.subscribe((state) => (latest = state)); + await session.send("make a spacer"); + + const planResults = (latest?.messages ?? []).filter((m) => { + const message = m as { role?: string; toolName?: string; isError?: boolean; details?: { plan?: unknown } }; + return message.role === "toolResult" && message.toolName === "update_plan" && !message.isError; + }); + expect(planResults).toHaveLength(1); + expect((planResults[0] as { details: { plan: { goal: string } } }).details.plan.goal).toBe("single spacer"); + // The new plan is live immediately: stopping with a todo component draws the nudge. + expect(countMarker(latest, PLAN_NUDGE_MARKER)).toBe(1); + }); }); diff --git a/packages/client/src/agent/session.ts b/packages/client/src/agent/session.ts index 8a44749..ea453ff 100644 --- a/packages/client/src/agent/session.ts +++ b/packages/client/src/agent/session.ts @@ -5,6 +5,15 @@ import * as rest from "../api/rest"; import { transformLlmContext } from "./contextPolicy"; import { runCompaction } from "./compaction"; import { withStreamRetry, type StreamRetryOptions } from "./retryStream"; +import { + PROBE_COMPONENT, + hasAssemblyEvidence, + latestPlan, + parseComponentDeclaration, + planIncompleteComponents, + runBudgetBucket, +} from "./plan"; +import { createUpdatePlanTool } from "./tools/updatePlan"; export interface ChatSession { conversationId: string; @@ -94,6 +103,19 @@ export const DEFAULT_MAX_CAD_RUNS = 10; export const SELF_CHECK_MARKER = "[Chamfer self-check]"; export const SELF_CHECK_PROMPT = `${SELF_CHECK_MARKER} The verify gate passed for the current script. A passing gate only confirms the current geometry matches its own EXPECT block - it does not mean the whole request is done. Re-read the original request, list every requested part, feature, and step, and mark each one satisfied or missing against the latest measurements and views. If anything is missing, continue building it now. If everything is satisfied, reply with the final summary.`; + +/** Prefix identifying the deterministic plan stop-gate nudge (planned turns replace + * the prose self-check with this; the UI renders it as a system chip). */ +export const PLAN_NUDGE_MARKER = "[Chamfer plan check]"; + +/** With an active plan, the per-turn ceiling is this multiple of maxCadRuns; each + * component bucket individually stays within maxCadRuns. */ +export const PLAN_BUDGET_CEILING_FACTOR = 3; + +export function buildPlanNudgePrompt(incomplete: readonly { id: string; status: string }[]): string { + const list = incomplete.map((c) => `"${c.id}" (${c.status})`).join(", "); + return `${PLAN_NUDGE_MARKER} The plan still has unfinished components: ${list}. A component only counts as done after a gate-passed run declares it via COMPONENT and passes its planned checks, and update_plan records it. Continue with the next unfinished component now, or - only if the request genuinely changed - revise the plan with update_plan and state why. Do not stop while the plan has unfinished components and budget remains.`; +} const persistenceIds = new WeakMap(); export function registerMessagePersistenceId(message: unknown, id: string): void { @@ -165,17 +187,25 @@ export function createSession(opts: CreateSessionOptions): ChatSession { }, }); + // update_plan validates against the live transcript (latest plan + gate evidence), + // so it is session-owned: the closure resolves to the agent created just below. + let agentForPlanTool: Agent | undefined; + const planTool = createUpdatePlanTool({ + getMessages: () => agentForPlanTool?.state.messages ?? [], + }) as unknown as AgentTool; + const agent = new Agent({ initialState: { systemPrompt: opts.systemPrompt, model, - tools: (opts.tools ?? []) as AgentTool[], + tools: [...((opts.tools ?? []) as AgentTool[]), planTool], }, streamFn, // The persisted transcript is the source of truth; what the model sees is the // policy-transformed view (stale view sheets stubbed, compacted history windowed). transformContext: async (messages) => transformLlmContext(messages), }); + agentForPlanTool = agent; agent.state.messages = priorMessages; let nextSeq = priorMessages.length; @@ -265,6 +295,13 @@ export function createSession(opts: CreateSessionOptions): ChatSession { // gate pass, nudging it to verify the WHOLE request is satisfied, not just the gate. let gatePassedThisTurn = false; let selfCheckArmed = false; + // Plan enforcement. With an active plan the budget is per component bucket (the + // COMPONENT declaration parsed from the script; probe runs drain only the global + // ceiling), and stopping with unfinished components triggers one deterministic + // follow-up. `planNudgedWithoutRun` guarantees a nudge is never injected twice + // without an intervening run_build123d call. + const cadRunsByBucket = new Map(); + let planNudgedWithoutRun = false; agent.subscribe((event: AgentEvent) => { if ( @@ -279,21 +316,48 @@ export function createSession(opts: CreateSessionOptions): ChatSession { event.type === "turn_end" && event.message.role === "assistant" && !event.message.errorMessage && - selfCheckArmed && - gatePassedThisTurn && !cadRunLimitReached && Array.isArray(event.message.content) && !event.message.content.some((block) => (block as { type?: string })?.type === "toolCall") && !agent.hasQueuedMessages() ) { - // The agent would stop here. Inject exactly one follow-up (pi drains the + // The agent would stop here. Inject at most one follow-up (pi drains the // follow-up queue only when the agent would otherwise end the run). - selfCheckArmed = false; - agent.followUp({ - role: "user", - content: [{ type: "text", text: SELF_CHECK_PROMPT }], - timestamp: Date.now(), - } as AgentMessage); + const activePlan = latestPlan(agent.state.messages); + const incomplete = activePlan ? planIncompleteComponents(activePlan) : []; + const missingAssembly = + activePlan !== undefined && + incomplete.length === 0 && + !hasAssemblyEvidence(activePlan, agent.state.messages); + if ((incomplete.length > 0 || missingAssembly) && !planNudgedWithoutRun) { + // Deterministic stop-gate: the plan of record says work remains - either + // unfinished components, or interfaces nobody has measured because no + // gate-passed run declared all components together. Never fires twice + // without an intervening run_build123d call. + planNudgedWithoutRun = true; + const text = + incomplete.length > 0 + ? buildPlanNudgePrompt(incomplete) + : `${PLAN_NUDGE_MARKER} Every component is done, but the interfaces are unverified: no gate-passed run has declared ALL components together. Build the assembly script (COMPONENT lists every component, Compound children labeled, interface clearance checks included) and run it before finishing.`; + agent.followUp({ + role: "user", + content: [{ type: "text", text }], + timestamp: Date.now(), + } as AgentMessage); + } else if ((incomplete.length > 0 || missingAssembly) && planNudgedWithoutRun) { + lastError = { + kind: "generic", + message: + "Stopped with unfinished plan work after the agent ignored the plan check. Continue the conversation to resume the build.", + }; + } else if (incomplete.length === 0 && !missingAssembly && selfCheckArmed && gatePassedThisTurn) { + selfCheckArmed = false; + agent.followUp({ + role: "user", + content: [{ type: "text", text: SELF_CHECK_PROMPT }], + timestamp: Date.now(), + } as AgentMessage); + } } if (event.type === "message_end") { const seq = nextSeq; @@ -318,7 +382,33 @@ export function createSession(opts: CreateSessionOptions): ChatSession { } if (event.type === "tool_execution_start" && event.toolName === "run_build123d") { cadRunsThisTurn += 1; - if (cadRunsThisTurn > maxCadRuns) { + planNudgedWithoutRun = false; + const activePlan = latestPlan(agent.state.messages); + if (activePlan) { + // Per-component budget under a global ceiling. Probe runs are diagnostics: + // they drain only the ceiling, never a component bucket. + const declaration = parseComponentDeclaration( + typeof (event.args as { code?: unknown })?.code === "string" ? (event.args as { code: string }).code : "", + ); + const ceiling = PLAN_BUDGET_CEILING_FACTOR * maxCadRuns; + const isProbe = declaration?.length === 1 && declaration[0] === PROBE_COMPONENT; + let exceeded: string | undefined; + if (cadRunsThisTurn > ceiling) { + exceeded = `Stopped after ${ceiling} CAD runs in one turn (plan ceiling of ${PLAN_BUDGET_CEILING_FACTOR}x ${maxCadRuns}).`; + } else if (!isProbe) { + const bucket = runBudgetBucket(declaration); + const used = (cadRunsByBucket.get(bucket) ?? 0) + 1; + cadRunsByBucket.set(bucket, used); + if (used > maxCadRuns) { + exceeded = `Stopped after ${maxCadRuns} CAD runs for plan component "${bucket}" in one turn.`; + } + } + if (exceeded) { + cadRunLimitReached = true; + lastError = { kind: "generic", message: exceeded }; + agent.abort(); + } + } else if (cadRunsThisTurn > maxCadRuns) { cadRunLimitReached = true; lastError = { kind: "generic", message: `Stopped after ${maxCadRuns} CAD runs in one turn.` }; agent.abort(); @@ -334,6 +424,8 @@ export function createSession(opts: CreateSessionOptions): ChatSession { cadRunLimitReached = false; gatePassedThisTurn = false; selfCheckArmed = true; + cadRunsByBucket.clear(); + planNudgedWithoutRun = false; // Compaction runs between turns, before the prompt: when the LLM-visible context // is near the window, older history is summarized into a persisted compaction // row. Failures are non-fatal - the turn proceeds on the uncompacted context. diff --git a/packages/client/src/agent/tools/updatePlan.ts b/packages/client/src/agent/tools/updatePlan.ts new file mode 100644 index 0000000..2331fe7 --- /dev/null +++ b/packages/client/src/agent/tools/updatePlan.ts @@ -0,0 +1,103 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { StringEnum, Type } from "@earendil-works/pi-ai"; +import { + PLAN_COMPONENT_STATUSES, + PLAN_INTERFACE_KINDS, + UPDATE_PLAN_TOOL_NAME, + collectComponentEvidence, + describePlanStatus, + latestPlan, + validatePlanSnapshot, + type Plan, +} from "../plan"; +import { PLAN_CHECK_ENTRY_SCHEMA } from "../planChecks"; + +const component = Type.Object({ + id: Type.String({ + description: + 'Stable lowercase slug (e.g. "lid"). Must equal the Compound child label and the script COMPONENT declaration; "probe" is reserved.', + }), + description: Type.String({ description: "What this component is, in one sentence." }), + bbox_mm: Type.Array(Type.Number(), { + minItems: 3, + maxItems: 3, + description: "Target envelope in mm, sorted-compare semantics like EXPECT.bbox_mm.", + }), + checks: Type.Array(PLAN_CHECK_ENTRY_SCHEMA, { + description: + "CHECKS entries this component must pass. A gate-passed run declaring the component must include every one of them before the component can be marked done.", + }), + status: StringEnum(PLAN_COMPONENT_STATUSES as unknown as string[], { + description: + '"done" is accepted only with gate evidence; "abandoned" requires abandon_reason and is the only legal way to shrink the plan.', + }), + abandon_reason: Type.Optional( + Type.String({ description: "Why this component is no longer part of the design. Required when abandoned." }), + ), + free_floating_reason: Type.Optional( + Type.String({ + description: + "Why this component legitimately has no interface holding it. Leave unset for anything that must be supported, fastened, or captive.", + }), + ), +}); + +const planInterface = Type.Object({ + a: Type.String({ description: "Component id." }), + b: Type.String({ description: "Component id." }), + kind: StringEnum(PLAN_INTERFACE_KINDS as unknown as string[], { + description: + '"clearance" bounds the gap between two components (min_mm 0 allows contact; max_mm 0 demands touching). "captive" declares retention without contact, e.g. a pin inside bores.', + }), + min_mm: Type.Optional(Type.Number({ description: "Minimum gap in mm (clearance only), >= 0." })), + max_mm: Type.Optional(Type.Number({ description: "Maximum gap in mm (clearance only); 0 asserts touching." })), +}); + +const parameters = Type.Object({ + goal: Type.String({ description: "One-sentence restatement of the user's request." }), + components: Type.Array(component, { minItems: 1 }), + interfaces: Type.Array(planInterface, { + description: + "Physical relations that hold the assembly together. Every non-free-floating component must appear in at least one, and the graph must be connected.", + }), +}); + +/** + * The plan artifact tool. Always submits a FULL snapshot; the newest accepted + * snapshot is the plan of record (derived from the transcript, so it survives + * reloads and compaction). Validation implements the trust model in plan.ts; + * a rejected snapshot throws, which pi reports to the model as a tool error. + */ +export function createUpdatePlanTool(deps: { + /** Live view of the session transcript (agent.state.messages). */ + getMessages: () => readonly unknown[]; +}): AgentTool { + return { + name: UPDATE_PLAN_TOOL_NAME, + label: "Update plan", + description: + "Create or revise the design plan for a multi-component request: the component list, per-component checks, and the interfaces that hold the assembly together. Submit the complete plan every time (never a delta). Call it before writing any geometry when the request has two or more distinct components or needs more than one script, and again whenever the decomposition changes or a component is finished.", + parameters, + execute: async (_toolCallId, args) => { + const messages = deps.getMessages(); + const next = args as unknown as Plan; + const errors = validatePlanSnapshot({ + next, + previous: latestPlan(messages), + evidence: collectComponentEvidence(messages), + }); + if (errors.length > 0) { + throw new Error(`Plan rejected:\n${errors.map((e) => `- ${e}`).join("\n")}`); + } + return { + content: [ + { + type: "text", + text: `Plan accepted: ${describePlanStatus(next)}.\n${JSON.stringify(next)}`, + }, + ], + details: { plan: next }, + }; + }, + }; +} diff --git a/packages/client/src/components/ChatPanel.tsx b/packages/client/src/components/ChatPanel.tsx index 5ea96ab..12885ec 100644 --- a/packages/client/src/components/ChatPanel.tsx +++ b/packages/client/src/components/ChatPanel.tsx @@ -6,8 +6,10 @@ import { Composer } from "./Composer"; import { ErrorBanner } from "./ErrorBanner"; import { PresetPrompts } from "./PresetPrompts"; import { VerificationChip } from "./VerificationChip"; +import { PlanCard } from "./PlanCard"; import { latestGateSummary } from "@/agent/gateSummary"; -import { SELF_CHECK_MARKER } from "@/agent/session"; +import { latestPlan } from "@/agent/plan"; +import { PLAN_NUDGE_MARKER, SELF_CHECK_MARKER } from "@/agent/session"; import { useChatState } from "@/state/chatState"; const SETTINGS_HINT = "Configure a model and API key in Settings to start chatting."; @@ -41,9 +43,9 @@ function lastUserMessageText(messages: unknown[]): string | undefined { ) as { text?: string } | undefined; text = textBlock?.text; } - // Injected self-check nudges are user-role messages but not the user's prompt; - // retrying one would just re-ask the agent to checklist itself. - if (text?.startsWith(SELF_CHECK_MARKER)) continue; + // Injected self-check and plan nudges are user-role messages but not the user's + // prompt; retrying one would just re-ask the agent to checklist itself. + if (text?.startsWith(SELF_CHECK_MARKER) || text?.startsWith(PLAN_NUDGE_MARKER)) continue; return text; } return undefined; @@ -180,6 +182,7 @@ export function ChatPanel({ onOpenSettings }: ChatPanelProps) { const disabledHint = !settingsPresent ? SETTINGS_HINT : undefined; const conversationTitle = conversations.find((c) => c.id === activeConversationId)?.title; + const plan = latestPlan(sessionState.messages); return (
@@ -190,6 +193,7 @@ export function ChatPanel({ onOpenSettings }: ChatPanelProps) { summary={latestGateSummary(sessionState.messages)} />
+ {plan && } {providerErrorBanner} {sessionState.error && ( ); } + if (isUser && text.startsWith(PLAN_NUDGE_MARKER)) { + return ( +
+ + +
+ ); + } const toolCalls = Array.isArray(message.content) ? message.content.filter(isToolCallBlock) : []; // User image blocks are persisted verbatim in contentJson (base64 included), so // replayed messages render straight from the content blocks; no attachment fetch. diff --git a/packages/client/src/components/PlanCard.test.tsx b/packages/client/src/components/PlanCard.test.tsx new file mode 100644 index 0000000..54dee27 --- /dev/null +++ b/packages/client/src/components/PlanCard.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PlanCard } from "./PlanCard"; +import type { Plan } from "@/agent/plan"; + +const plan: Plan = { + goal: "two-part gearbox housing", + components: [ + { id: "base", description: "housing base", status: "done" }, + { id: "lid", description: "flat lid", status: "building" }, + { id: "rib", description: "stiffening rib", status: "abandoned", abandon_reason: "user removed it" }, + ], + interfaces: [{ a: "base", b: "lid", kind: "clearance", min_mm: 0, max_mm: 0 }], +}; + +describe("PlanCard", () => { + it("collapsed: shows progress over non-abandoned components and the goal", () => { + render(); + expect(screen.getByTestId("plan-progress").textContent).toBe("1/2 components"); + expect(screen.getByTestId("plan-card").textContent).toContain("two-part gearbox housing"); + expect(screen.queryAllByTestId("plan-component")).toHaveLength(0); + }); + + it("expanded: lists every component with status, abandon reason, and the interfaces", () => { + render(); + fireEvent.click(screen.getByTestId("plan-card-toggle")); + + const components = screen.getAllByTestId("plan-component"); + expect(components).toHaveLength(3); + expect(components.map((c) => c.dataset.status)).toEqual(["done", "building", "abandoned"]); + expect(screen.getByTestId("plan-abandon-reason").textContent).toContain("user removed it"); + const iface = screen.getByTestId("plan-interface"); + expect(iface.textContent).toContain("base·lid"); + expect(iface.textContent).toContain("≤0mm"); + }); +}); diff --git a/packages/client/src/components/PlanCard.tsx b/packages/client/src/components/PlanCard.tsx new file mode 100644 index 0000000..2b3c46d --- /dev/null +++ b/packages/client/src/components/PlanCard.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { Ban, Check, ChevronDown, ChevronRight, Circle, Hammer, Link2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { Plan, PlanComponent } from "@/agent/plan"; + +function StatusIcon({ status }: { status: PlanComponent["status"] }) { + if (status === "done") return