diff --git a/src/Mod/VibeCAD/CMakeLists.txt b/src/Mod/VibeCAD/CMakeLists.txt
index cdc761aa..9397aaff 100644
--- a/src/Mod/VibeCAD/CMakeLists.txt
+++ b/src/Mod/VibeCAD/CMakeLists.txt
@@ -25,6 +25,8 @@ set(VibeCAD_Scripts
VibeCADDocumentChangeBatch.py
VibeCADDocumentReferences.py
VibeCADEditState.py
+ VibeCADEngineeringBrief.py
+ VibeCADEngineeringBriefGui.py
VibeCADFasteners.py
VibeCADFastenersGui.py
VibeCADFastenerModel.py
@@ -1288,6 +1290,7 @@ set(VibeCAD_Resources
vibecad-activity.svg
vibecad-new-conversation.svg
vibecad-prompt-starters.svg
+ vibecad-engineering-brief.svg
)
set(VibeCAD_UpdateTrustFiles)
@@ -1308,6 +1311,8 @@ if(BUILD_TEST AND BUILD_GUI)
vibecad_tests/link_override_visibility_gui_integration.py
vibecad_tests/mcp_gui_integration.py
vibecad_tests/session_recovery_gui_integration.py
+ vibecad_tests/engineering_brief_gui_integration.py
+ vibecad_tests/test_engineering_brief.py
vibecad_tests/test_session_recovery.py
vibecad_tests/native_background_gui_integration.py
vibecad_tests/native_analyze_context_responsiveness_gui_integration.py
diff --git a/src/Mod/VibeCAD/VibeCADEngineeringBrief.py b/src/Mod/VibeCAD/VibeCADEngineeringBrief.py
new file mode 100644
index 00000000..5f753a3c
--- /dev/null
+++ b/src/Mod/VibeCAD/VibeCADEngineeringBrief.py
@@ -0,0 +1,582 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+"""Non-mutating, durable Engineering Brief workflow for VibeCAD."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+import json
+from pathlib import Path
+import re
+import threading
+from typing import Any, Callable, Mapping
+
+from VibeCADProject import now_iso
+from VibeCADVibeScriptFileIO import atomic_write_text, open_shared_binary
+
+ENGINEERING_BRIEF_SCHEMA = "vibecad-engineering-brief-v1"
+ENGINEERING_BRIEF_VERSION = 1
+ENGINEERING_BRIEFS_DIRECTORY = "engineering-briefs"
+
+BRIEF_FIELD_ORDER = (
+ "objective",
+ "deliverables",
+ "existing_geometry",
+ "units",
+ "dimensions",
+ "materials",
+ "interfaces",
+ "loads",
+ "manufacturing",
+ "tolerances",
+ "analyses",
+ "acceptance_criteria",
+ "requirements",
+ "preferences",
+)
+
+BRIEF_FIELD_LABELS = {
+ "objective": "Objective",
+ "deliverables": "Deliverables",
+ "existing_geometry": "Existing geometry and document context",
+ "units": "Units",
+ "dimensions": "Dimensions",
+ "materials": "Materials",
+ "interfaces": "Interfaces and constraints",
+ "loads": "Loads and operating conditions",
+ "manufacturing": "Manufacturing",
+ "tolerances": "Tolerances",
+ "analyses": "Required analyses and drawings",
+ "acceptance_criteria": "Acceptance criteria",
+ "requirements": "Hard requirements",
+ "preferences": "Preferences",
+}
+
+ENGINEERING_BRIEF_TASK_INSTRUCTIONS = """You are VibeCAD's non-mutating Engineering Brief assistant.
+
+Help an engineer convert an incomplete request into a precise, reviewable brief for a separate CAD agent. Use the supplied active-conversation, document, selection, workbench, and unit context instead of asking for facts already known. Treat requirements and rejected directions from the active conversation as established context, while letting the user's current request control. Distinguish hard requirements from preferences. Ask exactly one highest-value question per response when a missing answer could materially change function, geometry, analysis, manufacture, safety, or acceptance. Do not interrogate indefinitely: if the user says to use best judgment, record transparent assumptions and move forward. Never silently invent consequential dimensions, loads, materials, tolerances, standards, or safety factors.
+
+Do not call or request CAD tools, mutate the document, claim CAD work was performed, or instruct the user to perform an unrelated workflow. Return only one JSON object matching the response contract in the current prompt. Do not wrap it in prose or Markdown."""
+
+_CONVERSATION_ID_PATTERN = re.compile(r"[0-9a-f]{32}")
+_STORE_LOCK = threading.RLock()
+
+
+def _clean_string(value: Any) -> str:
+ return str(value or "").strip()
+
+
+def _clean_string_list(value: Any, field: str) -> list[str]:
+ if not isinstance(value, list):
+ raise ValueError(f"Engineering Brief {field} must be an array.")
+ return [clean for item in value if (clean := _clean_string(item))]
+
+
+def _json_copy(value: Any, field: str) -> Any:
+ try:
+ return json.loads(json.dumps(value, ensure_ascii=False))
+ except (TypeError, ValueError) as exc:
+ raise ValueError(
+ f"Engineering Brief {field} must be JSON serializable."
+ ) from exc
+
+
+def _empty_brief(original_request: str) -> dict[str, Any]:
+ return {
+ "objective": _clean_string(original_request),
+ "deliverables": [],
+ "existing_geometry": [],
+ "units": "",
+ "dimensions": [],
+ "materials": [],
+ "interfaces": [],
+ "loads": [],
+ "manufacturing": [],
+ "tolerances": [],
+ "analyses": [],
+ "acceptance_criteria": [],
+ "requirements": [],
+ "preferences": [],
+ }
+
+
+def _normalized_brief(value: Any) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ raise ValueError("Engineering Brief brief must be an object.")
+ normalized: dict[str, Any] = {}
+ for field in BRIEF_FIELD_ORDER:
+ raw = value.get(field, "" if field in {"objective", "units"} else [])
+ if field in {"objective", "units"}:
+ if not isinstance(raw, str):
+ raise ValueError(f"Engineering Brief brief.{field} must be a string.")
+ normalized[field] = _clean_string(raw)
+ else:
+ normalized[field] = _clean_string_list(raw, f"brief.{field}")
+ return normalized
+
+
+def _normalized_transcript(value: Any) -> list[dict[str, str]]:
+ if not isinstance(value, list):
+ raise ValueError("Engineering Brief transcript must be an array.")
+ transcript: list[dict[str, str]] = []
+ for item in value:
+ if not isinstance(item, Mapping):
+ raise ValueError("Engineering Brief transcript entries must be objects.")
+ role = _clean_string(item.get("role")).lower()
+ content = _clean_string(item.get("content"))
+ if role not in {"user", "assistant"} or not content:
+ raise ValueError(
+ "Engineering Brief transcript entries require a user/assistant role "
+ "and non-empty content."
+ )
+ transcript.append({"role": role, "content": content})
+ return transcript
+
+
+def _validated_state(value: Any) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ raise ValueError("Engineering Brief state must be an object.")
+ if value.get("schema") != ENGINEERING_BRIEF_SCHEMA:
+ raise ValueError("Engineering Brief state has an unsupported schema.")
+ if value.get("version") != ENGINEERING_BRIEF_VERSION:
+ raise ValueError("Engineering Brief state has an unsupported version.")
+ document_uid = _clean_string(value.get("document_uid"))
+ if not document_uid:
+ raise ValueError("Engineering Brief state requires a document_uid.")
+ conversation_id = _clean_string(value.get("conversation_id")).lower()
+ if _CONVERSATION_ID_PATTERN.fullmatch(conversation_id) is None:
+ raise ValueError(
+ "Engineering Brief state requires a 32-character conversation_id."
+ )
+ original_request = _clean_string(value.get("original_request"))
+ ready = value.get("ready")
+ if not isinstance(ready, bool):
+ raise ValueError("Engineering Brief ready must be a boolean.")
+ next_question = _clean_string(value.get("next_question"))
+ if not ready and value.get("transcript") and not next_question:
+ raise ValueError(
+ "An unfinished Engineering Brief response requires next_question."
+ )
+ return {
+ "schema": ENGINEERING_BRIEF_SCHEMA,
+ "version": ENGINEERING_BRIEF_VERSION,
+ "document_uid": document_uid,
+ "conversation_id": conversation_id,
+ "original_request": original_request,
+ "context": _json_copy(value.get("context") or {}, "context"),
+ "transcript": _normalized_transcript(value.get("transcript") or []),
+ "brief": _normalized_brief(value.get("brief") or {}),
+ "assumptions": _clean_string_list(
+ value.get("assumptions") or [], "assumptions"
+ ),
+ "open_questions": _clean_string_list(
+ value.get("open_questions") or [], "open_questions"
+ ),
+ "editable_text": str(value.get("editable_text") or "").strip(),
+ "next_question": next_question,
+ "ready": ready,
+ "created_at": _clean_string(value.get("created_at")),
+ "updated_at": _clean_string(value.get("updated_at")),
+ }
+
+
+def new_engineering_brief(
+ original_request: str,
+ identity: Mapping[str, Any],
+ context: Mapping[str, Any],
+) -> dict[str, Any]:
+ """Create one editable brief bound to the active document conversation."""
+
+ clean_request = _clean_string(original_request)
+ timestamp = now_iso()
+ state = _validated_state(
+ {
+ "schema": ENGINEERING_BRIEF_SCHEMA,
+ "version": ENGINEERING_BRIEF_VERSION,
+ "document_uid": identity.get("document_uid"),
+ "conversation_id": identity.get("conversation_id"),
+ "original_request": clean_request,
+ "context": dict(context),
+ "transcript": [],
+ "brief": _empty_brief(clean_request),
+ "assumptions": [],
+ "open_questions": [],
+ "editable_text": "",
+ "next_question": "",
+ "ready": False,
+ "created_at": timestamp,
+ "updated_at": timestamp,
+ }
+ )
+ state["editable_text"] = _render_validated_engineering_brief(state)
+ return state
+
+
+def update_engineering_brief_draft(
+ state: Mapping[str, Any],
+ *,
+ original_request: str | None = None,
+ editable_text: str | None = None,
+) -> dict[str, Any]:
+ """Apply human edits without interpreting or discarding their wording."""
+
+ validated = _validated_state(state)
+ prior_render = _render_validated_engineering_brief(validated)
+ supplied_text = str(editable_text).strip() if editable_text is not None else None
+ preview_was_canonical = (
+ supplied_text is not None
+ and supplied_text
+ in {
+ str(validated.get("editable_text") or "").strip(),
+ prior_render,
+ }
+ and str(validated.get("editable_text") or "").strip() == prior_render
+ )
+ updated = dict(validated)
+ if original_request is not None:
+ updated["original_request"] = _clean_string(original_request)
+ if not validated["transcript"] and validated["brief"]["objective"] in {
+ "",
+ validated["original_request"],
+ }:
+ updated["brief"] = {
+ **validated["brief"],
+ "objective": updated["original_request"],
+ }
+ if editable_text is not None:
+ updated["editable_text"] = supplied_text or ""
+ if preview_was_canonical:
+ updated["editable_text"] = _render_validated_engineering_brief(updated)
+ updated["updated_at"] = now_iso()
+ return _validated_state(updated)
+
+
+def add_active_conversation_context(
+ state: Mapping[str, Any],
+ conversation_context: Mapping[str, Any],
+) -> dict[str, Any]:
+ """Add one bounded active-conversation snapshot to an existing brief."""
+
+ validated = _validated_state(state)
+ updated = dict(validated)
+ updated["context"] = {
+ **validated["context"],
+ "active_conversation": _json_copy(
+ conversation_context,
+ "context.active_conversation",
+ ),
+ }
+ updated["updated_at"] = now_iso()
+ return _validated_state(updated)
+
+
+def build_engineering_brief_prompt(
+ state: Mapping[str, Any],
+ user_response: str,
+ *,
+ use_best_judgment: bool = False,
+) -> str:
+ """Create a self-contained request for one non-mutating interview turn."""
+
+ validated = _validated_state(state)
+ if not validated["original_request"]:
+ raise ValueError(
+ "Describe the engineering outcome before developing the brief."
+ )
+ clean_response = _clean_string(user_response)
+ if use_best_judgment:
+ clean_response = (
+ clean_response
+ or "Use your best engineering judgment for remaining details and list every "
+ "assumption explicitly."
+ )
+ response_contract = {
+ "assistant_message": "string; concise explanation or the one next question",
+ "next_question": "string; empty only when ready is true",
+ "ready": "boolean",
+ "brief": {
+ "objective": "string",
+ "deliverables": ["string"],
+ "existing_geometry": ["string"],
+ "units": "string",
+ "dimensions": ["string"],
+ "materials": ["string"],
+ "interfaces": ["string"],
+ "loads": ["string"],
+ "manufacturing": ["string"],
+ "tolerances": ["string"],
+ "analyses": ["string"],
+ "acceptance_criteria": ["string"],
+ "requirements": ["string"],
+ "preferences": ["string"],
+ },
+ "assumptions": ["string"],
+ "open_questions": ["string"],
+ }
+ return (
+ "Develop the engineering brief below. Ask exactly one highest-value question "
+ "if an unresolved answer materially changes the design. If the brief is ready, "
+ "set ready=true and next_question to an empty string. Preserve known facts; "
+ "never turn an assumption into a stated requirement. Do not call or request CAD "
+ "tools. Return only JSON.\n\n"
+ "ENGINEERING_BRIEF_RESPONSE_CONTRACT_JSON\n"
+ + json.dumps(response_contract, ensure_ascii=False, separators=(",", ":"))
+ + "\nEND_ENGINEERING_BRIEF_RESPONSE_CONTRACT_JSON\n\n"
+ "ENGINEERING_BRIEF_STATE_JSON\n"
+ + json.dumps(validated, ensure_ascii=False, separators=(",", ":"))
+ + "\nEND_ENGINEERING_BRIEF_STATE_JSON\n\n"
+ "LATEST_USER_RESPONSE\n"
+ + (clean_response or "Begin by evaluating the current request and context.")
+ )
+
+
+def _extract_json_object(raw: str) -> dict[str, Any]:
+ text = str(raw or "").strip()
+ fenced = re.search(
+ r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL | re.IGNORECASE
+ )
+ if fenced is not None:
+ text = fenced.group(1)
+ start = text.find("{")
+ if start < 0:
+ raise ValueError("Engineering Brief provider response contains no JSON object.")
+ try:
+ decoded, _end = json.JSONDecoder().raw_decode(text[start:])
+ except ValueError as exc:
+ raise ValueError(
+ f"Engineering Brief provider response is not valid JSON: {exc}"
+ ) from exc
+ if not isinstance(decoded, dict):
+ raise ValueError("Engineering Brief provider response must be a JSON object.")
+ return decoded
+
+
+def parse_engineering_brief_result(
+ raw: str,
+ *,
+ prior_state: Mapping[str, Any],
+ user_response: str,
+) -> dict[str, Any]:
+ """Validate and merge one provider response without losing durable identity."""
+
+ prior = _validated_state(prior_state)
+ result = _extract_json_object(raw)
+ required = {
+ "assistant_message",
+ "next_question",
+ "ready",
+ "brief",
+ "assumptions",
+ "open_questions",
+ }
+ missing = sorted(required - set(result))
+ if missing:
+ raise ValueError(
+ "Engineering Brief provider response is missing: " + ", ".join(missing)
+ )
+ assistant_message = result.get("assistant_message")
+ next_question = result.get("next_question")
+ ready = result.get("ready")
+ if not isinstance(assistant_message, str) or not assistant_message.strip():
+ raise ValueError(
+ "Engineering Brief provider response assistant_message must be a string."
+ )
+ if not isinstance(next_question, str):
+ raise ValueError(
+ "Engineering Brief provider response next_question must be a string."
+ )
+ if not isinstance(ready, bool):
+ raise ValueError("Engineering Brief provider response ready must be a boolean.")
+ clean_question = next_question.strip()
+ if not ready and not clean_question:
+ raise ValueError(
+ "Engineering Brief provider response next_question is required until ready."
+ )
+ assumptions = _clean_string_list(result.get("assumptions"), "assumptions")
+ open_questions = _clean_string_list(result.get("open_questions"), "open_questions")
+ transcript = list(prior["transcript"])
+ clean_response = _clean_string(user_response)
+ if clean_response:
+ transcript.append({"role": "user", "content": clean_response})
+ transcript.append({"role": "assistant", "content": assistant_message.strip()})
+ updated = _validated_state(
+ {
+ **prior,
+ "transcript": transcript,
+ "brief": _normalized_brief(result.get("brief")),
+ "assumptions": assumptions,
+ "open_questions": open_questions,
+ "editable_text": "",
+ "next_question": clean_question,
+ "ready": ready,
+ "updated_at": now_iso(),
+ }
+ )
+ updated["editable_text"] = _render_validated_engineering_brief(updated)
+ return updated
+
+
+def run_engineering_brief_turn(
+ state: Mapping[str, Any],
+ *,
+ user_response: str,
+ provider: Any,
+ use_best_judgment: bool = False,
+ cancellation_check: Callable[[], bool] | None = None,
+ progress_callback: Callable[[dict[str, Any]], None] | None = None,
+) -> dict[str, Any]:
+ """Run one provider-only brief turn with no CAD tool runner or tool schemas."""
+
+ validated = _validated_state(state)
+ provider_context = {
+ "workbench": validated["context"].get("workbench"),
+ "document": deepcopy(validated["context"].get("document") or {}),
+ "selection": deepcopy(validated["context"].get("selection") or {}),
+ "units": deepcopy(validated["context"].get("units") or {}),
+ "provider_tool_schemas": [],
+ "_vibecad_toolless_task": True,
+ "_vibecad_task_instructions": ENGINEERING_BRIEF_TASK_INSTRUCTIONS,
+ }
+ prompt = build_engineering_brief_prompt(
+ validated,
+ user_response,
+ use_best_judgment=use_best_judgment,
+ )
+ result = provider.run(
+ prompt,
+ provider_context,
+ tool_runner=None,
+ cancellation_check=cancellation_check,
+ progress_callback=progress_callback,
+ )
+ return parse_engineering_brief_result(
+ str(getattr(result, "final_output", "") or ""),
+ prior_state=validated,
+ user_response=(
+ _clean_string(user_response)
+ or (
+ "Use your best engineering judgment for remaining details and list "
+ "every assumption explicitly."
+ if use_best_judgment
+ else ""
+ )
+ ),
+ )
+
+
+def _render_validated_engineering_brief(validated: Mapping[str, Any]) -> str:
+ lines: list[str] = []
+ for field in BRIEF_FIELD_ORDER:
+ value = validated["brief"][field]
+ if not value:
+ continue
+ lines.append(BRIEF_FIELD_LABELS[field])
+ if isinstance(value, list):
+ lines.extend(f"- {item}" for item in value)
+ else:
+ lines.append(str(value))
+ lines.append("")
+ if validated["assumptions"]:
+ lines.append("Explicit assumptions")
+ lines.extend(f"- {item}" for item in validated["assumptions"])
+ lines.append("")
+ if validated["open_questions"]:
+ lines.append("Open questions")
+ lines.extend(f"- {item}" for item in validated["open_questions"])
+ lines.append("")
+ return "\n".join(lines).strip()
+
+
+def render_engineering_brief(state: Mapping[str, Any]) -> str:
+ """Render the canonical state as readable, editable plain text."""
+
+ validated = _validated_state(state)
+ return str(validated.get("editable_text") or "").strip() or (
+ _render_validated_engineering_brief(validated)
+ )
+
+
+def engineering_brief_handoff(
+ state: Mapping[str, Any],
+ *,
+ approved_text: str,
+) -> str:
+ """Build the single authoritative user turn sent to the normal CAD agent."""
+
+ validated = _validated_state(state)
+ readable = _clean_string(approved_text) or render_engineering_brief(validated)
+ return (
+ "Complete the work in the active VibeCAD document using this approved "
+ "engineering brief. Inspect the current CAD state before acting and verify "
+ "the acceptance criteria before claiming completion.\n\n" + readable
+ )
+
+
+class EngineeringBriefStore:
+ """Durable per-conversation brief storage within one VibeCAD project."""
+
+ def __init__(self, project_root: str | Path) -> None:
+ clean_root = _clean_string(project_root)
+ if not clean_root:
+ raise ValueError("Engineering Brief storage requires a project root.")
+ self.project_root = Path(clean_root).expanduser()
+ self.directory = self.project_root / ENGINEERING_BRIEFS_DIRECTORY
+
+ def path_for(self, conversation_id: str) -> Path:
+ clean_id = _clean_string(conversation_id).lower()
+ if _CONVERSATION_ID_PATTERN.fullmatch(clean_id) is None:
+ raise ValueError(
+ "Engineering Brief storage requires a 32-character conversation id."
+ )
+ return self.directory / f"{clean_id}.json"
+
+ def write(self, state: Mapping[str, Any]) -> dict[str, Any]:
+ validated = _validated_state(state)
+ validated["updated_at"] = now_iso()
+ if not validated["created_at"]:
+ validated["created_at"] = validated["updated_at"]
+ path = self.path_for(validated["conversation_id"])
+ encoded = json.dumps(validated, ensure_ascii=False, indent=2, sort_keys=True)
+ with _STORE_LOCK:
+ atomic_write_text(path, encoded)
+ return {"written": True, "path": str(path), "state": validated}
+
+ def load(
+ self,
+ *,
+ document_uid: str,
+ conversation_id: str,
+ ) -> dict[str, Any]:
+ path = self.path_for(conversation_id)
+ with _STORE_LOCK:
+ if not path.is_file():
+ return {"available": False, "reason": "missing", "path": str(path)}
+ try:
+ with open_shared_binary(path) as stream:
+ raw = json.load(stream)
+ state = _validated_state(raw)
+ except (OSError, ValueError, RuntimeError) as exc:
+ return {
+ "available": True,
+ "recoverable": False,
+ "error": str(exc),
+ "path": str(path),
+ }
+ if state["document_uid"] != _clean_string(document_uid):
+ return {
+ "available": False,
+ "reason": "document_changed",
+ "path": str(path),
+ }
+ if state["conversation_id"] != _clean_string(conversation_id).lower():
+ return {
+ "available": False,
+ "reason": "conversation_changed",
+ "path": str(path),
+ }
+ return {
+ "available": True,
+ "recoverable": True,
+ "path": str(path),
+ "state": state,
+ }
diff --git a/src/Mod/VibeCAD/VibeCADEngineeringBriefGui.py b/src/Mod/VibeCAD/VibeCADEngineeringBriefGui.py
new file mode 100644
index 00000000..7f621edd
--- /dev/null
+++ b/src/Mod/VibeCAD/VibeCADEngineeringBriefGui.py
@@ -0,0 +1,474 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+"""Readable, modeless Engineering Brief window for the VibeCAD assistant."""
+
+from __future__ import annotations
+
+import html
+import queue
+import threading
+from typing import Any, Callable, Mapping
+
+from PySide import QtCore, QtWidgets
+
+from VibeCADEngineeringBrief import (
+ render_engineering_brief,
+ update_engineering_brief_draft,
+)
+
+
+class _EngineeringBriefSignals(QtCore.QObject):
+ turn_completed = QtCore.Signal(object)
+ turn_failed = QtCore.Signal(str)
+ persistence_failed = QtCore.Signal(str)
+
+
+class EngineeringBriefDialog(QtWidgets.QDialog):
+ """A non-modal interview and review surface for one engineering brief."""
+
+ def __init__(
+ self,
+ state: Mapping[str, Any],
+ *,
+ turn_runner: Callable[..., dict[str, Any]],
+ persist_callback: Callable[[Mapping[str, Any]], Any],
+ start_callback: Callable[[Mapping[str, Any], str], bool],
+ parent: Any = None,
+ ) -> None:
+ super().__init__(parent)
+ self.setObjectName("VibeEngineeringBriefDialog")
+ self.setWindowTitle("VibeCAD Engineering Brief")
+ self.setModal(False)
+ self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
+ self.resize(1120, 760)
+ self.setMinimumSize(820, 560)
+
+ self._state = dict(state)
+ self._turn_runner = turn_runner
+ self._persist_callback = persist_callback
+ self._start_callback = start_callback
+ self._turn_thread: threading.Thread | None = None
+ self._turn_active = False
+ self._cancel_event = threading.Event()
+ self._signals = _EngineeringBriefSignals(self)
+ self._signals.turn_completed.connect(self._complete_turn)
+ self._signals.turn_failed.connect(self._fail_turn)
+ self._signals.persistence_failed.connect(self._show_persistence_failure)
+
+ self._closing = False
+ self._lifecycle_lock = threading.Lock()
+ self._persist_queue: queue.Queue[dict[str, Any] | None] = queue.Queue()
+ self._persist_thread = threading.Thread(
+ target=self._persistence_loop,
+ name="VibeCAD-Engineering-Brief-Persistence",
+ daemon=True,
+ )
+ self._persist_thread.start()
+ self._build_ui()
+ self._render_state()
+ self._queue_persistence()
+
+ @property
+ def state(self) -> dict[str, Any]:
+ return dict(self._state)
+
+ def _build_ui(self) -> None:
+ root = QtWidgets.QVBoxLayout(self)
+ root.setContentsMargins(16, 14, 16, 14)
+ root.setSpacing(10)
+
+ title = QtWidgets.QLabel("Build an Engineering Brief", self)
+ title.setObjectName("VibeEngineeringBriefTitle")
+ title_font = title.font()
+ title_font.setPointSize(max(title_font.pointSize() + 4, 14))
+ title_font.setBold(True)
+ title.setFont(title_font)
+ root.addWidget(title)
+
+ explanation = QtWidgets.QLabel(
+ "The blue button always shows the next step. VibeCAD uses your active "
+ "conversation and document context, asks only consequential engineering "
+ "questions, and lets you review the result before any CAD work begins.",
+ self,
+ )
+ explanation.setWordWrap(True)
+ root.addWidget(explanation)
+
+ self.pages = QtWidgets.QStackedWidget(self)
+ self.pages.setObjectName("VibeEngineeringBriefSteps")
+ root.addWidget(self.pages, 1)
+
+ request_page = QtWidgets.QWidget(self.pages)
+ request_layout = QtWidgets.QVBoxLayout(request_page)
+ request_layout.setContentsMargins(0, 8, 0, 0)
+ request_layout.setSpacing(8)
+ request_title = QtWidgets.QLabel(
+ "1. Describe the result you need", request_page
+ )
+ request_title_font = request_title.font()
+ request_title_font.setBold(True)
+ request_title.setFont(request_title_font)
+ request_layout.addWidget(request_title)
+ request_help = QtWidgets.QLabel(
+ "Enter the engineering outcome in the box below. This is the only field "
+ "you need to complete at this step. Relevant details from the active "
+ "VibeCAD conversation will be included automatically.",
+ request_page,
+ )
+ request_help.setWordWrap(True)
+ request_layout.addWidget(request_help)
+
+ self.request_edit = QtWidgets.QPlainTextEdit(request_page)
+ self.request_edit.setObjectName("VibeEngineeringBriefRequest")
+ self.request_edit.setPlaceholderText(
+ "Example: Finish the robot design using the requirements we already "
+ "discussed."
+ )
+ self.request_edit.setMinimumHeight(180)
+ self.request_edit.textChanged.connect(self._schedule_human_edit_persistence)
+ request_layout.addWidget(self.request_edit, 1)
+ self.pages.addWidget(request_page)
+
+ interview_page = QtWidgets.QWidget(self.pages)
+ interview_layout = QtWidgets.QVBoxLayout(interview_page)
+ interview_layout.setContentsMargins(0, 8, 0, 0)
+ interview_layout.setSpacing(8)
+ interview_title = QtWidgets.QLabel(
+ "2. Answer one engineering question", interview_page
+ )
+ interview_title_font = interview_title.font()
+ interview_title_font.setBold(True)
+ interview_title.setFont(interview_title_font)
+ interview_layout.addWidget(interview_title)
+ interview_help = QtWidgets.QLabel(
+ "VibeCAD asks only for information that would materially change the "
+ "result. Enter your answer in the single box below.",
+ interview_page,
+ )
+ interview_help.setWordWrap(True)
+ interview_layout.addWidget(interview_help)
+
+ self.transcript = QtWidgets.QTextBrowser(interview_page)
+ self.transcript.setObjectName("VibeEngineeringBriefTranscript")
+ self.transcript.setOpenExternalLinks(False)
+ self.transcript.setOpenLinks(False)
+ interview_layout.addWidget(self.transcript, 1)
+
+ self.question_label = QtWidgets.QLabel(interview_page)
+ self.question_label.setObjectName("VibeEngineeringBriefQuestion")
+ question_font = self.question_label.font()
+ question_font.setBold(True)
+ self.question_label.setFont(question_font)
+ self.question_label.setWordWrap(True)
+ interview_layout.addWidget(self.question_label)
+
+ self.answer_edit = QtWidgets.QPlainTextEdit(interview_page)
+ self.answer_edit.setObjectName("VibeEngineeringBriefAnswer")
+ self.answer_edit.setPlaceholderText("Type your answer here.")
+ self.answer_edit.setMinimumHeight(80)
+ self.answer_edit.setMaximumHeight(140)
+ self.answer_edit.textChanged.connect(self._update_primary_action)
+ interview_layout.addWidget(self.answer_edit)
+ self.pages.addWidget(interview_page)
+
+ review_page = QtWidgets.QWidget(self.pages)
+ review_layout = QtWidgets.QVBoxLayout(review_page)
+ review_layout.setContentsMargins(0, 8, 0, 0)
+ review_layout.setSpacing(8)
+ review_title = QtWidgets.QLabel("3. Review the engineering brief", review_page)
+ review_title_font = review_title.font()
+ review_title_font.setBold(True)
+ review_title.setFont(review_title_font)
+ review_layout.addWidget(review_title)
+ review_help = QtWidgets.QLabel(
+ "Read the generated brief and edit anything that needs correction. When "
+ "it is accurate, the blue button starts the CAD work.",
+ review_page,
+ )
+ review_help.setWordWrap(True)
+ review_layout.addWidget(review_help)
+
+ self.preview = QtWidgets.QPlainTextEdit(review_page)
+ self.preview.setObjectName("VibeEngineeringBriefPreview")
+ self.preview.setPlaceholderText(
+ "Your generated engineering brief will appear here."
+ )
+ self.preview.textChanged.connect(self._schedule_human_edit_persistence)
+ review_layout.addWidget(self.preview, 1)
+ self.pages.addWidget(review_page)
+
+ self.status = QtWidgets.QLabel(self)
+ self.status.setObjectName("VibeEngineeringBriefStatus")
+ self.status.setWordWrap(True)
+ root.addWidget(self.status)
+
+ footer = QtWidgets.QHBoxLayout()
+ self.best_judgment_button = QtWidgets.QPushButton(
+ "Finish with Assumptions", self
+ )
+ self.best_judgment_button.setObjectName("VibeEngineeringBriefBestJudgment")
+ self.best_judgment_button.setToolTip(
+ "Let VibeCAD make and clearly record reasonable assumptions for the "
+ "remaining details"
+ )
+ self.best_judgment_button.clicked.connect(lambda: self._begin_turn(True))
+ footer.addWidget(self.best_judgment_button)
+ footer.addStretch(1)
+ self.close_button = QtWidgets.QPushButton("Close", self)
+ self.close_button.setObjectName("VibeEngineeringBriefClose")
+ self.close_button.clicked.connect(self.close)
+ footer.addWidget(self.close_button)
+
+ self.primary_button = QtWidgets.QPushButton("Build My Brief", self)
+ self.primary_button.setObjectName("VibeEngineeringBriefPrimary")
+ self.primary_button.setToolTip("Continue to the next step shown in this window")
+ self.primary_button.clicked.connect(self._advance)
+ footer.addWidget(self.primary_button)
+ root.addLayout(footer)
+
+ self._edit_timer = QtCore.QTimer(self)
+ self._edit_timer.setObjectName("VibeEngineeringBriefDraftTimer")
+ self._edit_timer.setSingleShot(True)
+ self._edit_timer.setInterval(600)
+ self._edit_timer.timeout.connect(self._capture_and_persist_human_edits)
+
+ def _render_state(self) -> None:
+ request_blocked = self.request_edit.blockSignals(True)
+ preview_blocked = self.preview.blockSignals(True)
+ try:
+ self.request_edit.setPlainText(
+ str(self._state.get("original_request") or "")
+ )
+ self.preview.setPlainText(render_engineering_brief(self._state))
+ finally:
+ self.request_edit.blockSignals(request_blocked)
+ self.preview.blockSignals(preview_blocked)
+
+ transcript = list(self._state.get("transcript") or [])
+ if transcript:
+ blocks = []
+ for item in transcript:
+ role = "You" if item.get("role") == "user" else "Brief assistant"
+ content = html.escape(str(item.get("content") or "")).replace(
+ "\n", "
"
+ )
+ blocks.append(f"
{html.escape(role)}
{content}
")
+ self.transcript.setHtml("".join(blocks))
+ scrollbar = self.transcript.verticalScrollBar()
+ scrollbar.setValue(scrollbar.maximum())
+ else:
+ self.transcript.setHtml(
+ "Brief assistant
I will use your active VibeCAD "
+ "conversation and ask one consequential question at a time.
"
+ )
+
+ question = str(self._state.get("next_question") or "").strip()
+ self.question_label.setText(question)
+ if self._state.get("ready"):
+ self.pages.setCurrentIndex(2)
+ elif question or transcript:
+ self.pages.setCurrentIndex(1)
+ else:
+ self.pages.setCurrentIndex(0)
+ self.request_edit.setReadOnly(self.pages.currentIndex() != 0)
+ self.preview.setReadOnly(not bool(self._state.get("ready")))
+ self.best_judgment_button.setVisible(
+ bool(question) and not bool(self._state.get("ready"))
+ )
+ if self._state.get("ready"):
+ self.status.setText(
+ "Review the brief. The blue button will start CAD work when it is ready."
+ )
+ elif question:
+ self.status.setText(
+ "Type one answer, then use the blue button. No CAD work has started."
+ )
+ else:
+ self.status.setText(
+ "Enter your request, then use the blue button. No CAD work will begin yet."
+ )
+ self._update_primary_action()
+
+ def _update_primary_action(self) -> None:
+ if self._turn_active:
+ label = "Building Brief..."
+ enabled = False
+ elif self._state.get("ready"):
+ label = "Start CAD Work"
+ enabled = bool(self.preview.toPlainText().strip())
+ elif str(self._state.get("next_question") or "").strip():
+ label = "Submit Answer"
+ enabled = bool(self.answer_edit.toPlainText().strip())
+ else:
+ label = "Build My Brief"
+ enabled = bool(self.request_edit.toPlainText().strip())
+ self.primary_button.setText(label)
+ self.primary_button.setEnabled(enabled)
+ self.primary_button.setAutoDefault(enabled)
+ self.primary_button.setDefault(enabled)
+
+ def _advance(self) -> None:
+ if self._state.get("ready"):
+ self._start_in_vibecad()
+ else:
+ self._begin_turn(False)
+
+ def _schedule_human_edit_persistence(self) -> None:
+ self._update_primary_action()
+ if not self._closing:
+ self._edit_timer.start()
+
+ def _capture_human_edits(self) -> None:
+ self._state = update_engineering_brief_draft(
+ self._state,
+ original_request=self.request_edit.toPlainText(),
+ editable_text=self.preview.toPlainText(),
+ )
+
+ def _capture_and_persist_human_edits(self) -> None:
+ self._capture_human_edits()
+ self._queue_persistence()
+
+ def _queue_persistence(self) -> None:
+ with self._lifecycle_lock:
+ if not self._closing:
+ self._persist_queue.put(dict(self._state))
+
+ def _persistence_loop(self) -> None:
+ while True:
+ state = self._persist_queue.get()
+ try:
+ if state is None:
+ return
+ self._persist_callback(state)
+ except Exception as exc:
+ try:
+ self._signals.persistence_failed.emit(str(exc))
+ except RuntimeError:
+ return
+ finally:
+ self._persist_queue.task_done()
+
+ def _show_persistence_failure(self, message: str) -> None:
+ if not self._closing:
+ self.status.setText(f"The brief is open but could not be saved: {message}")
+
+ def _set_turn_busy(self, busy: bool) -> None:
+ self.request_edit.setReadOnly(busy or self.pages.currentIndex() != 0)
+ self.preview.setReadOnly(busy or not bool(self._state.get("ready")))
+ self.answer_edit.setReadOnly(busy)
+ self.best_judgment_button.setEnabled(not busy)
+ self.close_button.setText("Cancel" if busy else "Close")
+ self._update_primary_action()
+
+ def _begin_turn(self, use_best_judgment: bool) -> None:
+ if self._turn_active:
+ return
+ self._capture_human_edits()
+ if not str(self._state.get("original_request") or "").strip():
+ self.status.setText(
+ "Describe the engineering outcome before developing the brief."
+ )
+ self.request_edit.setFocus()
+ return
+ response = self.answer_edit.toPlainText().strip()
+ if self._state.get("next_question") and not response and not use_best_judgment:
+ self.status.setText("Answer the current question or use best judgment.")
+ self.answer_edit.setFocus()
+ return
+ snapshot = dict(self._state)
+ self._cancel_event.clear()
+ self._turn_active = True
+ self._set_turn_busy(True)
+ self.status.setText("Building your engineering brief...")
+
+ def run() -> None:
+ try:
+ updated = self._turn_runner(
+ snapshot,
+ user_response=response,
+ use_best_judgment=use_best_judgment,
+ cancellation_check=self._cancel_event.is_set,
+ )
+ with self._lifecycle_lock:
+ if self._closing or self._cancel_event.is_set():
+ return
+ self._persist_queue.put(dict(updated))
+ except Exception as exc:
+ try:
+ self._signals.turn_failed.emit(str(exc))
+ except RuntimeError:
+ pass
+ return
+ try:
+ self._signals.turn_completed.emit(updated)
+ except RuntimeError:
+ pass
+
+ self._turn_thread = threading.Thread(
+ target=run,
+ name="VibeCAD-Engineering-Brief-Provider",
+ daemon=True,
+ )
+ self._turn_thread.start()
+
+ @QtCore.Slot(object)
+ def _complete_turn(self, state: Mapping[str, Any]) -> None:
+ if self._closing:
+ return
+ self._state = dict(state)
+ self._turn_thread = None
+ self._turn_active = False
+ self.answer_edit.clear()
+ self._set_turn_busy(False)
+ self._render_state()
+ if not self._state.get("ready"):
+ self.answer_edit.setFocus()
+
+ @QtCore.Slot(str)
+ def _fail_turn(self, message: str) -> None:
+ if self._closing:
+ return
+ self._turn_thread = None
+ self._turn_active = False
+ self._set_turn_busy(False)
+ self.status.setText(f"The brief assistant could not continue: {message}")
+
+ def _start_in_vibecad(self) -> None:
+ if self._turn_active:
+ return
+ self._capture_human_edits()
+ request = str(self._state.get("original_request") or "").strip()
+ readable = self.preview.toPlainText().strip()
+ if not request:
+ self.status.setText("Describe the engineering outcome before starting.")
+ self.request_edit.setFocus()
+ return
+ if not readable:
+ self.status.setText(
+ "Develop or write the engineering brief before starting."
+ )
+ self.preview.setFocus()
+ return
+ self._queue_persistence()
+ try:
+ started = bool(self._start_callback(dict(self._state), readable))
+ except Exception as exc:
+ self.status.setText(f"VibeCAD could not start this brief: {exc}")
+ return
+ if started:
+ self.close()
+
+ def closeEvent(self, event: Any) -> None: # noqa: N802 (Qt API)
+ if self._closing:
+ event.accept()
+ return
+ self._edit_timer.stop()
+ self._cancel_event.set()
+ self._capture_human_edits()
+ with self._lifecycle_lock:
+ self._closing = True
+ self._persist_queue.put(dict(self._state))
+ self._persist_queue.put(None)
+ self._persist_thread.join()
+ event.accept()
diff --git a/src/Mod/VibeCAD/VibeCADGui.py b/src/Mod/VibeCAD/VibeCADGui.py
index 1ec991bb..e10a1008 100644
--- a/src/Mod/VibeCAD/VibeCADGui.py
+++ b/src/Mod/VibeCAD/VibeCADGui.py
@@ -41,6 +41,7 @@
)
from VibeCADSession import (
_format_document_delta,
+ _recent_conversation_payload,
prewarm_analyze_context,
prewarm_drawing_context,
rebuild_intent_memory,
@@ -49,7 +50,6 @@
run_sketch_close_continuation,
)
-
DOCK_NAME = "VibeCADAssistantPanel"
CONTEXT_DEBUG_DOCK_NAME = "VibeCADContextDebugPanel"
MODEL_CODE_DOCK_NAME = "VibeCADScriptedModelPanel"
@@ -61,6 +61,7 @@
ICON_ACTIVITY = "vibecad-activity.svg"
ICON_NEW_CONVERSATION = "vibecad-new-conversation.svg"
ICON_PROMPT_STARTERS = "vibecad-prompt-starters.svg"
+ICON_ENGINEERING_BRIEF = "vibecad-engineering-brief.svg"
_commands_registered = False
_preferences_registered = False
@@ -72,6 +73,7 @@
_context_debug_startup_scheduled = False
_registered_assistant_widget = None
_registered_context_debug_widget = None
+_engineering_brief_dialog = None
_document_save_conversations: dict[str, dict[str, Any]] = {}
_document_save_references: dict[str, dict[str, Any]] = {}
_pending_question_request: list[dict[str, Any]] = []
@@ -291,6 +293,11 @@ def _shutdown_internal_assistant() -> None:
if _application_shutting_down.is_set():
return
_persist_session_recovery_before_shutdown()
+ if _engineering_brief_dialog is not None:
+ try:
+ _engineering_brief_dialog.close()
+ except RuntimeError:
+ pass
_application_shutting_down.set()
_assistant_run_controller.request_cancel()
_intent_memory_rebuild_cancel_event.set()
@@ -369,9 +376,9 @@ def apply() -> None:
cancel_internal=_cancel_internal_agent_for_mcp,
question_callback=lambda questions: _request_user_answers(
questions,
- lambda: not get_control_mode_controller().snapshot().get(
- "mcp_enabled", False
- ),
+ lambda: not get_control_mode_controller()
+ .snapshot()
+ .get("mcp_enabled", False),
),
event_callback=handle_event,
)
@@ -555,10 +562,7 @@ def _authoring_mode_selector_state():
document is not None
and (
bool(getattr(document, "HasPendingTransaction", False))
- or (
- callable(booked_transaction)
- and int(booked_transaction() or 0) != 0
- )
+ or (callable(booked_transaction) and int(booked_transaction() or 0) != 0)
)
)
recompute_active = bool(
@@ -626,8 +630,7 @@ def _refresh_authoring_mode_selector(dock: Any | None = None) -> None:
selector.setCurrentIndex(index)
if choice_required:
selectable = any(
- state.target_enabled(mode)
- for mode in ("vibescript", "native")
+ state.target_enabled(mode) for mode in ("vibescript", "native")
)
selector.setEnabled(selectable)
else:
@@ -721,10 +724,14 @@ def _select_authoring_mode_from_header(index: int) -> None:
validated = validate_human_mode_request(state, requested)
if validated == state.current_mode and not choice_required:
return
- if requires_take_manual_control_confirmation(
- state.current_mode,
- validated,
- ) and _active_document_has_vibescript_content() and not _confirm_take_manual_control():
+ if (
+ requires_take_manual_control_confirmation(
+ state.current_mode,
+ validated,
+ )
+ and _active_document_has_vibescript_content()
+ and not _confirm_take_manual_control()
+ ):
_refresh_authoring_mode_selector(dock)
return
_ensure_first_conversation(service)
@@ -1583,9 +1590,7 @@ def _saved_conversation_blocks(conversation: list[dict[str, Any]]) -> list[str]:
if role == "assistant":
metadata = entry.get("metadata")
runtime = (
- metadata.get("provider_runtime")
- if isinstance(metadata, dict)
- else None
+ metadata.get("provider_runtime") if isinstance(metadata, dict) else None
)
if isinstance(runtime, dict):
tooltip = _provider_runtime_tooltip(runtime)
@@ -1770,8 +1775,7 @@ def _new_conversation_from_panel() -> None:
_render_assistant_run_state(
dock,
text=str(
- state.get("message")
- or "Create or open a document to use VibeCAD."
+ state.get("message") or "Create or open a document to use VibeCAD."
),
)
return
@@ -2289,7 +2293,9 @@ def _format_progress_event(event: dict[str, Any]) -> str:
count = int(event.get("finding_count", 0) or 0)
return f"Independent design review: {verdict} | {count} findings."
if name == "design_review_failed":
- return f"Independent design review failed: {event.get('error', 'unknown error')}"
+ return (
+ f"Independent design review failed: {event.get('error', 'unknown error')}"
+ )
if name == "provider_tool_requested":
arguments = event.get("arguments")
arg_text = ""
@@ -2503,9 +2509,7 @@ def _require_assistant_document(dock: Any | None = None) -> bool:
return True
if dock is None:
dock = _find_dock()
- message = str(
- state.get("message") or "Create or open a document to use VibeCAD."
- )
+ message = str(state.get("message") or "Create or open a document to use VibeCAD.")
if dock is not None:
_render_assistant_run_state(dock, text=message)
else:
@@ -2519,10 +2523,7 @@ def _require_assistant_turn(dock: Any | None = None) -> bool:
return True
if dock is None:
dock = _find_dock()
- message = str(
- state.get("message")
- or "Choose Native or VibeScript to begin."
- )
+ message = str(state.get("message") or "Choose Native or VibeScript to begin.")
if dock is not None:
_render_assistant_run_state(dock, text=message)
else:
@@ -2849,6 +2850,10 @@ def _apply_composer_button_presentation(
is_busy = _is_assistant_run_active() if busy is None else bool(busy)
labels = {
+ "VibeEngineeringBrief": (
+ "Engineering Brief",
+ "Turn a rough request into a reviewable engineering brief",
+ ),
"VibeAttachView": (
"Attach View",
"Attach a screenshot of the current 3D view",
@@ -3015,7 +3020,9 @@ def _populate_prompt_starter_menu(menu: Any, prompt: Any) -> None:
for starter in category_starters:
action = category_menu.addAction(starter.name)
action.setToolTip(
- "Built-in prompt starter" if starter.builtin else "Custom prompt starter"
+ "Built-in prompt starter"
+ if starter.builtin
+ else "Custom prompt starter"
)
action.triggered.connect(
lambda _checked=False, text=starter.content: _insert_prompt_starter(
@@ -3033,6 +3040,205 @@ def _populate_prompt_starter_menu(menu: Any, prompt: Any) -> None:
manage_action.triggered.connect(_show_prompt_starter_preferences)
+def _engineering_brief_context(
+ service: Any,
+) -> tuple[dict[str, str], dict[str, Any], dict[str, Any]]:
+ """Capture only cheap, explicit context on the FreeCAD document thread."""
+
+ _ensure_first_conversation(service)
+ prepared = service.prepare_conversation_history_read()
+ conversation_id = str(prepared.get("conversation_id") or "").strip().lower()
+ if not conversation_id:
+ history = service.conversation_history()
+ conversation_id = str(history.get("conversation_id") or "").strip().lower()
+ prepared = service.prepare_conversation_history_read()
+ if conversation_id and not prepared.get("conversation_id"):
+ prepared = {
+ **prepared,
+ "conversation_id": conversation_id,
+ "cached_conversation": [
+ dict(item)
+ for item in history.get("conversation") or []
+ if isinstance(item, dict)
+ ],
+ }
+ scope = service.project_scope_snapshot()
+ document = scope.get("document")
+ document_info = document if isinstance(document, dict) else {}
+ document_uid = str(
+ prepared.get("document_uid") or document_info.get("uid") or ""
+ ).strip()
+ project_root = str(prepared.get("project_root") or scope.get("root") or "").strip()
+ if not project_root or not document_uid or not conversation_id:
+ raise RuntimeError(
+ "VibeCAD could not resolve the active document conversation for this brief."
+ )
+ try:
+ unit_schema = int(App.Units.getSchema())
+ length_example = str(App.Units.Quantity(1.0, App.Units.Length).UserString)
+ except Exception:
+ unit_schema = int(
+ App.ParamGet("User parameter:BaseApp/Preferences/Units").GetInt(
+ "UserSchema", 0
+ )
+ )
+ length_example = ""
+ identity = {
+ "project_root": project_root,
+ "document_uid": document_uid,
+ "conversation_id": conversation_id,
+ }
+ context = {
+ "workbench": service.active_workbench_name(),
+ "units": {"schema": unit_schema, "length_example": length_example},
+ "document": {
+ **service.provider_turn_document_summary(),
+ "label": str(document_info.get("label") or scope.get("title") or ""),
+ "file_name": str(document_info.get("file_path") or ""),
+ },
+ "selection": service.provider_turn_selection_summary(),
+ }
+ return identity, context, prepared
+
+
+def _engineering_brief_dialog_destroyed(*_args: Any) -> None:
+ global _engineering_brief_dialog
+ _engineering_brief_dialog = None
+
+
+def _open_engineering_brief_from_panel() -> None:
+ """Open or raise the readable Engineering Brief interview window."""
+
+ global _engineering_brief_dialog
+ if _engineering_brief_dialog is not None:
+ try:
+ _engineering_brief_dialog.show()
+ _engineering_brief_dialog.raise_()
+ _engineering_brief_dialog.activateWindow()
+ return
+ except RuntimeError:
+ _engineering_brief_dialog = None
+ dock = _find_dock()
+ if dock is None or not _require_assistant_document(dock):
+ return
+ if not _internal_agent_allowed():
+ _render_assistant_run_state(dock)
+ return
+ if _is_assistant_run_active():
+ _set_status_line(
+ "Wait for the current CAD run to finish before developing a brief.",
+ dock=dock,
+ )
+ return
+
+ from VibeCADEngineeringBrief import (
+ add_active_conversation_context,
+ EngineeringBriefStore,
+ engineering_brief_handoff,
+ new_engineering_brief,
+ run_engineering_brief_turn,
+ )
+ from VibeCADEngineeringBriefGui import EngineeringBriefDialog
+
+ service = get_service()
+ try:
+ _ensure_document_thread_invoker()
+ identity, context, prepared_history = _engineering_brief_context(service)
+ store = EngineeringBriefStore(identity["project_root"])
+ loaded = store.load(
+ document_uid=identity["document_uid"],
+ conversation_id=identity["conversation_id"],
+ )
+ except Exception as exc:
+ _set_status_line(f"Could not open the Engineering Brief: {exc}", dock=dock)
+ return
+ prompt_box = _find_child("QPlainTextEdit", "VibePrompt", dock)
+ composer_request = (
+ str(prompt_box.toPlainText() or "").strip() if prompt_box is not None else ""
+ )
+ loaded_state = loaded.get("state") if loaded.get("recoverable") else None
+ if isinstance(loaded_state, dict) and (
+ not composer_request
+ or composer_request == str(loaded_state.get("original_request") or "").strip()
+ ):
+ initial_state = loaded_state
+ else:
+ initial_state = new_engineering_brief(
+ composer_request,
+ identity=identity,
+ context=context,
+ )
+
+ def run_turn(state: dict[str, Any], **arguments: Any) -> dict[str, Any]:
+ from VibeCADSession import choose_provider
+
+ history = service.complete_conversation_history_read(prepared_history)
+ loaded_conversation_id = str(history.get("conversation_id") or "").lower()
+ if loaded_conversation_id != str(state.get("conversation_id") or "").lower():
+ raise RuntimeError(
+ "The active conversation changed before its context could be loaded. "
+ "Close this brief and reopen it in the conversation you want to use."
+ )
+ state_with_conversation = add_active_conversation_context(
+ state,
+ _recent_conversation_payload(history.get("conversation") or []),
+ )
+ provider = _dispatch_to_document_thread(
+ lambda: choose_provider(
+ service,
+ prefer_online=service.use_online_provider_by_default(),
+ )
+ )
+ return run_engineering_brief_turn(
+ state_with_conversation,
+ provider=provider,
+ **arguments,
+ )
+
+ def start_brief(state: dict[str, Any], readable: str) -> bool:
+ current_identity, _current_context, _current_history = (
+ _engineering_brief_context(service)
+ )
+ if current_identity["document_uid"] != state.get(
+ "document_uid"
+ ) or current_identity["conversation_id"] != state.get("conversation_id"):
+ raise RuntimeError(
+ "The active document or conversation changed. Reopen the brief there "
+ "before starting CAD work."
+ )
+ if _is_assistant_run_active():
+ raise RuntimeError("Wait for the current CAD run to finish.")
+ assistant_state = service.assistant_document_state()
+ if not assistant_state.get("enabled") or not assistant_state.get(
+ "turn_enabled", True
+ ):
+ raise RuntimeError(
+ str(
+ assistant_state.get("message")
+ or "Choose Native or VibeScript before starting CAD work."
+ )
+ )
+ handoff = engineering_brief_handoff(state, approved_text=readable)
+ _append_conversation("User", handoff)
+ if prompt_box is not None:
+ _clear_prompt_without_recovery(prompt_box)
+ _execute_assistant_run(dock, service, prompt=handoff)
+ return True
+
+ dialog = EngineeringBriefDialog(
+ initial_state,
+ turn_runner=run_turn,
+ persist_callback=store.write,
+ start_callback=start_brief,
+ parent=Gui.getMainWindow(),
+ )
+ dialog.destroyed.connect(_engineering_brief_dialog_destroyed)
+ _engineering_brief_dialog = dialog
+ dialog.show()
+ dialog.raise_()
+ dialog.activateWindow()
+
+
# ---------------------------------------------------------------------------
# Run / stop / steering
# ---------------------------------------------------------------------------
@@ -3133,9 +3339,7 @@ def _render_assistant_run_state(dock: Any, text: str | None = None) -> None:
internal_available = bool(control.get("internal_agent_enabled"))
document_state = _assistant_document_state()
document_ready = bool(document_state.get("enabled"))
- turn_ready = document_ready and bool(
- document_state.get("turn_enabled", True)
- )
+ turn_ready = document_ready and bool(document_state.get("turn_enabled", True))
pending_sketch = _sketch_close_continuation_controller.snapshot()
dock.setProperty("VibeRunActive", busy)
dock.setProperty("VibeCancelRequested", cancel_requested)
@@ -3146,6 +3350,7 @@ def _render_assistant_run_state(dock: Any, text: str | None = None) -> None:
prompt_box = _find_child("QPlainTextEdit", "VibePrompt", dock)
attach_button = _find_child("QPushButton", "VibeAttachView", dock)
attach_image_button = _find_child("QPushButton", "VibeAttachImage", dock)
+ engineering_brief_button = _find_child("QPushButton", "VibeEngineeringBrief", dock)
reference_chips = _find_child("QWidget", "VibeReferenceChips", dock)
conversation_selector = _find_child("QComboBox", "VibeConversationSelector", dock)
new_conversation = _find_child("QToolButton", "VibeNewConversation", dock)
@@ -3155,9 +3360,7 @@ def _render_assistant_run_state(dock: Any, text: str | None = None) -> None:
if send_button is not None:
send_button.setEnabled(
- internal_available
- and turn_ready
- and not cancel_requested
+ internal_available and turn_ready and not cancel_requested
)
if stop_button is not None:
stop_button.setEnabled(busy and not cancel_requested)
@@ -3167,6 +3370,10 @@ def _render_assistant_run_state(dock: Any, text: str | None = None) -> None:
attach_image_button.setEnabled(
internal_available and document_ready and not busy
)
+ if engineering_brief_button is not None:
+ engineering_brief_button.setEnabled(
+ internal_available and document_ready and not busy
+ )
if reference_chips is not None:
reference_chips.setEnabled(internal_available and document_ready and not busy)
if conversation_selector is not None:
@@ -3187,9 +3394,7 @@ def _render_assistant_run_state(dock: Any, text: str | None = None) -> None:
if prompt_box is not None:
prompt_box.setEnabled(internal_available and document_ready)
prompt_box.setReadOnly(
- not internal_available
- or cancel_requested
- or not document_ready
+ not internal_available or cancel_requested or not document_ready
)
if not internal_available:
placeholder = "VibeCAD is controlled by an external MCP client."
@@ -3220,13 +3425,11 @@ def _render_assistant_run_state(dock: Any, text: str | None = None) -> None:
status_text = text or ""
elif not document_ready:
status_text = str(
- document_state.get("message")
- or "Create or open a document to use VibeCAD."
+ document_state.get("message") or "Create or open a document to use VibeCAD."
)
elif not turn_ready:
status_text = str(
- document_state.get("message")
- or "Choose Native or VibeScript to begin."
+ document_state.get("message") or "Choose Native or VibeScript to begin."
)
else:
if text:
@@ -3439,9 +3642,11 @@ def _execute_assistant_run(
"Sketch closed. Continuing the CAD work..."
if continuation_event
and continuation_event.get("type") == "human_closed_sketch"
- else "CAD work changed. Continuing the design..."
- if continuation_event
- else None
+ else (
+ "CAD work changed. Continuing the design..."
+ if continuation_event
+ else None
+ )
),
)
_clear_thinking(dock)
@@ -3691,8 +3896,7 @@ def _start_sketch_close_continuation(event: dict[str, Any]) -> None:
_render_assistant_run_state(
dock,
text=str(
- state.get("message")
- or "Create or open a document to use VibeCAD."
+ state.get("message") or "Create or open a document to use VibeCAD."
),
)
return
@@ -3707,9 +3911,7 @@ def _start_native_surface_continuation(event: dict[str, Any]) -> None:
if not _internal_agent_allowed():
return
if _is_assistant_run_active() or _is_intent_memory_rebuild_active():
- _warn(
- "VibeCAD ignored a workspace continuation while another run was active."
- )
+ _warn("VibeCAD ignored a workspace continuation while another run was active.")
return
document = getattr(App, "ActiveDocument", None)
if document is None:
@@ -3767,8 +3969,7 @@ def _start_native_surface_continuation(event: dict[str, Any]) -> None:
_render_assistant_run_state(
dock,
text=str(
- state.get("message")
- or "Create or open a document to use VibeCAD."
+ state.get("message") or "Create or open a document to use VibeCAD."
),
)
return
@@ -3797,8 +3998,7 @@ def _run_prompt_from_panel() -> None:
_render_assistant_run_state(
dock,
text=str(
- state.get("message")
- or "Create or open a document to use VibeCAD."
+ state.get("message") or "Create or open a document to use VibeCAD."
),
)
return
@@ -3920,9 +4120,7 @@ def _live_document_for_storage_key(
if named_document is not None:
candidates.append(named_document)
candidates.extend(
- document
- for name, document in live_documents.items()
- if name != document_name
+ document for name, document in live_documents.items() if name != document_name
)
for candidate in candidates:
try:
@@ -3954,8 +4152,7 @@ def _timeline_resource_owner(document: Any, obj: Any) -> Any | None:
return None
property_type = getattr(obj, "getTypeIdOfProperty", None)
if callable(property_type) and (
- property_type("VibeCADTimelineOwner")
- != "App::PropertyLinkHidden"
+ property_type("VibeCADTimelineOwner") != "App::PropertyLinkHidden"
):
return None
owner = getattr(obj, "VibeCADTimelineOwner", None)
@@ -3975,11 +4172,7 @@ def _timeline_object_is_active(document: Any, obj: Any) -> bool:
try:
get_object = getattr(document, "getObject", None)
- timeline = (
- get_object("VibeCADTimeline")
- if callable(get_object)
- else None
- )
+ timeline = get_object("VibeCADTimeline") if callable(get_object) else None
if timeline is None:
return True
@@ -4024,18 +4217,13 @@ def _timeline_object_is_active(document: Any, obj: Any) -> bool:
if operation_index >= position:
return False
- suppression = list(
- getattr(timeline, "SuppressionAtEnd", []) or []
- )
+ suppression = list(getattr(timeline, "SuppressionAtEnd", []) or [])
for owner in owners:
try:
owner_index = operations.index(owner)
except ValueError:
continue
- if (
- owner_index < len(suppression)
- and bool(suppression[owner_index])
- ):
+ if owner_index < len(suppression) and bool(suppression[owner_index]):
return False
return True
except (AttributeError, ReferenceError, RuntimeError, TypeError, ValueError):
@@ -4100,9 +4288,7 @@ def _recompute_pending_document_geometry(document: Any) -> bool:
return False
try:
gui_document = Gui.getDocument(str(document.Name))
- was_modified = (
- bool(gui_document.Modified) if gui_document is not None else None
- )
+ was_modified = bool(gui_document.Modified) if gui_document is not None else None
except Exception:
gui_document = None
was_modified = None
@@ -4180,9 +4366,7 @@ def _restore_precomputed_projection_slice(
for obj in list(getattr(document, "Objects", []) or []):
object_name = str(getattr(obj, "Name", "") or "")
restore = getattr(obj, "restorePrecomputedState", None)
- source_state = str(
- getattr(obj, "PrecomputedProjectionSourceState", "") or ""
- )
+ source_state = str(getattr(obj, "PrecomputedProjectionSourceState", "") or "")
if (
object_name
and object_name not in attempted
@@ -4489,9 +4673,7 @@ def render_when_stable() -> None:
restored_projection_names,
)
)
- geometry_recomputed_any = (
- geometry_recomputed_any or projection_restored
- )
+ geometry_recomputed_any = geometry_recomputed_any or projection_restored
restore_modified_state(live_document)
if projections_remaining:
QtCore.QTimer.singleShot(0, render_when_stable)
@@ -4597,14 +4779,13 @@ def _move_saved_document_conversation(doc: Any, filepath: str) -> None:
target_file = str(filepath or "").strip()
if not current_file or not target_file:
return
- if Path(current_file).expanduser().resolve() != Path(
- target_file
- ).expanduser().resolve():
+ if (
+ Path(current_file).expanduser().resolve()
+ != Path(target_file).expanduser().resolve()
+ ):
return
conversation_store_path = str(snapshot.get("store_path") or "").strip()
- temporary_project_root = str(
- snapshot.get("temporary_project_root") or ""
- ).strip()
+ temporary_project_root = str(snapshot.get("temporary_project_root") or "").strip()
relocation_succeeded = True
if conversation_store_path:
try:
@@ -4744,16 +4925,12 @@ def _refresh_native_authority_selector(document_uid: str = "") -> None:
_schedule_native_authority_selector_refresh(document_uid)
def slotCreatedDocument(self, doc) -> None:
- get_service().ensure_native_document_state(
- str(getattr(doc, "Uid", "") or "")
- )
+ get_service().ensure_native_document_state(str(getattr(doc, "Uid", "") or ""))
_schedule_document_render_after_restore(doc)
_schedule_assistant_document_refresh()
def slotActivateDocument(self, doc) -> None:
- get_service().ensure_native_document_state(
- str(getattr(doc, "Uid", "") or "")
- )
+ get_service().ensure_native_document_state(str(getattr(doc, "Uid", "") or ""))
pending = _sketch_close_continuation_controller.snapshot()
active_uid = str(getattr(doc, "Uid", "") or "")
if pending and pending.get("document_uid") != active_uid:
@@ -4800,7 +4977,9 @@ def slotChangedObject(self, obj, property_name) -> None:
if document is not None:
get_service().invalidate_vibescript_reference_snapshots(obj)
try:
- from VibeCADVibeScriptDomainPublication import mark_programs_stale_from_source
+ from VibeCADVibeScriptDomainPublication import (
+ mark_programs_stale_from_source,
+ )
marked = mark_programs_stale_from_source(obj, str(property_name or ""))
except Exception as exc:
@@ -4857,9 +5036,7 @@ def slotDeletedDocument(self, doc) -> None:
get_service().discard_unsaved_document_project(doc)
except Exception as exc:
_warn(f"VibeCAD temporary project cleanup failed: {exc}")
- get_service().discard_session_modeling_engine(
- document_uid
- )
+ get_service().discard_session_modeling_engine(document_uid)
get_service().close_native_document_state(document_uid)
get_service().clear_vibescript_reference_snapshots(
str(getattr(doc, "Uid", "") or "")
@@ -5065,9 +5242,7 @@ def _build_panel_widget():
"Choose whether VibeCAD authors through source or direct ribbon tools"
)
authoring_mode.setEnabled(False)
- authoring_mode.currentIndexChanged.connect(
- _select_authoring_mode_from_header
- )
+ authoring_mode.currentIndexChanged.connect(_select_authoring_mode_from_header)
conversation_header_layout.addWidget(authoring_mode)
new_conversation = QtWidgets.QToolButton(conversation_header)
@@ -5216,9 +5391,7 @@ def _build_panel_widget():
prompt_starters.setIconSize(icon_size)
prompt_starters.setToolTip("Insert an editable prompt starter")
prompt_starters.setEnabled(False)
- prompt_starters.setPopupMode(
- QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup
- )
+ prompt_starters.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup)
prompt_starter_menu = QtWidgets.QMenu(prompt_starters)
prompt_starter_menu.setObjectName("VibePromptStarterMenu")
prompt_starter_menu.aboutToShow.connect(
@@ -5226,6 +5399,18 @@ def _build_panel_widget():
)
prompt_starters.setMenu(prompt_starter_menu)
+ engineering_brief_button = QtWidgets.QPushButton(
+ "Engineering Brief", composer_buttons
+ )
+ engineering_brief_button.setObjectName("VibeEngineeringBrief")
+ engineering_brief_button.setIcon(QtGui.QIcon(_icon_path(ICON_ENGINEERING_BRIEF)))
+ engineering_brief_button.setIconSize(icon_size)
+ engineering_brief_button.setToolTip(
+ "Turn a rough request into a reviewable engineering brief"
+ )
+ engineering_brief_button.setEnabled(False)
+ engineering_brief_button.clicked.connect(_open_engineering_brief_from_panel)
+
send_button = QtWidgets.QPushButton("Send", composer_buttons)
send_button.setObjectName("VibeSend")
send_button.setIcon(QtGui.QIcon(_icon_path(ICON_SEND)))
@@ -5244,6 +5429,7 @@ def _build_panel_widget():
stop_button.clicked.connect(_stop_prompt_from_panel)
buttons_layout.addWidget(prompt_starters)
+ buttons_layout.addWidget(engineering_brief_button)
buttons_layout.addWidget(attach_button)
buttons_layout.addWidget(attach_image_button)
buttons_layout.addStretch(1)
@@ -5567,11 +5753,7 @@ def _selection() -> tuple[Any, Any] | None:
if len(selected) != 2:
return None
lcs = next(
- (
- obj
- for obj in selected
- if is_native_coordinate_system(obj)
- ),
+ (obj for obj in selected if is_native_coordinate_system(obj)),
None,
)
if lcs is None:
@@ -5714,10 +5896,8 @@ def _selected_scripted_model_operation(
is not operation
or str(getattr(operation, "TypeId", "") or "")
!= "PartDesign::DesignScriptOperation"
- or str(getattr(operation, "VibeCADTimelineRole", "") or "")
- != "operation"
- or str(getattr(operation, command_property, "") or "")
- != command_name
+ or str(getattr(operation, "VibeCADTimelineRole", "") or "") != "operation"
+ or str(getattr(operation, command_property, "") or "") != command_name
):
return None
program_id = str(getattr(operation, "ProgramId", "") or "")
@@ -5733,8 +5913,7 @@ def _selected_scripted_model_operation(
if root is not None:
if (
str(getattr(root, PROP_PROGRAM_ID, "") or "") != program_id
- or str(getattr(root, PROP_PROGRAM_DOMAIN, "") or "")
- != "partdesign"
+ or str(getattr(root, PROP_PROGRAM_DOMAIN, "") or "") != "partdesign"
):
return None
else:
@@ -5743,12 +5922,10 @@ def _selected_scripted_model_operation(
# immutable ownership tags are sufficient to dispatch the exact
# lifecycle command; arbitrary History objects still cannot opt in.
if (
- str(getattr(operation, "VibeCADScriptedRole", "") or "")
- != "implementation"
+ str(getattr(operation, "VibeCADScriptedRole", "") or "") != "implementation"
or str(getattr(operation, "VibeCADScriptedEngine", "") or "")
!= "vibescript:partdesign"
- or str(getattr(operation, "VibeCADScriptedModelId", "") or "")
- != program_id
+ or str(getattr(operation, "VibeCADScriptedModelId", "") or "") != program_id
):
return None
return operation
@@ -5846,9 +6023,7 @@ def ensure_preferences_registered() -> None:
Gui.addIconPath(str(Path(__file__).resolve().parent))
Gui.addPreferencePage(VibeCADPreferences.VibeCADPreferencesPage, "VibeCAD")
- Gui.addPreferencePage(
- VibeCADPreferences.VibeCADMCPPreferencesPage, "VibeCAD"
- )
+ Gui.addPreferencePage(VibeCADPreferences.VibeCADMCPPreferencesPage, "VibeCAD")
Gui.addPreferencePage(
VibeCADPreferences.VibeCADPromptStartersPreferencesPage, "VibeCAD"
)
@@ -5897,17 +6072,13 @@ def ensure_commands_registered() -> None:
Gui.addCommand("VibeCAD_OpenPreferences", OpenPreferencesCommand())
Gui.addCommand("VibeCAD_OpenScriptedModel", OpenScriptedModelCommand())
Gui.addCommand("VibeCAD_EditScriptedModel", EditScriptedModelCommand())
- for action in Gui.Command.get(
- "VibeCAD_EditScriptedModel"
- ).ensureAction():
+ for action in Gui.Command.get("VibeCAD_EditScriptedModel").ensureAction():
action.setProperty("VibeCADTimelineOperationEditor", True)
Gui.addCommand(
"VibeCAD_DeleteScriptedModel",
DeleteScriptedModelCommand(),
)
- for action in Gui.Command.get(
- "VibeCAD_DeleteScriptedModel"
- ).ensureAction():
+ for action in Gui.Command.get("VibeCAD_DeleteScriptedModel").ensureAction():
action.setProperty("VibeCADTimelineOperationDeleter", True)
Gui.addCommand("VibeCAD_AuthStatus", AuthStatusCommand())
try:
diff --git a/src/Mod/VibeCAD/VibeCADNativeApplicationManifest.py b/src/Mod/VibeCAD/VibeCADNativeApplicationManifest.py
index 15eb47da..41ff2e3c 100644
--- a/src/Mod/VibeCAD/VibeCADNativeApplicationManifest.py
+++ b/src/Mod/VibeCAD/VibeCADNativeApplicationManifest.py
@@ -120,6 +120,7 @@ def summary(self) -> dict[str, str | None]:
"VibeAttachImage",
"VibePromptStarters",
"VibePromptStarterMenu",
+ "VibeEngineeringBrief",
"VibeSend",
"VibeStop",
}
diff --git a/src/Mod/VibeCAD/VibeCADProvider.py b/src/Mod/VibeCAD/VibeCADProvider.py
index 36947fca..66576ccc 100644
--- a/src/Mod/VibeCAD/VibeCADProvider.py
+++ b/src/Mod/VibeCAD/VibeCADProvider.py
@@ -29,7 +29,6 @@
from VibeCADProviderDrawingResult import provider_visible_drawing_readiness
from VibeCADVibeScriptDomains import get_vibescript_pack
-
MAX_PROVIDER_IMAGE_BYTES = 2_000_000
CODEX_INLINE_IMAGE_MAX_BYTES = MAX_PROVIDER_IMAGE_BYTES
CODEX_LOCAL_IMAGE_MAX_BYTES = 20 * 1024 * 1024
@@ -188,6 +187,9 @@ def _vibescript_authoring_instruction(context: dict[str, Any]) -> str:
def _system_instruction_sections(context: dict[str, Any]) -> list[str]:
"""Ordered system-instruction sections shared by every wire format."""
sections = [VIBECAD_SYSTEM_INSTRUCTIONS]
+ task_instructions = context.get("_vibecad_task_instructions")
+ if isinstance(task_instructions, str) and task_instructions.strip():
+ sections.append(task_instructions.strip())
if _vibescript_surface_active(context):
instruction = _vibescript_authoring_instruction(context)
if instruction:
@@ -456,19 +458,11 @@ def _codex_dynamic_tool_surface(
if namespaced
else _codex_flat_function_name(namespace_name, function_name)
)
- key = (
- (namespace_name, function_name)
- if namespaced
- else ("", flat_name)
- )
+ key = (namespace_name, function_name) if namespaced else ("", flat_name)
if key in names:
raise ProviderUnavailable(
"Duplicate Codex dynamic tool name: "
- + (
- f"{namespace_name}.{function_name}"
- if namespaced
- else flat_name
- )
+ + (f"{namespace_name}.{function_name}" if namespaced else flat_name)
)
names[key] = tool_name
function = {
@@ -499,9 +493,7 @@ def _codex_dynamic_tool_surface(
def _codex_skill_read_tool(*, namespaced: bool = True) -> dict[str, Any]:
function = {
"type": "function",
- "name": (
- "read" if namespaced else _codex_flat_function_name("skills", "read")
- ),
+ "name": ("read" if namespaced else _codex_flat_function_name("skills", "read")),
"description": (
"Read one enabled skill's SKILL.md or a referenced UTF-8 "
"resource contained in that skill directory."
@@ -546,9 +538,7 @@ def _codex_turn_input(prompt: str, context: dict[str, Any]) -> list[dict[str, An
local_references = _codex_local_reference_image_input(visible)
if local_references is not None:
items.extend(local_references)
- image_blocks = [
- block for block in image_blocks if not block[0].startswith("R")
- ]
+ image_blocks = [block for block in image_blocks if not block[0].startswith("R")]
for label, mime_type, data in image_blocks:
items.append({"type": "text", "text": label})
items.append(
@@ -695,9 +685,7 @@ def provider_input_budget(
prompt_text = str(prompt or "")
values = _provider_prompt_section_values(prompt_text)
sections: dict[str, int] = {
- "system_instructions": len(
- _provider_instructions(context).encode("utf-8")
- ),
+ "system_instructions": len(_provider_instructions(context).encode("utf-8")),
"provider_tool_schemas": len(
json.dumps(
_json_safe(list(context.get("provider_tool_schemas") or [])),
@@ -1078,6 +1066,7 @@ def run(
from VibeCADOllama import codex_context_limits, inspect_model
live_context = dict(context)
+ tooless_task = live_context.get("_vibecad_toolless_task") is True
ollama_model: dict[str, Any] = {}
model_context_window: int | None = None
model_auto_compact_token_limit: int | None = None
@@ -1094,13 +1083,11 @@ def run(
f"model: {ollama_model.get('error') or 'unknown error'}"
)
capabilities = set(ollama_model.get("capabilities") or [])
- if capabilities and "tools" not in capabilities:
+ if capabilities and "tools" not in capabilities and not tooless_task:
raise ProviderUnavailable(
f"Ollama model {self.model!r} does not advertise tool calling."
)
- runtime_context = int(
- ollama_model.get("runtime_context_length") or 0
- )
+ runtime_context = int(ollama_model.get("runtime_context_length") or 0)
if runtime_context <= 0:
raise ProviderUnavailable(
"Ollama loaded the selected model but did not report its "
@@ -1125,23 +1112,25 @@ def run(
"provider": "Ollama via Codex",
"model": self.model,
"context_window": model_context_window,
- "auto_compact_token_limit": (
- model_auto_compact_token_limit
- ),
+ "auto_compact_token_limit": (model_auto_compact_token_limit),
},
)
namespaced_tools = _codex_uses_namespaced_tools(
auth_mode=self.auth_mode,
base_url=self.base_url,
)
- dynamic_tools, dynamic_name_map = _codex_dynamic_tool_surface(
- live_context,
- namespaced=namespaced_tools,
- )
- if not dynamic_tools:
- raise ProviderUnavailable(
- "Codex mode has no declared VibeCAD tools for the current workbench."
+ if tooless_task:
+ dynamic_tools: list[dict[str, Any]] = []
+ dynamic_name_map: dict[tuple[str, str], str] = {}
+ else:
+ dynamic_tools, dynamic_name_map = _codex_dynamic_tool_surface(
+ live_context,
+ namespaced=namespaced_tools,
)
+ if not dynamic_tools:
+ raise ProviderUnavailable(
+ "Codex mode has no declared VibeCAD tools for the current workbench."
+ )
skill_call_key = (
("skills", "read")
if namespaced_tools
@@ -1349,9 +1338,7 @@ def _server_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
arguments_json = json.dumps(
_json_safe(arguments), ensure_ascii=True, separators=(",", ":")
)
- provider_call_id = str(
- params.get("callId") or params.get("call_id") or ""
- )
+ provider_call_id = str(params.get("callId") or params.get("call_id") or "")
_emit_provider_progress(
progress_callback,
{
@@ -1421,9 +1408,7 @@ def _server_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
sdk_call="codex-app-server.turn/steer",
turn=1,
request=steer_request,
- base_url=(
- self.base_url if self.auth_mode == "api_key" else None
- ),
+ base_url=(self.base_url if self.auth_mode == "api_key" else None),
)
try:
client.request("turn/steer", steer_request, timeout=30.0)
@@ -1494,9 +1479,7 @@ def server_response_sent(method: str) -> None:
"web_search_enabled": self.web_search_enabled,
"skills_enabled": self.skills_enabled,
"model_context_window": model_context_window,
- "model_auto_compact_token_limit": (
- model_auto_compact_token_limit
- ),
+ "model_auto_compact_token_limit": (model_auto_compact_token_limit),
"api_key_sha256": (
hashlib.sha256(self.api_key.encode("utf-8")).hexdigest()
if self.api_key
@@ -1517,9 +1500,7 @@ def server_response_sent(method: str) -> None:
session_identity.get("conversation_path") or ""
),
"engine": str(thread_declaration.get("engine") or ""),
- "schema_sha256": str(
- thread_declaration.get("schema_sha256") or ""
- ),
+ "schema_sha256": str(thread_declaration.get("schema_sha256") or ""),
}
thread_key = hashlib.sha256(
json.dumps(
@@ -1578,7 +1559,7 @@ def server_response_sent(method: str) -> None:
)
update_cached_account(account)
- if self.skills_enabled:
+ if self.skills_enabled and not tooless_task:
skill_catalog = load_codex_skill_catalog(
client,
cwd=codex_workspace(),
@@ -1597,13 +1578,21 @@ def server_response_sent(method: str) -> None:
"browser automation",
"computer-control",
]
- if not self.web_search_enabled:
+ task_web_search_enabled = self.web_search_enabled and not tooless_task
+ if not task_web_search_enabled:
forbidden_capabilities.append("web")
- developer_instructions = (
- "Operate only through the supplied VibeCAD tools. Do not "
- f"use {', '.join(forbidden_capabilities)} tools."
- )
- if self.skills_enabled and skill_catalog:
+ if tooless_task:
+ developer_instructions = (
+ "Complete this non-mutating text-only VibeCAD task directly. "
+ "Do not call tools. Do not use "
+ f"{', '.join(forbidden_capabilities)} tools."
+ )
+ else:
+ developer_instructions = (
+ "Operate only through the supplied VibeCAD tools. Do not "
+ f"use {', '.join(forbidden_capabilities)} tools."
+ )
+ if self.skills_enabled and skill_catalog and not tooless_task:
developer_instructions += (
" Read selected skill instructions and referenced resources "
"only through skills.read."
@@ -1619,17 +1608,13 @@ def server_response_sent(method: str) -> None:
"environments": [],
"dynamicTools": dynamic_tools,
"config": vibecad_thread_config(
- web_search_enabled=self.web_search_enabled,
- skills_enabled=self.skills_enabled,
+ web_search_enabled=task_web_search_enabled,
+ skills_enabled=self.skills_enabled and not tooless_task,
openai_base_url=(
- (codex_base_url or "")
- if self.auth_mode == "api_key"
- else None
+ (codex_base_url or "") if self.auth_mode == "api_key" else None
),
model_context_window=model_context_window,
- model_auto_compact_token_limit=(
- model_auto_compact_token_limit
- ),
+ model_auto_compact_token_limit=(model_auto_compact_token_limit),
),
"serviceName": "vibecad",
}
@@ -1645,9 +1630,7 @@ def server_response_sent(method: str) -> None:
sdk_call="codex-app-server.thread/resume",
turn=1,
request=resume_request,
- base_url=(
- self.base_url if self.auth_mode == "api_key" else None
- ),
+ base_url=(self.base_url if self.auth_mode == "api_key" else None),
)
thread_result = client.request(
"thread/resume",
@@ -1661,9 +1644,7 @@ def server_response_sent(method: str) -> None:
sdk_call="codex-app-server.thread/start",
turn=1,
request=thread_request,
- base_url=(
- self.base_url if self.auth_mode == "api_key" else None
- ),
+ base_url=(self.base_url if self.auth_mode == "api_key" else None),
)
thread_result = client.request(
"thread/start", thread_request, timeout=30.0
@@ -1674,9 +1655,7 @@ def server_response_sent(method: str) -> None:
if not isinstance(thread, dict) or not thread.get("id"):
raise ProviderUnavailable("Codex app-server created no VibeCAD thread.")
thread_id = str(thread["id"])
- resumed_thread = bool(
- managed_lease is not None and managed_lease.thread_id
- )
+ resumed_thread = bool(managed_lease is not None and managed_lease.thread_id)
if managed_lease is not None:
managed_lease.remember_thread(thread_id)
@@ -1751,10 +1730,7 @@ def server_response_sent(method: str) -> None:
transition_interrupt_sent = False
while not turn_completed.wait(0.05):
- if (
- transition_response_sent.is_set()
- and not transition_interrupt_sent
- ):
+ if transition_response_sent.is_set() and not transition_interrupt_sent:
transition_interrupt_sent = True
client.request(
"turn/interrupt",
@@ -1846,9 +1822,7 @@ def server_response_sent(method: str) -> None:
{
"ollama": {
"model": self.model,
- "server_version": ollama_model.get(
- "server_version"
- ),
+ "server_version": ollama_model.get("server_version"),
"context_window": model_context_window,
"auto_compact_token_limit": (
model_auto_compact_token_limit
@@ -1969,10 +1943,19 @@ def run(
) -> ProviderResult:
try:
provider_context = dict(context)
- provider_context["_vibecad_provider_options"] = {
- "web_search_enabled": self.web_search_enabled,
- "compaction_model": self.compaction_model,
- }
+ provider_options = dict(
+ provider_context.get("_vibecad_provider_options") or {}
+ )
+ provider_options.update(
+ {
+ "web_search_enabled": (
+ self.web_search_enabled
+ and provider_context.get("_vibecad_toolless_task") is not True
+ ),
+ "compaction_model": self.compaction_model,
+ }
+ )
+ provider_context["_vibecad_provider_options"] = provider_options
return _run_provider_subprocess(
prompt=prompt,
context=provider_context,
@@ -2809,8 +2792,7 @@ def _provider_tool_parameters(schema: dict[str, Any]) -> dict[str, Any]:
for branch in branches
]
if all(
- isinstance(operation, str) and operation
- for operation in operations
+ isinstance(operation, str) and operation for operation in operations
):
parameters = {
"type": "object",
@@ -3020,10 +3002,7 @@ def _provider_compact_native_mutation_value(
or not str(key).endswith("_state_sha256")
)
)
- or (
- not expose_state_hashes
- and not str(key).endswith("sha256")
- )
+ or (not expose_state_hashes and not str(key).endswith("sha256"))
)
}
if expose_state_hashes:
@@ -3031,9 +3010,7 @@ def _provider_compact_native_mutation_value(
digest = value.get(key)
if isinstance(digest, str) and len(digest) == 64:
visible[f"expected_{key}"] = digest
- state_sha256 = value.get("state_sha256") or value.get(
- "view_state_sha256"
- )
+ state_sha256 = value.get("state_sha256") or value.get("view_state_sha256")
if (
value.get("object_name")
and "expected_state_sha256" not in visible
@@ -3085,9 +3062,7 @@ def _provider_visible_tool_result(
visible = dict(result)
visible.pop("_vibecad_image_attachment", None)
native_result = bool(visible.pop("_vibecad_native_result", False))
- source_lifecycle = bool(
- visible.pop("_vibecad_source_lifecycle_result", False)
- )
+ source_lifecycle = bool(visible.pop("_vibecad_source_lifecycle_result", False))
source_read = bool(visible.pop("_vibecad_source_read_result", False))
geometry_request = visible.pop("_vibecad_geometry_read_request", None)
complete_read = bool(
@@ -3118,9 +3093,11 @@ def _provider_visible_tool_result(
visible_job = dict(job)
visible_job["result"] = _provider_visible_native_mutation_result(
nested_result,
- capability=str(nested_receipt.get("capability") or "")
- if isinstance(nested_receipt, dict)
- else "",
+ capability=(
+ str(nested_receipt.get("capability") or "")
+ if isinstance(nested_receipt, dict)
+ else ""
+ ),
)
visible["job"] = visible_job
visible = _provider_hide_internal_program_ids(visible)
@@ -3135,9 +3112,7 @@ def _provider_visible_tool_result(
# this, one collision summary is repeated through candidate outputs,
# publication metadata, and live outputs until useful data crosses the
# provider byte boundary.
- visible["result"] = _provider_visible_source_lifecycle_result(
- visible["result"]
- )
+ visible["result"] = _provider_visible_source_lifecycle_result(visible["result"])
elif source_read:
visible = _provider_visible_source_read_result(visible)
elif isinstance(geometry_request, dict):
@@ -3418,9 +3393,7 @@ def _provider_compact_failure_details(value: Any) -> dict[str, Any]:
"correction",
)
result = {
- key: value[key]
- for key in fields
- if value.get(key) not in (None, "", [], {})
+ key: value[key] for key in fields if value.get(key) not in (None, "", [], {})
}
issues = value.get("issues")
if isinstance(issues, list) and issues:
@@ -3580,11 +3553,7 @@ def _provider_visible_source_lifecycle_result(
if observed:
compact["observed"] = observed
- source_available = bool(
- program
- and revision
- and not result.get("source_deleted")
- )
+ source_available = bool(program and revision and not result.get("source_deleted"))
if source_available and result.get("ok") is not True:
actions: list[dict[str, Any]] = [
{
@@ -3784,7 +3753,10 @@ def _provider_visible_geometry_read_result(
if matrix and list(matrix) != identity:
compact["placement"] = placement
shape_revision = result.get("shape_revision")
- if isinstance(shape_revision, dict) and shape_revision.get("shape_hash") is not None:
+ if (
+ isinstance(shape_revision, dict)
+ and shape_revision.get("shape_hash") is not None
+ ):
compact["selection_revision"] = {
"shape_hash": shape_revision["shape_hash"],
"rule": "Read geometry again after this object's topology changes.",
@@ -4264,9 +4236,7 @@ def _anthropic_user_content(
# prefix can be reused across requests. They remain in the logical input;
# prompt caching only avoids reprocessing identical bytes.
reference_blocks = [block for block in blocks if block[0].startswith("R")]
- observation_blocks = [
- block for block in blocks if not block[0].startswith("R")
- ]
+ observation_blocks = [block for block in blocks if not block[0].startswith("R")]
content: list[dict[str, Any]] = []
for label_text, mime_type, image_data in reference_blocks:
content.append({"type": "text", "text": label_text})
@@ -4629,9 +4599,7 @@ def _anthropic_response_summary(response: Any) -> dict[str, Any]:
else:
model_dump = getattr(raw_usage, "model_dump", None)
dumped = (
- model_dump(mode="json", exclude_none=True)
- if callable(model_dump)
- else {}
+ model_dump(mode="json", exclude_none=True) if callable(model_dump) else {}
)
usage_payload = dumped if isinstance(dumped, dict) else {}
token_usage = {
@@ -4880,9 +4848,7 @@ def _anthropic_compaction_tool_event(
"characters": len(source),
}
if "input_schema" in safe_arguments:
- argument_summary["input_schema"] = {
- "omitted": "readable_input_schema"
- }
+ argument_summary["input_schema"] = {"omitted": "readable_input_schema"}
def project_result(value: Any, depth: int = 0) -> dict[str, Any]:
if not isinstance(value, dict) or depth >= 3:
@@ -4925,9 +4891,7 @@ def _anthropic_turn_compaction_packet(
"cad_tool_events": list(tool_events[-24:]),
}
if previous_compaction:
- packet["previous_compaction"] = _bounded_compaction_value(
- previous_compaction
- )
+ packet["previous_compaction"] = _bounded_compaction_value(previous_compaction)
def packet_bytes() -> int:
return len(
@@ -5049,9 +5013,7 @@ def _anthropic_recovery_request_tools(
def _anthropic_recovery_terminal_output(block: Any) -> str | None:
- name = str(
- getattr(block, "name", None) or _object_payload(block).get("name") or ""
- )
+ name = str(getattr(block, "name", None) or _object_payload(block).get("name") or "")
value = getattr(block, "input", None)
if value is None:
value = _object_payload(block).get("input")
@@ -5445,7 +5407,9 @@ def _gemini_forced_tool_completion(
)
function = getattr(calls[0], "function", None)
if str(getattr(function, "name", None) or "") != function_name:
- raise RuntimeError(f"Google Gemini {operation_label} called the wrong function.")
+ raise RuntimeError(
+ f"Google Gemini {operation_label} called the wrong function."
+ )
arguments_json = str(getattr(function, "arguments", None) or "")
try:
arguments = json.loads(arguments_json)
@@ -5627,9 +5591,7 @@ def build_tool_surface(
accumulated["name"] += function_name
else:
accumulated["name"] = function_name
- arguments_delta = str(
- getattr(function, "arguments", None) or ""
- )
+ arguments_delta = str(getattr(function, "arguments", None) or "")
if arguments_delta:
if (
str(accumulated["arguments"]).strip() == "{}"
@@ -5817,9 +5779,12 @@ def _anthropic_child_main(
try:
live_context = dict(context)
web_search_enabled = _provider_option(live_context, "web_search_enabled")
- compaction_model = str(
- _provider_option_value(live_context, "compaction_model") or model
- ).strip() or model
+ compaction_model = (
+ str(
+ _provider_option_value(live_context, "compaction_model") or model
+ ).strip()
+ or model
+ )
def build_tool_surface(
surface_context: dict[str, Any],
@@ -5892,8 +5857,10 @@ def build_tool_surface(
"max_tokens": max_tokens,
"cache_control": {"type": "ephemeral"},
"system": system_blocks,
- "tools": _anthropic_request_tools(tool_definitions, web_search_enabled),
}
+ request_tools = _anthropic_request_tools(tool_definitions, web_search_enabled)
+ if request_tools:
+ request_kwargs["tools"] = request_tools
if thinking is not None:
request_kwargs["thinking"] = thinking
request_kwargs["output_config"] = {
@@ -5913,7 +5880,7 @@ def _stream_response(turn: int, attempt: int) -> Any:
if recovery_required:
sdk_request["max_tokens"] = max_tokens
sdk_request["tools"] = _anthropic_recovery_request_tools(
- list(request_kwargs["tools"])
+ list(request_kwargs.get("tools") or [])
)
sdk_request["tool_choice"] = {"type": "auto"}
sdk_request["system"] = [
@@ -5939,7 +5906,7 @@ def _stream_response(turn: int, attempt: int) -> Any:
"attempt": attempt,
"model": model,
"message_count": len(messages),
- "tool_count": len(request_kwargs["tools"]),
+ "tool_count": len(sdk_request.get("tools") or []),
"max_tokens": sdk_request["max_tokens"],
"thinking": request_kwargs.get("thinking"),
"output_config": sdk_request.get("output_config"),
@@ -6288,9 +6255,13 @@ def _stream_response_with_retries(turn: int) -> Any:
if isinstance(updated_context, dict):
live_context = updated_context
tools_by_name, tool_definitions = build_tool_surface(live_context)
- request_kwargs["tools"] = _anthropic_request_tools(
+ refreshed_tools = _anthropic_request_tools(
tool_definitions, web_search_enabled
)
+ if refreshed_tools:
+ request_kwargs["tools"] = refreshed_tools
+ else:
+ request_kwargs.pop("tools", None)
state_after = _provider_state_after_tool(
live_context,
result if isinstance(result, dict) else None,
diff --git a/src/Mod/VibeCAD/vibecad-engineering-brief.svg b/src/Mod/VibeCAD/vibecad-engineering-brief.svg
new file mode 100644
index 00000000..7c6bfaec
--- /dev/null
+++ b/src/Mod/VibeCAD/vibecad-engineering-brief.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/src/Mod/VibeCAD/vibecad_tests/engineering_brief_gui_integration.py b/src/Mod/VibeCAD/vibecad_tests/engineering_brief_gui_integration.py
new file mode 100644
index 00000000..05847829
--- /dev/null
+++ b/src/Mod/VibeCAD/vibecad_tests/engineering_brief_gui_integration.py
@@ -0,0 +1,203 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+"""Clean-profile GUI acceptance gate for the Engineering Brief window."""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import threading
+import time
+import traceback
+
+source_root = str(os.environ.get("VIBECAD_TEST_SOURCE_ROOT") or "").strip()
+if source_root:
+ sys.path.insert(0, source_root)
+
+import FreeCADGui as Gui
+from PySide import QtCore, QtWidgets
+
+from VibeCADEngineeringBrief import (
+ new_engineering_brief,
+ parse_engineering_brief_result,
+)
+from VibeCADEngineeringBriefGui import EngineeringBriefDialog
+
+
+def _run() -> None:
+ application = QtWidgets.QApplication.instance()
+ main_thread = threading.get_ident()
+ dialog = None
+ poll_timer = QtCore.QTimer()
+ tick_timer = QtCore.QTimer()
+ tick_count = 0
+ persisted: list[dict] = []
+ started: list[tuple[dict, str]] = []
+ exit_code = 1
+ turn_count = 0
+ phase = 0
+
+ def finish(code: int) -> None:
+ nonlocal exit_code
+ exit_code = code
+ poll_timer.stop()
+ tick_timer.stop()
+ if dialog is not None:
+ dialog.close()
+ application.exit(exit_code)
+
+ try:
+ identity = {
+ "project_root": "unused-by-injected-store",
+ "document_uid": "brief-gui-document",
+ "conversation_id": "d" * 32,
+ }
+ context = {
+ "workbench": "PartDesignWorkbench",
+ "units": {"schema": 0, "length_example": "1.00 mm"},
+ "document": {
+ "name": "BriefGuiDocument",
+ "uid": "brief-gui-document",
+ "object_count": 1,
+ },
+ "selection": {
+ "selection_count": 1,
+ "selection": [{"object": "Body", "label": "Bracket"}],
+ },
+ }
+ state = new_engineering_brief(
+ "Design a motor bracket.",
+ identity=identity,
+ context=context,
+ )
+ payload = {
+ "assistant_message": "What vertical service load must it support?",
+ "next_question": "What vertical service load must it support?",
+ "ready": False,
+ "brief": {
+ "objective": "Design a motor bracket.",
+ "deliverables": ["Editable 3D model"],
+ "existing_geometry": ["Use selected Bracket body"],
+ "units": "mm, N, MPa",
+ "dimensions": [],
+ "materials": [],
+ "interfaces": [],
+ "loads": [],
+ "manufacturing": [],
+ "tolerances": [],
+ "analyses": [],
+ "acceptance_criteria": [],
+ "requirements": ["Remain editable"],
+ "preferences": [],
+ },
+ "assumptions": [],
+ "open_questions": ["Vertical service load"],
+ }
+ ready_payload = json.loads(json.dumps(payload))
+ ready_payload.update(
+ {
+ "assistant_message": "The engineering brief is ready for review.",
+ "next_question": "",
+ "ready": True,
+ "open_questions": [],
+ }
+ )
+ ready_payload["brief"]["loads"] = ["1.5 kN vertical service load"]
+
+ def turn_runner(prior, *, user_response, **_kwargs):
+ nonlocal turn_count
+ assert threading.get_ident() != main_thread
+ turn_count += 1
+ return parse_engineering_brief_result(
+ json.dumps(payload if turn_count == 1 else ready_payload),
+ prior_state=prior,
+ user_response=user_response,
+ )
+
+ def persist(snapshot):
+ assert threading.get_ident() != main_thread
+ time.sleep(0.05)
+ persisted.append(dict(snapshot))
+
+ def start(snapshot, readable):
+ assert threading.get_ident() == main_thread
+ started.append((dict(snapshot), readable))
+ return True
+
+ dialog = EngineeringBriefDialog(
+ state,
+ turn_runner=turn_runner,
+ persist_callback=persist,
+ start_callback=start,
+ parent=Gui.getMainWindow(),
+ )
+ dialog.show()
+ assert dialog.isModal() is False
+ assert dialog.windowTitle() == "VibeCAD Engineering Brief"
+ assert dialog.request_edit.toPlainText() == "Design a motor bracket."
+ assert "Objective" in dialog.preview.toPlainText()
+ assert "Design a motor bracket." in dialog.preview.toPlainText()
+ assert dialog.pages.currentIndex() == 0
+ assert dialog.primary_button.text() == "Build My Brief"
+ assert dialog.primary_button.isEnabled()
+ assert dialog.primary_button.isDefault()
+
+ def tick() -> None:
+ nonlocal tick_count
+ tick_count += 1
+
+ def poll() -> None:
+ nonlocal phase
+ try:
+ if phase == 0:
+ if dialog.state.get("next_question") != payload["next_question"]:
+ return
+ assert tick_count >= 1
+ assert dialog.pages.currentIndex() == 1
+ assert dialog.primary_button.text() == "Submit Answer"
+ assert not dialog.primary_button.isEnabled()
+ assert not dialog.primary_button.isDefault()
+ assert dialog.best_judgment_button.isVisible()
+ assert payload["next_question"] in dialog.question_label.text()
+ dialog.answer_edit.setPlainText("Use a 1.5 kN service load.")
+ assert dialog.primary_button.isEnabled()
+ assert dialog.primary_button.isDefault()
+ dialog.primary_button.click()
+ assert not dialog.primary_button.isEnabled()
+ assert not dialog.primary_button.isDefault()
+ phase = 1
+ return
+ if not dialog.state.get("ready"):
+ return
+ assert dialog.pages.currentIndex() == 2
+ assert dialog.primary_button.text() == "Start CAD Work"
+ assert dialog.primary_button.isEnabled()
+ assert dialog.primary_button.isDefault()
+ assert not dialog.preview.isReadOnly()
+ assert not dialog.best_judgment_button.isVisible()
+ dialog.preview.appendPlainText("\nHuman review: prioritize stiffness.")
+ dialog.primary_button.click()
+ assert len(started) == 1
+ assert "Human review: prioritize stiffness." in started[0][1]
+ assert not dialog._persist_thread.is_alive()
+ assert persisted
+ print("VIBECAD_ENGINEERING_BRIEF_GUI_OK", flush=True)
+ finish(0)
+ except Exception:
+ traceback.print_exc(file=sys.__stderr__)
+ finish(1)
+
+ tick_timer.timeout.connect(tick)
+ tick_timer.start(10)
+ poll_timer.timeout.connect(poll)
+ poll_timer.start(20)
+ dialog.primary_button.click()
+ assert not dialog.primary_button.isEnabled()
+ assert not dialog.primary_button.isDefault()
+ except Exception:
+ traceback.print_exc(file=sys.__stderr__)
+ finish(1)
+
+
+QtCore.QTimer.singleShot(1000, _run)
diff --git a/src/Mod/VibeCAD/vibecad_tests/test_engineering_brief.py b/src/Mod/VibeCAD/vibecad_tests/test_engineering_brief.py
new file mode 100644
index 00000000..6794db03
--- /dev/null
+++ b/src/Mod/VibeCAD/vibecad_tests/test_engineering_brief.py
@@ -0,0 +1,412 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from VibeCADEngineeringBrief import (
+ add_active_conversation_context,
+ EngineeringBriefStore,
+ build_engineering_brief_prompt,
+ engineering_brief_handoff,
+ new_engineering_brief,
+ parse_engineering_brief_result,
+ render_engineering_brief,
+ run_engineering_brief_turn,
+ update_engineering_brief_draft,
+)
+
+
+def _identity() -> dict[str, str]:
+ return {
+ "project_root": "test-project-root",
+ "document_uid": "document-uid",
+ "conversation_id": "a" * 32,
+ }
+
+
+def _context() -> dict[str, object]:
+ return {
+ "workbench": "PartDesignWorkbench",
+ "units": {"schema": 0, "label": "Standard (mm/kg/s/degree)"},
+ "document": {
+ "name": "Bracket",
+ "uid": "document-uid",
+ "object_count": 4,
+ },
+ "selection": {
+ "selection_count": 1,
+ "selection": [{"object": "Body", "label": "Mounting Bracket"}],
+ },
+ }
+
+
+def _provider_payload(*, ready: bool = False) -> dict[str, object]:
+ return {
+ "assistant_message": (
+ "I have enough information to prepare the brief."
+ if ready
+ else "What load must the bracket support?"
+ ),
+ "next_question": "" if ready else "What load must the bracket support?",
+ "ready": ready,
+ "brief": {
+ "objective": "Create a wall-mounted equipment bracket.",
+ "deliverables": ["Editable 3D model", "Manufacturing drawing"],
+ "existing_geometry": ["Use the selected Mounting Bracket body"],
+ "units": "mm, N, MPa",
+ "dimensions": ["Fit within a 120 mm by 80 mm envelope"],
+ "materials": ["6061-T6 aluminum"],
+ "interfaces": ["Four M6 wall fasteners"],
+ "loads": [] if not ready else ["1.5 kN vertical service load"],
+ "manufacturing": ["3-axis CNC milling"],
+ "tolerances": ["General tolerance +/-0.2 mm"],
+ "analyses": ["Static FEA with factor of safety"],
+ "acceptance_criteria": ["Factor of safety at least 2.0"],
+ "requirements": ["Remain editable"],
+ "preferences": ["Minimize mass"],
+ },
+ "assumptions": ["Room-temperature indoor service"],
+ "open_questions": [] if ready else ["Required service load"],
+ }
+
+
+def test_new_brief_keeps_request_identity_and_safe_document_context() -> None:
+ state = new_engineering_brief(
+ "Make this bracket strong enough for a motor",
+ identity=_identity(),
+ context=_context(),
+ )
+
+ assert state["schema"] == "vibecad-engineering-brief-v1"
+ assert state["original_request"] == "Make this bracket strong enough for a motor"
+ assert state["document_uid"] == "document-uid"
+ assert state["conversation_id"] == "a" * 32
+ assert state["context"]["selection"]["selection"][0]["object"] == "Body"
+ assert state["transcript"] == []
+ assert state["ready"] is False
+
+
+def test_provider_prompt_requests_one_question_and_forbids_cad_mutation() -> None:
+ state = new_engineering_brief(
+ "Make this bracket strong enough for a motor",
+ identity=_identity(),
+ context=_context(),
+ )
+
+ prompt = build_engineering_brief_prompt(state, "It carries a 1.5 kN motor")
+
+ assert "exactly one highest-value question" in prompt
+ assert "Do not call or request CAD tools" in prompt
+ assert "ENGINEERING_BRIEF_STATE_JSON" in prompt
+ assert "It carries a 1.5 kN motor" in prompt
+ assert "PartDesignWorkbench" in prompt
+
+
+def test_request_edits_update_an_unreviewed_brief_without_losing_human_edits() -> None:
+ state = new_engineering_brief("Make a bracket", _identity(), _context())
+ original_render = render_engineering_brief(state)
+
+ updated = update_engineering_brief_draft(
+ state,
+ original_request="Make a lightweight motor bracket",
+ editable_text=original_render,
+ )
+ human_edited = update_engineering_brief_draft(
+ updated,
+ original_request="Make a lightweight motor bracket for a wall",
+ editable_text=render_engineering_brief(updated)
+ + "\n\nHuman note: reuse M6 bolts.",
+ )
+
+ assert updated["brief"]["objective"] == "Make a lightweight motor bracket"
+ assert "Make a lightweight motor bracket" in render_engineering_brief(updated)
+ assert "Human note: reuse M6 bolts." in render_engineering_brief(human_edited)
+
+
+def test_provider_result_accepts_fenced_json_and_updates_transcript() -> None:
+ state = new_engineering_brief(
+ "Make this bracket strong enough for a motor",
+ identity=_identity(),
+ context=_context(),
+ )
+ raw = "```json\n" + json.dumps(_provider_payload()) + "\n```"
+
+ updated = parse_engineering_brief_result(
+ raw,
+ prior_state=state,
+ user_response="Start with a 1.5 kN design load.",
+ )
+
+ assert updated["brief"]["objective"].startswith("Create a wall-mounted")
+ assert updated["next_question"] == "What load must the bracket support?"
+ assert updated["open_questions"] == ["Required service load"]
+ assert updated["transcript"] == [
+ {"role": "user", "content": "Start with a 1.5 kN design load."},
+ {"role": "assistant", "content": "What load must the bracket support?"},
+ ]
+
+
+@pytest.mark.parametrize(
+ "payload,error",
+ [
+ ({"ready": False}, "assistant_message"),
+ ({**_provider_payload(), "ready": "yes"}, "ready"),
+ ({**_provider_payload(), "brief": []}, "brief"),
+ ({**_provider_payload(), "assumptions": "none"}, "assumptions"),
+ ],
+)
+def test_provider_result_rejects_ambiguous_contracts(
+ payload: dict[str, object], error: str
+) -> None:
+ state = new_engineering_brief("Make a bracket", _identity(), _context())
+ with pytest.raises(ValueError, match=error):
+ parse_engineering_brief_result(
+ json.dumps(payload),
+ prior_state=state,
+ user_response="",
+ )
+
+
+def test_turn_runner_exposes_no_cad_tools_and_uses_brief_instructions() -> None:
+ requests: list[dict[str, object]] = []
+
+ class Provider:
+ def run(self, prompt, context, **kwargs):
+ requests.append(
+ {
+ "prompt": prompt,
+ "context": context,
+ "tool_runner": kwargs.get("tool_runner"),
+ }
+ )
+ return SimpleNamespace(final_output=json.dumps(_provider_payload()))
+
+ state = new_engineering_brief("Make a bracket", _identity(), _context())
+ updated = run_engineering_brief_turn(
+ state,
+ user_response="",
+ provider=Provider(),
+ )
+
+ assert updated["next_question"] == "What load must the bracket support?"
+ assert requests[0]["tool_runner"] is None
+ assert requests[0]["context"]["provider_tool_schemas"] == []
+ assert requests[0]["context"]["_vibecad_toolless_task"] is True
+ assert (
+ "non-mutating Engineering Brief assistant"
+ in requests[0]["context"]["_vibecad_task_instructions"]
+ )
+
+
+def test_active_conversation_context_is_available_to_the_brief_assistant() -> None:
+ state = new_engineering_brief("Finish the robot", _identity(), _context())
+ conversation = {
+ "turns": [
+ {"role": "user", "content": "The robot must fit through a doorway."},
+ {
+ "role": "assistant",
+ "content": "I will keep the shoulder width below 800 mm.",
+ },
+ ],
+ "omitted_turn_count": 3,
+ "truncated_turn_count": 0,
+ }
+
+ updated = add_active_conversation_context(state, conversation)
+ prompt = build_engineering_brief_prompt(updated, "")
+
+ assert updated["context"]["active_conversation"] == conversation
+ assert "The robot must fit through a doorway." in prompt
+ assert "shoulder width below 800 mm" in prompt
+ assert updated["conversation_id"] == state["conversation_id"]
+
+
+def test_readable_brief_and_agent_handoff_preserve_edits_and_assumptions() -> None:
+ state = new_engineering_brief("Make a bracket", _identity(), _context())
+ state = parse_engineering_brief_result(
+ json.dumps(_provider_payload(ready=True)),
+ prior_state=state,
+ user_response="Use a 1.5 kN service load.",
+ )
+ readable = render_engineering_brief(state)
+ edited = readable.replace("Minimize mass", "Prefer simple machining")
+
+ handoff = engineering_brief_handoff(state, approved_text=edited)
+
+ assert "approved engineering brief" in handoff.lower()
+ assert "Prefer simple machining" in handoff
+ assert "Room-temperature indoor service" in handoff
+ assert "ENGINEERING_BRIEF_JSON" not in handoff
+ assert "END_ENGINEERING_BRIEF" not in handoff
+ assert '"objective"' not in handoff
+ assert "Complete the work in the active VibeCAD document" in handoff
+
+
+def test_store_round_trip_is_scoped_to_document_and_conversation(
+ tmp_path: Path,
+) -> None:
+ identity = {
+ "project_root": str(tmp_path),
+ "document_uid": "document-uid",
+ "conversation_id": "b" * 32,
+ }
+ state = new_engineering_brief("Make a bracket", identity, _context())
+ store = EngineeringBriefStore(tmp_path)
+
+ written = store.write(state)
+ loaded = store.load(
+ document_uid="document-uid",
+ conversation_id="b" * 32,
+ )
+ other_conversation = store.load(
+ document_uid="document-uid",
+ conversation_id="c" * 32,
+ )
+
+ assert written["written"] is True
+ assert Path(written["path"]).is_file()
+ assert loaded["available"] is True
+ assert loaded["state"]["original_request"] == "Make a bracket"
+ assert other_conversation == {
+ "available": False,
+ "reason": "missing",
+ "path": str(tmp_path / "engineering-briefs" / ("c" * 32 + ".json")),
+ }
+
+
+def test_task_specific_provider_instructions_are_additive() -> None:
+ from VibeCADProvider import (
+ VIBECAD_SYSTEM_INSTRUCTIONS,
+ _system_instruction_sections,
+ )
+
+ default_sections = _system_instruction_sections({"provider_tool_schemas": []})
+ brief_sections = _system_instruction_sections(
+ {
+ "provider_tool_schemas": [],
+ "_vibecad_task_instructions": "Brief-specific contract.",
+ }
+ )
+
+ assert default_sections == [VIBECAD_SYSTEM_INSTRUCTIONS]
+ assert brief_sections == [VIBECAD_SYSTEM_INSTRUCTIONS, "Brief-specific contract."]
+
+
+def test_codex_supports_explicit_toolless_brief_tasks(monkeypatch) -> None:
+ import VibeCADCodex as codex
+ import VibeCADCodexResponses as codex_responses
+ import VibeCADOllama as ollama
+ from VibeCADProvider import CodexProvider
+
+ monkeypatch.setattr(
+ codex_responses,
+ "codex_responses_base_url",
+ lambda value: value,
+ )
+ monkeypatch.setattr(
+ ollama,
+ "inspect_model",
+ lambda *_args, **_kwargs: {"detected": False, "ok": True},
+ )
+ thread_requests: list[dict[str, object]] = []
+
+ class Client:
+ def __init__(
+ self,
+ *,
+ notification_handler,
+ server_request_handler,
+ environment=None,
+ ) -> None:
+ del server_request_handler, environment
+ self.notification_handler = notification_handler
+ self.alive = True
+
+ @property
+ def stderr_tail(self):
+ return []
+
+ def start(self):
+ return None
+
+ def request(self, method, params, timeout):
+ del timeout
+ if method == "thread/start":
+ thread_requests.append(params)
+ return {"thread": {"id": "brief-thread"}, "model": "gpt-test"}
+ if method == "turn/start":
+ self.notification_handler(
+ "item/completed",
+ {
+ "threadId": "brief-thread",
+ "item": {
+ "type": "agentMessage",
+ "text": json.dumps(_provider_payload()),
+ },
+ },
+ )
+ self.notification_handler(
+ "turn/completed",
+ {
+ "threadId": "brief-thread",
+ "turn": {"id": "brief-turn", "status": "completed"},
+ },
+ )
+ return {"turn": {"id": "brief-turn"}}
+ if method == "thread/delete":
+ return {}
+ raise AssertionError(method)
+
+ def close(self):
+ self.alive = False
+
+ monkeypatch.setattr(codex, "CodexAppServerClient", Client)
+ active_provider = CodexProvider(
+ model="gpt-test",
+ api_key="test-key",
+ auth_mode="api_key",
+ )
+
+ result = active_provider.run(
+ "Develop the brief.",
+ {
+ "workbench": "PartDesignWorkbench",
+ "provider_tool_schemas": [],
+ "_vibecad_toolless_task": True,
+ "_vibecad_task_instructions": "Return the engineering brief JSON.",
+ },
+ tool_runner=None,
+ )
+
+ assert json.loads(result.final_output)["next_question"]
+ assert thread_requests[0]["dynamicTools"] == []
+ assert (
+ "non-mutating text-only VibeCAD task"
+ in thread_requests[0]["developerInstructions"]
+ )
+
+
+def test_assistant_composer_exposes_the_engineering_brief_window() -> None:
+ root = Path(__file__).resolve().parents[4]
+ gui_source = (root / "src/Mod/VibeCAD/VibeCADGui.py").read_text(encoding="utf-8")
+ brief_gui_source = (
+ root / "src/Mod/VibeCAD/VibeCADEngineeringBriefGui.py"
+ ).read_text(encoding="utf-8")
+ cmake_source = (root / "src/Mod/VibeCAD/CMakeLists.txt").read_text(encoding="utf-8")
+
+ assert 'setObjectName("VibeEngineeringBrief")' in gui_source
+ assert "_open_engineering_brief_from_panel" in gui_source
+ assert 'setWindowTitle("VibeCAD Engineering Brief")' in brief_gui_source
+ assert 'setObjectName("VibeEngineeringBriefPreview")' in brief_gui_source
+ assert 'setObjectName("VibeEngineeringBriefTranscript")' in brief_gui_source
+ assert 'setObjectName("VibeEngineeringBriefPrimary")' in brief_gui_source
+ assert 'QPushButton("Build My Brief"' in brief_gui_source
+ assert '"Finish with Assumptions"' in brief_gui_source
+ assert "complete_conversation_history_read" in gui_source
+ assert "VibeCADEngineeringBriefGui.py" in cmake_source
+ assert "vibecad-engineering-brief.svg" in cmake_source