From 96a0cccf51f2a5caa618ee60bd3555581006d598 Mon Sep 17 00:00:00 2001 From: Eli Powell Date: Sun, 12 Jul 2026 22:03:12 -0400 Subject: [PATCH] Add bounded Level power-plan pilot --- .../aec_level_power_plan_runtime.ts | 271 ++++++++++++++++++ .../aec_scope_work_package_runtime.ts | 11 +- .../aec_scope_work_package_runtime.test.ts | 92 ++++-- 3 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 apps/operator-backend/src/deterministic/aec_level_power_plan_runtime.ts diff --git a/apps/operator-backend/src/deterministic/aec_level_power_plan_runtime.ts b/apps/operator-backend/src/deterministic/aec_level_power_plan_runtime.ts new file mode 100644 index 0000000..a0d0e7d --- /dev/null +++ b/apps/operator-backend/src/deterministic/aec_level_power_plan_runtime.ts @@ -0,0 +1,271 @@ +import type { ChatRequest, ChatResponse, ToolResult } from "../contracts.js"; +import type { AecSemanticTaskV1 } from "../aec_semantic_task.js"; +import { appendGoalProgress, getActiveGoalForSession, updateGoal } from "../goals/service.js"; + +type ResolvedView = { id: number; name: string; levelName: string | null }; +type Inventory = { view: ResolvedView; elementIds: number[]; inventoryActionId: string }; +type RuntimeState = { + task: AecSemanticTaskV1; + views: ResolvedView[]; + stage: "inventory" | "summary" | "frame"; + actionIds: string[]; + inventories: Inventory[]; + summaries: Map>>; + evidenceActionIds: string[]; +}; + +const states = new Map(); +const POWER_INSPECTION_CATEGORIES = ["OST_ElectricalFixtures", "OST_ElectricalEquipment", "OST_Wire"] as const; +const MAX_PILOT_VIEWS = 2; +const MAX_ELEMENTS_PER_VIEW = 500; + +function response(message: string, actions: ChatResponse["actions"] = []): ChatResponse { + return { version: "operator.backend.v1", assistant_message: message, actions }; +} + +function terminal(message: string, status: "complete" | "failed"): ChatResponse { + return { + ...response(message), + aec_query_receipt: { + schema: "revit-operator.aec-query-receipt.v1", + terminal: true, + status, + workflow_id: "query.level_power_plan_pilot", + bounded: true, + broadened: false + } + }; +} + +function object(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function text(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } + +function isPowerPlanTask(task: AecSemanticTaskV1): boolean { + return task.scope.kind === "level" && + task.operation === "layout" && + task.mutation.requested && + (task.subject.semantic_class === "electrical_equipment" || task.subject.semantic_class === "receptacle"); +} + +function actionId(viewId: number, suffix: string): string { return `aec-power-view-${viewId}-${suffix}`; } + +function inventoryActions(views: ResolvedView[]): ChatResponse["actions"] { + return views.map(view => ({ + action_id: actionId(view.id, "inventory"), + method: "POST" as const, + path: "/revit/find-elements", + body: { viewId: view.id, categories: [...POWER_INSPECTION_CATEGORIES], limit: MAX_ELEMENTS_PER_VIEW } + })); +} + +function matchingResults(req: ChatRequest, actionIds: string[]): ToolResult[] { + const wanted = new Set(actionIds); + return (req.tool_results ?? []).filter(result => wanted.has(result.action_id)); +} + +function exactPositiveIds(value: unknown): number[] | null { + if (!Array.isArray(value)) return null; + const ids: number[] = []; + for (const raw of value) { + if (!Number.isSafeInteger(raw) || (raw as number) <= 0) return null; + ids.push(raw as number); + } + return new Set(ids).size === ids.length ? ids : null; +} + +function parseInventory(view: ResolvedView, result: ToolResult): Inventory | null { + if (result.status !== "done" || result.path !== "/revit/find-elements") return null; + const payload = object(result.result_json); + const scope = object(payload?.scope); + const scopedViewIds = exactPositiveIds(scope?.viewIds); + const ids = exactPositiveIds(payload?.elementIds); + const resolvedCategories = Array.isArray(payload?.resolvedCategories) ? payload.resolvedCategories : []; + const resolvedTokens = new Set(resolvedCategories.map(row => text(object(row)?.builtInToken)).filter(Boolean)); + if (!payload || !ids || ids.length > MAX_ELEMENTS_PER_VIEW || payload.truncated === true || payload.categoryFilterApplied !== true) return null; + if (text(scope?.kind).toLocaleLowerCase() !== "view" || !scopedViewIds || scopedViewIds.length !== 1 || scopedViewIds[0] !== view.id) return null; + if (POWER_INSPECTION_CATEGORIES.some(category => !resolvedTokens.has(category))) return null; + return { view, elementIds: ids, inventoryActionId: result.action_id }; +} + +function summaryActions(inventories: Inventory[]): ChatResponse["actions"] { + return inventories.filter(item => item.elementIds.length > 0).map(item => ({ + action_id: actionId(item.view.id, "summary"), + method: "POST" as const, + path: "/revit/get-element-summary", + body: { viewId: item.view.id, elementIds: item.elementIds } + })); +} + +function frameActions(views: ResolvedView[]): ChatResponse["actions"] { + return views.map(view => ({ + action_id: actionId(view.id, "frame"), + method: "POST" as const, + path: "/revit/export-view-frame", + body: { viewId: view.id, imageSize: 1600, includeMapping: true } + })); +} + +function categoryCounts(rows: Array>): Record { + const counts: Record = {}; + for (const row of rows) { + const category = text(row.category) || "Uncategorized"; + counts[category] = (counts[category] ?? 0) + 1; + } + return Object.fromEntries(Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0]))); +} + +function findingSummary(inventory: Inventory, rows: Array>): string { + if (inventory.elementIds.length === 0) return "0 bounded power-plan elements were visible in this exact view."; + const counts = categoryCounts(rows); + const detail = Object.entries(counts).map(([category, count]) => `${category}: ${count}`).join(", "); + return `${inventory.elementIds.length} bounded power-plan element(s) were visible. ${detail}.`; +} + +function block(req: ChatRequest, state: RuntimeState, message: string, results: ToolResult[]): ChatResponse { + appendGoalProgress(req.session_id, { + summary: message, + work_items: state.views.map(view => ({ + id: `view.${view.id}.inspect`, + title: `Inspect ${view.name}`, + status: "blocked", + scope: { kind: "view", view_id: view.id, view_name: view.name, level_name: view.levelName }, + blocker: message, + evidence_refs: results.map(result => `action:${result.action_id}`) + })) + }); + states.delete(req.session_id); + return terminal(`${message} I made no model changes and did not widen the verified Level scope.`, "failed"); +} + +function finish(req: ChatRequest, state: RuntimeState, frameResults: ToolResult[]): ChatResponse { + const invalidFrame = frameResults.some(result => result.status !== "done" || result.path !== "/revit/export-view-frame" || !object(result.result_json)); + if (frameResults.length !== state.views.length || invalidFrame) return block(req, state, "A focused per-view visual frame failed, so the bounded inspection receipt is incomplete.", frameResults); + + const inspectIds = state.views.map(view => `view.${view.id}.inspect`); + const findings = state.inventories.map(inventory => { + const rows = state.summaries.get(inventory.view.id) ?? []; + return { inventory, rows, summary: findingSummary(inventory, rows) }; + }); + appendGoalProgress(req.session_id, { + summary: `Completed bounded read-only power-plan inspection in ${state.views.length} exact Level view(s); no placement or model write was inferred.`, + assumptions: [ + { + id: "power.inspection_profile", + statement: `Power-plan inspection is bounded to ${POWER_INSPECTION_CATEGORIES.join(", ")} in the exact resolved views.`, + status: "accepted", + basis: "structured electrical semantic class and released read-only pilot profile", + evidence_refs: [...state.evidenceActionIds, ...frameResults.map(result => result.action_id)].map(id => `action:${id}`) + }, + { + id: "power.no_implicit_write", + statement: "Existing visible content is evidence, not authorization to create, move, or delete elements without an exact dry-run target and verified precedent.", + status: "accepted", + basis: "bounded mutation safety policy" + }, + ...findings.map(({ inventory, summary }) => ({ + id: `power.view.${inventory.view.id}.baseline`, + statement: `${inventory.view.name}: ${summary}`, + status: "accepted" as const, + basis: "exact view inventory plus element summary and exported visual frame", + evidence_refs: [inventory.inventoryActionId, ...(inventory.elementIds.length ? [actionId(inventory.view.id, "summary")] : []), actionId(inventory.view.id, "frame")].map(id => `action:${id}`) + })) + ], + work_items: [ + ...findings.map(({ inventory, summary }) => ({ + id: `view.${inventory.view.id}.inspect`, + title: `Inspect ${inventory.view.name}`, + status: "complete", + scope: { kind: "view", view_id: inventory.view.id, view_name: inventory.view.name, level_name: inventory.view.levelName }, + depends_on: ["scope.resolve"], + planned_actions: ["bounded power-category inventory", "exact element summaries", "before-state visual frame"], + evidence_refs: [inventory.inventoryActionId, ...(inventory.elementIds.length ? [actionId(inventory.view.id, "summary")] : []), actionId(inventory.view.id, "frame")].map(id => `action:${id}`), + result_summary: summary + })), + { + id: "precedent.resolve", + title: "Resolve applicable current-project precedent and office assumptions", + status: "ready", + depends_on: inspectIds, + planned_actions: ["resolve an exact adjacent-level or office-standard power-plan precedent", "compare category/type/placement evidence", "record accepted or rejected assumptions"], + result_summary: "The exact Level baseline is inspected. A separate verified precedent is still required before proposing placements." + }, + { + id: "design.plan", + title: "Prepare the bounded design plan", + status: "pending", + depends_on: ["precedent.resolve"], + planned_actions: ["produce exact per-view target ids and coordinates", "dry-run bounded actions", "record a repair/revert strategy"], + result_summary: "No broad write plan was invented from inventory counts alone." + } + ] + }); + const goal = getActiveGoalForSession(req.session_id); + if (goal) updateGoal(goal.id, { current_phase: "precedent_resolution", current_step: "Resolve exact current-project or office-standard power-plan precedent" }); + states.delete(req.session_id); + const details = findings.map(({ inventory, summary }) => `${inventory.view.name} (${inventory.view.id}): ${summary}`).join(" "); + return terminal(`I completed the bounded Level power-plan pilot inspection. ${details} I preserved the per-view evidence and next action—resolve an exact precedent before any dry-run or model write.`, "complete"); +} + +function continueRuntime(req: ChatRequest, state: RuntimeState): ChatResponse { + const results = matchingResults(req, state.actionIds); + if (results.length !== state.actionIds.length) return block(req, state, "One or more bounded power-plan inspection actions are missing.", results); + + if (state.stage === "inventory") { + const inventories = state.views.map(view => parseInventory(view, results.find(result => result.action_id === actionId(view.id, "inventory"))!)); + if (inventories.some(item => item === null)) return block(req, state, "A power-plan inventory was malformed, truncated, outside its exact view, or missing the requested category-filter receipt.", results); + const accepted = inventories as Inventory[]; + const actions = summaryActions(accepted); + if (actions.length === 0) { + const frames = frameActions(state.views); + states.set(req.session_id, { ...state, stage: "frame", actionIds: frames.map(action => action.action_id), inventories: accepted, evidenceActionIds: [...state.evidenceActionIds, ...results.map(result => result.action_id)] }); + return response("The exact power views are empty for the bounded inspection categories. I am exporting focused before-state frames to preserve that finding.", frames); + } + states.set(req.session_id, { ...state, stage: "summary", actionIds: actions.map(action => action.action_id), inventories: accepted, evidenceActionIds: [...state.evidenceActionIds, ...results.map(result => result.action_id)] }); + return response("I completed exact per-view power inventories and am reading back only those bounded element IDs before visual inspection.", actions); + } + + if (state.stage === "summary") { + const summaries = new Map>>(); + for (const inventory of state.inventories) { + if (inventory.elementIds.length === 0) { summaries.set(inventory.view.id, []); continue; } + const result = results.find(candidate => candidate.action_id === actionId(inventory.view.id, "summary")); + if (!result || result.status !== "done" || result.path !== "/revit/get-element-summary" || !Array.isArray(result.result_json)) return block(req, state, "An exact element-summary readback failed.", results); + const rows = result.result_json.map(object); + const ids = rows.map(row => row?.id); + if (rows.some(row => !row || row.found !== true) || ids.length !== inventory.elementIds.length || !inventory.elementIds.every(id => ids.includes(id))) return block(req, state, "Element-summary readback did not exactly cover the inventoried IDs.", results); + summaries.set(inventory.view.id, rows as Array>); + } + const frames = frameActions(state.views); + states.set(req.session_id, { ...state, stage: "frame", actionIds: frames.map(action => action.action_id), summaries, evidenceActionIds: [...state.evidenceActionIds, ...results.map(result => result.action_id)] }); + return response("The bounded IDs read back exactly. I am exporting one focused visual frame per resolved view before recording findings and next actions.", frames); + } + + return finish(req, state, results); +} + +export function startAecLevelPowerPlanPilot(req: ChatRequest, task: AecSemanticTaskV1, views: ResolvedView[]): ChatResponse | null { + if (!isPowerPlanTask(task)) return null; + if (views.length === 0 || views.length > MAX_PILOT_VIEWS) { + const blocker = views.length === 0 ? "No exact power-plan view was resolved." : `The pilot supports at most ${MAX_PILOT_VIEWS} exact power-plan views, but ${views.length} were resolved.`; + appendGoalProgress(req.session_id, { summary: blocker, work_item: { id: "scope.inspect", title: "Inspect current model and view state in the resolved scope", status: "blocked", blocker } }); + return terminal(`${blocker} I stopped without model changes rather than silently sampling or widening the scope.`, "failed"); + } + const actions = inventoryActions(views); + states.set(req.session_id, { task, views, stage: "inventory", actionIds: actions.map(action => action.action_id), inventories: [], summaries: new Map(), evidenceActionIds: [] }); + appendGoalProgress(req.session_id, { + summary: `Starting a bounded read-only power-plan pilot in ${views.length} exact view(s).`, + assumptions: [{ id: "power.no_implicit_write", statement: "The pilot inspects and records exact per-view evidence before any design proposal; no broad write scope is inferred.", status: "accepted", basis: "underspecified Level power-plan request" }] + }); + return response(`I resolved ${views.length} exact power-plan view(s). I am now inspecting only bounded electrical fixtures, equipment, and wires in those views; no model write is planned.`, actions); +} + +export function maybeContinueAecLevelPowerPlanPilot(req: ChatRequest): ChatResponse | null { + const state = states.get(req.session_id); + if (!state || !Array.isArray(req.tool_results) || req.tool_results.length === 0) return null; + return continueRuntime(req, state); +} + +export function __testOnlyClearAecLevelPowerPlanPilot(): void { states.clear(); } diff --git a/apps/operator-backend/src/deterministic/aec_scope_work_package_runtime.ts b/apps/operator-backend/src/deterministic/aec_scope_work_package_runtime.ts index c54c5e9..c42a915 100644 --- a/apps/operator-backend/src/deterministic/aec_scope_work_package_runtime.ts +++ b/apps/operator-backend/src/deterministic/aec_scope_work_package_runtime.ts @@ -4,6 +4,7 @@ import { appendGoalProgress, getActiveGoalForSession, setAgentGoal } from "../go import { buildAecScopeWorkPackage, type AecScopeWorkPackageV1 } from "./aec_scope_work_package.js"; import { resolveLevelIdentities, type ResolvedLevelIdentity } from "./aec_level_identity.js"; import { __testOnlyClearAecLevelTagRuntime, maybeContinueAecLevelTagRuntime, startAecLevelTagRuntime } from "./aec_level_tag_runtime.js"; +import { __testOnlyClearAecLevelPowerPlanPilot, maybeContinueAecLevelPowerPlanPilot, startAecLevelPowerPlanPilot } from "./aec_level_power_plan_runtime.js"; type RuntimeState = { task: AecSemanticTaskV1; package: AecScopeWorkPackageV1; stage: "levels" | "scope"; action_ids: string[]; evidence_action_ids: string[]; resolved_levels: ResolvedLevelIdentity[] }; const states = new Map(); @@ -127,12 +128,20 @@ function continuation(req: ChatRequest, state: RuntimeState): ChatResponse { }))); states.delete(req.session_id); if (tagRuntime) return tagRuntime; + const powerPlanRuntime = startAecLevelPowerPlanPilot(req, state.task, resolved.map(view => ({ + id: view.id, + name: view.name, + levelName: "levelName" in view ? view.levelName : null + }))); + if (powerPlanRuntime) return powerPlanRuntime; return terminal(`I resolved ${resolved.length} exact graphical view(s) and persisted separate inspect, execute, and visual-verification work items for each. No model changes were made during scope resolution.`, "complete"); } export function maybeRunAecScopeWorkPackage(req: ChatRequest, task?: AecSemanticTaskV1 | null): ChatResponse | null { const tagContinuation = maybeContinueAecLevelTagRuntime(req); if (tagContinuation) return tagContinuation; + const powerPlanContinuation = maybeContinueAecLevelPowerPlanPilot(req); + if (powerPlanContinuation) return powerPlanContinuation; const existingState = states.get(req.session_id); if (existingState && Array.isArray(req.tool_results) && req.tool_results.length > 0) return continuation(req, existingState); if (!task || (req.user_text ?? "").trim().length === 0 || (req.tool_results?.length ?? 0) > 0) return null; @@ -161,4 +170,4 @@ export function maybeRunAecScopeWorkPackage(req: ChatRequest, task?: AecSemantic return response(`${workPackage.summary} I am resolving exact live Revit scope first; this is read-only and will not execute model changes.`, workPackage.discovery_actions); } -export function __testOnlyClearAecScopeWorkPackageStates(): void { states.clear(); __testOnlyClearAecLevelTagRuntime(); } +export function __testOnlyClearAecScopeWorkPackageStates(): void { states.clear(); __testOnlyClearAecLevelTagRuntime(); __testOnlyClearAecLevelPowerPlanPilot(); } diff --git a/apps/operator-backend/test/aec_scope_work_package_runtime.test.ts b/apps/operator-backend/test/aec_scope_work_package_runtime.test.ts index 3a75a97..47dc8d6 100644 --- a/apps/operator-backend/test/aec_scope_work_package_runtime.test.ts +++ b/apps/operator-backend/test/aec_scope_work_package_runtime.test.ts @@ -13,7 +13,7 @@ function tagTask(prompt: string): AecSemanticTaskV1 { return { ...task(prompt), function req(session: string, prompt = "", tool_results?: ChatRequest["tool_results"]): ChatRequest { return { version: OPERATOR_BACKEND_CONTRACT_VERSION, session_id: session, message_id: `${session}-${tool_results?.length ?? 0}`, user_text: prompt, tool_results }; } const levelResult = { action_id: "aec-scope-resolve-levels", method: "POST" as const, path: "/revit/query", status: "done" as const, result_json: [{ id: 1362791, name: "L4", category: "Levels" }] }; -test("runtime persists scope package then materializes exact per-view work items", () => { +test("runtime persists scope package then completes bounded per-view power-plan inspection", () => { const previous = process.env.OPERATOR_WORKSPACE_ROOT; const root = fs.mkdtempSync(path.join(os.tmpdir(), "aec-scope-runtime-")); process.env.OPERATOR_WORKSPACE_ROOT = root; try { __testOnlyClearAecScopeWorkPackageStates(); const prompt = "Lay out the power plans on Level 4."; @@ -23,19 +23,55 @@ test("runtime persists scope package then materializes exact per-view work items const second = maybeRunAecScopeWorkPackage(req("scope-runtime", "", [levelResult])); assert.deepEqual(second?.actions, [{ action_id: "aec-scope-resolve-views", method: "POST", path: "/revit/views", body: { action: "list", levelNames: ["L4"], includeTemplates: false, offset: 0, limit: 50, semanticGroups: ["power"] } }]); const third = maybeRunAecScopeWorkPackage(req("scope-runtime", "", [{ action_id: "aec-scope-resolve-views", method: "POST", path: "/revit/views", status: "done", result_json: { status: "ok", count: 2, returned: 2, truncated: false, appliedFilters: ["exclude_templates", "level_names_exact", "semantic_groups"], views: [{ id: 101, name: "L4 - Power", levelName: "L4", type: "FloorPlan" }, { id: 102, name: "L4 - Power Enlarged", levelName: "L4", type: "FloorPlan" }] } }])); - assert.deepEqual(third?.actions, []); - assert.equal(third?.aec_query_receipt?.workflow_id, "query.scope_work_package"); - assert.equal(third?.aec_query_receipt?.status, "complete"); + assert.deepEqual(third?.actions.map(action => [action.path, action.body]), [ + ["/revit/find-elements", { viewId: 101, categories: ["OST_ElectricalFixtures", "OST_ElectricalEquipment", "OST_Wire"], limit: 500 }], + ["/revit/find-elements", { viewId: 102, categories: ["OST_ElectricalFixtures", "OST_ElectricalEquipment", "OST_Wire"], limit: 500 }] + ]); + const inventory = (viewId: number, ids: number[]) => ({ action_id: `aec-power-view-${viewId}-inventory`, method: "POST" as const, path: "/revit/find-elements", status: "done" as const, result_json: { status: "Ok", scope: { kind: "View", viewIds: [viewId], sheetId: null }, elementIds: ids, categoryFilterApplied: true, resolvedCategories: [{ builtInToken: "OST_ElectricalFixtures" }, { builtInToken: "OST_ElectricalEquipment" }, { builtInToken: "OST_Wire" }], truncated: false, warnings: [] } }); + const fourth = maybeRunAecScopeWorkPackage(req("scope-runtime", "", [inventory(101, [1001, 1002]), inventory(102, [])])); + assert.deepEqual(fourth?.actions, [{ action_id: "aec-power-view-101-summary", method: "POST", path: "/revit/get-element-summary", body: { viewId: 101, elementIds: [1001, 1002] } }]); + const fifth = maybeRunAecScopeWorkPackage(req("scope-runtime", "", [{ action_id: "aec-power-view-101-summary", method: "POST", path: "/revit/get-element-summary", status: "done", result_json: [{ id: 1001, found: true, category: "Electrical Fixtures", name: "Duplex" }, { id: 1002, found: true, category: "Electrical Equipment", name: "Panel" }] }])); + assert.deepEqual(fifth?.actions.map(action => [action.path, action.body]), [["/revit/export-view-frame", { viewId: 101, imageSize: 1600, includeMapping: true }], ["/revit/export-view-frame", { viewId: 102, imageSize: 1600, includeMapping: true }]]); + const sixth = maybeRunAecScopeWorkPackage(req("scope-runtime", "", [ + { action_id: "aec-power-view-101-frame", method: "POST", path: "/revit/export-view-frame", status: "done", result_json: { frameId: "frame-101", viewId: 101 } }, + { action_id: "aec-power-view-102-frame", method: "POST", path: "/revit/export-view-frame", status: "done", result_json: { frameId: "frame-102", viewId: 102 } } + ])); + assert.deepEqual(sixth?.actions, []); + assert.equal(sixth?.aec_query_receipt?.workflow_id, "query.level_power_plan_pilot"); + assert.equal(sixth?.aec_query_receipt?.status, "complete"); const goal = getActiveGoalForSession("scope-runtime"); assert.equal(goal?.work_items.find(item => item.id === "scope.resolve")?.status, "complete"); assert.deepEqual((goal?.work_items.find(item => item.id === "scope.resolve")?.scope as any)?.resolved_view_ids, [101, 102]); - assert.equal(goal?.work_items.find(item => item.id === "view.101.inspect")?.status, "ready"); + assert.equal(goal?.work_items.find(item => item.id === "view.101.inspect")?.status, "complete"); + assert.match(goal?.work_items.find(item => item.id === "view.101.inspect")?.result_summary ?? "", /Electrical Fixtures: 1/); + assert.equal(goal?.work_items.find(item => item.id === "view.102.inspect")?.status, "complete"); + assert.match(goal?.work_items.find(item => item.id === "view.102.inspect")?.result_summary ?? "", /0 bounded power-plan elements/); + assert.equal(goal?.work_items.find(item => item.id === "precedent.resolve")?.status, "ready"); assert.deepEqual(goal?.work_items.find(item => item.id === "precedent.resolve")?.depends_on, ["view.101.inspect", "view.102.inspect"]); assert.deepEqual(goal?.work_items.find(item => item.id === "design.plan")?.depends_on, ["precedent.resolve"]); assert.equal(goal?.work_items.find(item => item.id === "design.execute")?.status, "skipped"); assert.deepEqual(goal?.work_items.find(item => item.id === "view.101.execute")?.depends_on, ["view.101.inspect", "design.plan"]); assert.equal(goal?.work_items.find(item => item.id === "view.102.verify")?.status, "pending"); assert.deepEqual(goal?.work_items.find(item => item.id === "verify.visual")?.depends_on, ["view.101.verify", "view.102.verify"]); + assert.equal(goal?.assumptions.find(item => item.id === "power.no_implicit_write")?.status, "accepted"); + assert.match(goal?.assumptions.find(item => item.id === "power.view.101.baseline")?.statement ?? "", /2 bounded power-plan element/); + assert.equal(goal?.current_phase, "precedent_resolution"); + assert.match(goal?.current_step ?? "", /exact current-project or office-standard power-plan precedent/); + } finally { if (previous === undefined) delete process.env.OPERATOR_WORKSPACE_ROOT; else process.env.OPERATOR_WORKSPACE_ROOT = previous; fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("power-plan pilot rejects a truncated per-view inventory without summaries or writes", () => { + const previous = process.env.OPERATOR_WORKSPACE_ROOT; const root = fs.mkdtempSync(path.join(os.tmpdir(), "aec-power-pilot-block-")); process.env.OPERATOR_WORKSPACE_ROOT = root; + try { + __testOnlyClearAecScopeWorkPackageStates(); const session = "power-pilot-truncated"; const prompt = "Lay out the power plans on Level 4."; + maybeRunAecScopeWorkPackage(req(session, prompt), task(prompt)); + maybeRunAecScopeWorkPackage(req(session, "", [levelResult])); + maybeRunAecScopeWorkPackage(req(session, "", [{ action_id: "aec-scope-resolve-views", method: "POST", path: "/revit/views", status: "done", result_json: { status: "ok", count: 1, returned: 1, truncated: false, appliedFilters: ["exclude_templates", "level_names_exact", "semantic_groups"], views: [{ id: 101, name: "L4 - Power", levelName: "L4", type: "FloorPlan" }] } }])); + const result = maybeRunAecScopeWorkPackage(req(session, "", [{ action_id: "aec-power-view-101-inventory", method: "POST", path: "/revit/find-elements", status: "done", result_json: { scope: { kind: "View", viewIds: [101] }, elementIds: [1], categoryFilterApplied: true, resolvedCategories: [{ builtInToken: "OST_ElectricalFixtures" }, { builtInToken: "OST_ElectricalEquipment" }, { builtInToken: "OST_Wire" }], truncated: true } }])); + assert.deepEqual(result?.actions, []); + assert.equal(result?.aec_query_receipt?.status, "failed"); + assert.match(result?.assistant_message ?? "", /malformed, truncated/i); + assert.equal(getActiveGoalForSession(session)?.work_items.find(item => item.id === "view.101.inspect")?.status, "blocked"); } finally { if (previous === undefined) delete process.env.OPERATOR_WORKSPACE_ROOT; else process.env.OPERATOR_WORKSPACE_ROOT = previous; fs.rmSync(root, { recursive: true, force: true }); } }); @@ -48,29 +84,35 @@ test("runtime blocks empty and truncated scope discovery without model actions", }); test("runtime rejects wrong-level and missing-filter receipts instead of trusting widened results", () => { - for (const payload of [ - { count: 1, returned: 1, truncated: false, appliedFilters: ["exclude_templates", "level_names_exact", "semantic_groups"], views: [{ id: 1, name: "L3 - Power", levelName: "L3" }] }, - { count: 1, returned: 1, truncated: false, appliedFilters: ["exclude_templates"], views: [{ id: 1, name: "L4 - Power", levelName: "L4" }] }, - { count: 51, returned: 50, truncated: false, appliedFilters: ["exclude_templates", "level_names_exact", "semantic_groups"], views: [{ id: 1, name: "L4 - Power", levelName: "L4" }] } - ]) { - __testOnlyClearAecScopeWorkPackageStates(); - const session = `invalid-receipt-${payload.count}-${payload.appliedFilters.length}-${payload.views[0]?.levelName}`; - const prompt = "Complete Level 4 power plans."; - maybeRunAecScopeWorkPackage(req(session, prompt), task(prompt)); - maybeRunAecScopeWorkPackage(req(session, "", [levelResult])); - const result = maybeRunAecScopeWorkPackage(req(session, "", [{ action_id: "aec-scope-resolve-views", method: "POST", path: "/revit/views", status: "done", result_json: payload }])); - assert.deepEqual(result?.actions, []); - assert.equal(getActiveGoalForSession(session)?.work_items.find(item => item.id === "scope.resolve")?.status, "blocked"); - } + const previous = process.env.OPERATOR_WORKSPACE_ROOT; const root = fs.mkdtempSync(path.join(os.tmpdir(), "aec-scope-invalid-receipt-")); process.env.OPERATOR_WORKSPACE_ROOT = root; + try { + for (const payload of [ + { count: 1, returned: 1, truncated: false, appliedFilters: ["exclude_templates", "level_names_exact", "semantic_groups"], views: [{ id: 1, name: "L3 - Power", levelName: "L3" }] }, + { count: 1, returned: 1, truncated: false, appliedFilters: ["exclude_templates"], views: [{ id: 1, name: "L4 - Power", levelName: "L4" }] }, + { count: 51, returned: 50, truncated: false, appliedFilters: ["exclude_templates", "level_names_exact", "semantic_groups"], views: [{ id: 1, name: "L4 - Power", levelName: "L4" }] } + ]) { + __testOnlyClearAecScopeWorkPackageStates(); + const session = `invalid-receipt-${payload.count}-${payload.appliedFilters.length}-${payload.views[0]?.levelName}`; + const prompt = "Complete Level 4 power plans."; + maybeRunAecScopeWorkPackage(req(session, prompt), task(prompt)); + maybeRunAecScopeWorkPackage(req(session, "", [levelResult])); + const result = maybeRunAecScopeWorkPackage(req(session, "", [{ action_id: "aec-scope-resolve-views", method: "POST", path: "/revit/views", status: "done", result_json: payload }])); + assert.deepEqual(result?.actions, []); + assert.equal(getActiveGoalForSession(session)?.work_items.find(item => item.id === "scope.resolve")?.status, "blocked"); + } + } finally { if (previous === undefined) delete process.env.OPERATOR_WORKSPACE_ROOT; else process.env.OPERATOR_WORKSPACE_ROOT = previous; fs.rmSync(root, { recursive: true, force: true }); } }); test("runtime blocks an unresolved level alias before querying views", () => { - __testOnlyClearAecScopeWorkPackageStates(); const session = "missing-level"; const prompt = "Complete Level 4 power plans."; - maybeRunAecScopeWorkPackage(req(session, prompt), task(prompt)); - const result = maybeRunAecScopeWorkPackage(req(session, "", [{ ...levelResult, result_json: [{ id: 1, name: "L3" }] }])); - assert.deepEqual(result?.actions, []); - assert.match(result?.assistant_message ?? "", /not found/i); - assert.equal(result?.aec_query_receipt?.status, "failed"); + const previous = process.env.OPERATOR_WORKSPACE_ROOT; const root = fs.mkdtempSync(path.join(os.tmpdir(), "aec-scope-missing-level-")); process.env.OPERATOR_WORKSPACE_ROOT = root; + try { + __testOnlyClearAecScopeWorkPackageStates(); const session = "missing-level"; const prompt = "Complete Level 4 power plans."; + maybeRunAecScopeWorkPackage(req(session, prompt), task(prompt)); + const result = maybeRunAecScopeWorkPackage(req(session, "", [{ ...levelResult, result_json: [{ id: 1, name: "L3" }] }])); + assert.deepEqual(result?.actions, []); + assert.match(result?.assistant_message ?? "", /not found/i); + assert.equal(result?.aec_query_receipt?.status, "failed"); + } finally { if (previous === undefined) delete process.env.OPERATOR_WORKSPACE_ROOT; else process.env.OPERATOR_WORKSPACE_ROOT = previous; fs.rmSync(root, { recursive: true, force: true }); } }); test("authoritative Sidecar request safely supersedes an empty same-session auto goal", () => {