diff --git a/.github/scripts/detect_validate_surfaces.py b/.github/scripts/detect_validate_surfaces.py index 705d1c95..3370f51d 100644 --- a/.github/scripts/detect_validate_surfaces.py +++ b/.github/scripts/detect_validate_surfaces.py @@ -37,7 +37,14 @@ "emitters/rust.py", "emitters/sql.py", "emitters/typescript.py", + "llm/conversation_plan.py", + "llm/conversation_planner.py", + "llm/conversation_backend.py", + "llm/conversation_engine.py", "llm/context.py", + "llm/provider_types.py", + "llm/workspace_editor.py", + "llm/workspace_query.py", "registry/__init__.py", "registry/resolver.py", "registry/signature.py", diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 9e47b159..44d27e3d 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -79,6 +79,7 @@ asyncio_default_test_loop_scope = "module" addopts = "-n auto" markers = [ "docker: requires Docker daemon and internet access (opt-in via MODELABLE_DOCKER_TESTS=1)", + "ollama: requires a developer-controlled local Ollama server and model (opt-in via MODELABLE_OLLAMA_TESTS=1)", ] [tool.coverage.run] diff --git a/cli/scripts/build_browser_wheel.py b/cli/scripts/build_browser_wheel.py index 130069f4..c59494a0 100644 --- a/cli/scripts/build_browser_wheel.py +++ b/cli/scripts/build_browser_wheel.py @@ -49,7 +49,14 @@ "emitters/rust.py", "emitters/sql.py", "emitters/typescript.py", + "llm/conversation_plan.py", + "llm/conversation_planner.py", + "llm/conversation_backend.py", + "llm/conversation_engine.py", "llm/context.py", + "llm/provider_types.py", + "llm/workspace_editor.py", + "llm/workspace_query.py", "registry/__init__.py", "registry/resolver.py", "registry/signature.py", diff --git a/cli/src/modelable/browser/__init__.py b/cli/src/modelable/browser/__init__.py index d8a8885c..35d5afbb 100644 --- a/cli/src/modelable/browser/__init__.py +++ b/cli/src/modelable/browser/__init__.py @@ -1,4 +1,5 @@ from modelable.browser.api import BrowserCompiler +from modelable.browser.conversation import BrowserConversationReply, BrowserConversationService from modelable.browser.dispatch import dispatch_browser_request from modelable.browser.dto import ( BrowserArtifact, @@ -17,6 +18,8 @@ "BrowserCompileResult", "BrowserCompiler", "BrowserCompletionResult", + "BrowserConversationReply", + "BrowserConversationService", "BrowserDiagnostic", "BrowserFormatResult", "BrowserHoverResult", diff --git a/cli/src/modelable/browser/ai.py b/cli/src/modelable/browser/ai.py deleted file mode 100644 index e46ee395..00000000 --- a/cli/src/modelable/browser/ai.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -from modelable.browser.dto import ( - BrowserAiExplainResult, - BrowserAiGenerateResult, - BrowserAiPendingResult, - BrowserDiagnostic, - BrowserLlmRequest, -) -from modelable.browser.errors import BrowserLanguageError -from modelable.diagnostics.model import Diagnostic -from modelable.language.workspace import LanguageWorkspace -from modelable.llm.context import build_workspace_summary -from modelable.validation.semantic import validate_diagnostics - -_GENERATE_ENTITY_SYSTEM = """\ -You are a domain modeling assistant. You write valid Modelable .mdl source. -Given a natural-language description of a business entity, produce a complete -.mdl entity definition. Include a primary key field using uuid type with @key, -and add fields that match the description. Use snake_case for field names. -Output ONLY the .mdl source with no explanation or markdown fences.""" - -_SUGGEST_PROJECTION_SYSTEM = """\ -You are a domain modeling assistant. You write valid Modelable .mdl source. -Given a source model definition and a consumer domain name, produce a -projection that exposes the source model's fields relevant to the consumer. -Exclude PII and server-only fields. Output ONLY the .mdl source with no -explanation or markdown fences.""" - -_EXPLAIN_SYSTEM = """\ -You are a domain modeling assistant. Given a Modelable workspace summary and -an optional model reference or diagnostic, provide a clear, concise explanation. -Use markdown formatting: headers for sections, bullet lists for multiple points, -inline code for field and type names, and bold for emphasis. Focus on what the -element does, why it exists, and how it relates to other definitions.""" - - -def build_generate_entity_request( - language: LanguageWorkspace, - description: str, - domain_name: str | None, - model_name: str | None, -) -> BrowserAiPendingResult: - semantic = language.semantic_workspace() - if semantic is None: - raise BrowserLanguageError("LANGUAGE_UNAVAILABLE") - - workspace_summary = build_workspace_summary(semantic) - parts = [f"Workspace context:\n{workspace_summary}\n"] - parts.append(f"Description: {description}") - if domain_name: - parts.append(f"Domain: {domain_name}") - if model_name: - parts.append(f"Entity name: {model_name}") - - return BrowserAiPendingResult( - llm_request=BrowserLlmRequest( - system=_GENERATE_ENTITY_SYSTEM, - user="\n".join(parts), - temperature=0.2, - response_format="text", - ), - ) - - -def build_suggest_projection_request( - language: LanguageWorkspace, - source_ref: str, - consumer_domain: str, -) -> BrowserAiPendingResult: - semantic = language.semantic_workspace() - if semantic is None: - raise BrowserLanguageError("LANGUAGE_UNAVAILABLE") - - from modelable.llm.context import build_model_summary - - workspace_summary = build_workspace_summary(semantic) - model_summary = build_model_summary(semantic, source_ref) - - user_prompt = ( - f"Workspace context:\n{workspace_summary}\n\n" - f"Source model:\n{model_summary}\n\n" - f"Consumer domain: {consumer_domain}\n" - f"Create a projection of this model for the {consumer_domain} domain." - ) - - return BrowserAiPendingResult( - llm_request=BrowserLlmRequest( - system=_SUGGEST_PROJECTION_SYSTEM, - user=user_prompt, - temperature=0.2, - response_format="text", - ), - ) - - -def build_explain_request( - language: LanguageWorkspace, - ref: str | None, - diagnostic_index: int | None, -) -> BrowserAiPendingResult: - semantic = language.semantic_workspace() - if semantic is None: - raise BrowserLanguageError("LANGUAGE_UNAVAILABLE") - - workspace_summary = build_workspace_summary(semantic) - parts = [f"Workspace context:\n{workspace_summary}\n"] - - if ref: - from modelable.llm.context import build_model_summary - - model_summary = build_model_summary(semantic, ref) - parts.append(f"Explain this model:\n{model_summary}") - elif diagnostic_index is not None: - diagnostics = list(semantic.errors) - if 0 <= diagnostic_index < len(diagnostics): - diag = diagnostics[diagnostic_index] - parts.append(f"Explain this diagnostic:\n{diag.message}") - else: - parts.append("Explain the overall workspace structure.") - else: - parts.append("Explain the overall workspace structure.") - - return BrowserAiPendingResult( - llm_request=BrowserLlmRequest( - system=_EXPLAIN_SYSTEM, - user="\n".join(parts), - temperature=0.2, - response_format="text", - ), - ) - - -def _browser_diagnostic(diagnostic: Diagnostic) -> BrowserDiagnostic: - return BrowserDiagnostic( - code=diagnostic.code, - severity=diagnostic.severity, - message=diagnostic.message, - uri=diagnostic.path, - line=diagnostic.line, - column=diagnostic.column, - end_line=diagnostic.end_line, - end_column=diagnostic.end_column, - ) - - -def parse_generate_result( - llm_content: str, -) -> BrowserAiGenerateResult: - source = _strip_code_fences(llm_content) - diagnostics = _validate_source(source) - return BrowserAiGenerateResult( - source=source, - diagnostics=diagnostics, - ) - - -def parse_explain_result( - llm_content: str, -) -> BrowserAiExplainResult: - return BrowserAiExplainResult(explanation=llm_content.strip()) - - -def _strip_code_fences(text: str) -> str: - lines = text.strip().splitlines() - if lines and lines[0].startswith("```"): - lines = lines[1:] - if lines and lines[-1].strip() == "```": - lines = lines[:-1] - return "\n".join(lines) + "\n" if lines else "" - - -def _validate_source(source: str) -> tuple[BrowserDiagnostic, ...]: - from modelable.compiler.workspace import WorkspaceDocumentSource, load_workspace_from_sources - from modelable.parser.ir import ParseError - from modelable.parser.parse import parse_text_to_ir - - uri = "ai-generated.mdl" - try: - parse_text_to_ir(source, path=uri) - except ParseError as error: - return (_browser_diagnostic(error.diagnostic(uri)),) - - try: - workspace = load_workspace_from_sources([WorkspaceDocumentSource(path=None, uri=uri, text=source)]) - except ParseError as error: - return (_browser_diagnostic(error.diagnostic(uri)),) - - return tuple(_browser_diagnostic(diagnostic) for diagnostic in validate_diagnostics(workspace.mdl, path=uri)) diff --git a/cli/src/modelable/browser/api.py b/cli/src/modelable/browser/api.py index 8c6b70f8..b707a965 100644 --- a/cli/src/modelable/browser/api.py +++ b/cli/src/modelable/browser/api.py @@ -2,18 +2,8 @@ from types import MappingProxyType -from modelable.browser.ai import ( - build_explain_request, - build_generate_entity_request, - build_suggest_projection_request, - parse_explain_result, - parse_generate_result, -) from modelable.browser.compatibility import build_browser_compatibility from modelable.browser.dto import ( - BrowserAiExplainResult, - BrowserAiGenerateResult, - BrowserAiPendingResult, BrowserArtifact, BrowserCompatibilityResult, BrowserCompileResult, @@ -117,6 +107,11 @@ def _load_workspace(sources: tuple[BrowserSource, ...]) -> Workspace | tuple[Bro class BrowserCompiler: def __init__(self) -> None: self.language = LanguageWorkspace() + self._sources: tuple[BrowserSource, ...] = () + + @property + def sources(self) -> tuple[BrowserSource, ...]: + return self._sources def open_workspace( self, @@ -130,6 +125,7 @@ def open_workspace( workspace_revision, tuple(LanguageDocument.from_text(source.uri, source.text, source.version) for source in sources), ) + self._sources = sources return BrowserWorkspaceResult( workspace_revision=synchronization.revision, diagnostics=tuple(_browser_diagnostic(diagnostic) for diagnostic in synchronization.diagnostics), @@ -277,55 +273,6 @@ def governance( raise BrowserLanguageError("LANGUAGE_UNAVAILABLE") return build_browser_governance(semantic, workspace_revision) - def ai_generate( - self, - workspace_revision: int, - action: str, - parameters: dict[str, object], - llm_response_content: str | None, - ) -> BrowserAiPendingResult | BrowserAiGenerateResult: - if workspace_revision != self.language.revision: - raise BrowserLanguageError("STALE_WORKSPACE") - - if llm_response_content is None: - if action == "generate_entity": - return build_generate_entity_request( - self.language, - description=str(parameters.get("description", "")), - domain_name=parameters.get("domainName"), # type: ignore[arg-type] - model_name=parameters.get("modelName"), # type: ignore[arg-type] - ) - if action == "suggest_projection": - return build_suggest_projection_request( - self.language, - source_ref=str(parameters.get("sourceRef", "")), - consumer_domain=str(parameters.get("consumerDomain", "")), - ) - raise BrowserRequestValidationError(f"Unknown generate action: {action}") - - return parse_generate_result(llm_response_content) - - def ai_explain( - self, - workspace_revision: int, - parameters: dict[str, object], - llm_response_content: str | None, - ) -> BrowserAiPendingResult | BrowserAiExplainResult: - if workspace_revision != self.language.revision: - raise BrowserLanguageError("STALE_WORKSPACE") - - if llm_response_content is None: - ref = parameters.get("ref") - raw_index = parameters.get("diagnosticIndex") - diag_index: int | None = int(raw_index) if isinstance(raw_index, (int, float, str)) else None - return build_explain_request( - self.language, - ref=str(ref) if ref is not None else None, - diagnostic_index=diag_index, - ) - - return parse_explain_result(llm_response_content) - def _validate_language_request( self, request: BrowserLanguagePosition, diff --git a/cli/src/modelable/browser/conversation.py b/cli/src/modelable/browser/conversation.py new file mode 100644 index 00000000..9c780af9 --- /dev/null +++ b/cli/src/modelable/browser/conversation.py @@ -0,0 +1,487 @@ +from __future__ import annotations + +import hashlib +import time +from collections import OrderedDict +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Literal, cast +from urllib.parse import quote, unquote, urlparse + +from modelable.browser.api import BrowserCompiler +from modelable.browser.dto import BrowserSource +from modelable.browser.errors import BrowserLanguageError +from modelable.compiler.workspace import Workspace, WorkspaceDocumentSource, load_workspace_from_sources +from modelable.llm.context import build_workspace_summary +from modelable.llm.conversation_backend import ( + ConversationPreviewFile, + ConversationReply, +) +from modelable.llm.conversation_engine import ConversationEngine +from modelable.llm.conversation_plan import ChangeSetPlan, CompilePlan, QueryPlan +from modelable.llm.conversation_planner import ( + PendingPlanRequest, + PlanningRequestError, + ResumableConversationPlanner, +) +from modelable.llm.workspace_editor import PendingChangeSet, WorkspaceEditError, WorkspaceEditor +from modelable.llm.workspace_query import WorkspaceQueryService + +if TYPE_CHECKING: + from modelable.operations.compilation import CompilationFilePreview + +_MAX_SESSIONS = 32 +_SESSION_TTL_SECONDS = 30 * 60 + + +@dataclass(frozen=True) +class BrowserConversationReply: + reply: ConversationReply + workspace_revision: int + sources: tuple[BrowserSource, ...] = () + + +@dataclass(frozen=True) +class _BrowserCompilationFile: + category: str + destination: Path + staged_path: Path + status: str + media_type: str + ref: str | None + before_hash: str | None + after_hash: str + before_size: int + after_size: int + before_text: str | None + after_text: str | None + diff_text: str | None + resolved_parent: Path + + +@dataclass +class _Session: + engine: ConversationEngine + backend: BrowserConversationBackend + workspace_revision: int + last_used: float + + +class BrowserConversationBackend: + def __init__(self, compiler: BrowserCompiler) -> None: + self.compiler = compiler + self._pending_change: PendingChangeSet | None = None + self._pending_compilation: tuple[str, tuple[CompilationFilePreview, ...]] | None = None + self._path_to_uri: dict[Path, str] = {} + self._workspace = self._load_workspace() + + @property + def pending_action_id(self) -> str | None: + if self._pending_change is not None: + return self._pending_change.change_set_id + if self._pending_compilation is not None: + return self._pending_compilation[0] + return None + + def workspace_summary(self) -> str: + return build_workspace_summary(self._workspace) + + def execute_query(self, plan: QueryPlan) -> ConversationReply: + return ConversationReply( + kind="answer", + text=WorkspaceQueryService(self._workspace).execute(plan).text, + ) + + def preview_source_change( + self, + plan: ChangeSetPlan, + replaced_action_id: str | None, + ) -> ConversationReply: + self._assert_replaced(replaced_action_id) + try: + pending = WorkspaceEditor(Path("."), workspace=self._workspace).preview(plan) + except WorkspaceEditError as error: + return ConversationReply(kind="error", text=f"Could not preview workspace changes: {error}") + self._pending_change = pending + self._pending_compilation = None + current = {source.path: source.text for source in self._workspace.sources if source.path is not None} + return ConversationReply( + kind="preview", + text=f"Previewed {plan.summary}\n\nApply or discard change set {pending.change_set_id}.", + change_set_id=pending.change_set_id, + operation_kind="source_change", + focused_ref=pending.focus_ref, + changed=tuple(pending.changed), + affected=tuple(pending.affected), + compatibility=tuple(pending.compatibility), + diagnostics=tuple(pending.diagnostics), + preview_files=tuple( + ConversationPreviewFile( + path=path, + existed_before=path in current, + before_text=current.get(path, ""), + after_text=text, + ) + for path, text in sorted(pending.candidate_sources.items()) + ), + ) + + def preview_compilation( + self, + plan: CompilePlan, + replaced_action_id: str | None, + ) -> ConversationReply: + self._assert_replaced(replaced_action_id) + if plan.domains or plan.output is not None or plan.descriptor_set: + return ConversationReply( + kind="error", + text=( + "Browser compilation does not support domain filters, output paths, " + "or descriptor sets; use an unfiltered browser compile or the CLI." + ), + ) + target = { + "json-schema": "jsonSchema", + "sql": "sql-postgres", + }.get(plan.target, plan.target) + try: + result = self.compiler.compile(self.compiler.sources, target) + except (BrowserLanguageError, ValueError) as error: + return ConversationReply( + kind="error", + text=f"Browser compilation does not support {plan.target}: {error}", + ) + if any(item.severity == "error" for item in result.diagnostics): + return ConversationReply(kind="error", text="Could not preview compilation.") + files = tuple( + _compilation_preview(artifact.path, artifact.media_type, artifact.content) for artifact in result.artifacts + ) + action_id = _artifact_action_id(files) + self._pending_change = None + self._pending_compilation = (action_id, files) + return ConversationReply( + kind="preview", + text=f"Previewed {len(files)} compilation artifact(s) for {plan.target}.", + change_set_id=action_id, + operation_kind="compile", + compilation_files=files, + ) + + def apply(self, action_id: str) -> ConversationReply: + if action_id != self.pending_action_id: + return ConversationReply(kind="error", text=f"Pending action does not match {action_id}.") + if self._pending_compilation is not None: + _, files = self._pending_compilation + self._pending_compilation = None + return ConversationReply( + kind="applied", + text=f"Applied compilation {action_id}.", + change_set_id=action_id, + operation_kind="compile", + compilation_files=files, + ) + pending = self._pending_change + if pending is None: + return ConversationReply(kind="error", text="There is no pending action to apply.") + current_hashes = { + source.path: source.content_hash for source in self._workspace.sources if source.path is not None + } + if current_hashes != pending.source_fingerprints: + return ConversationReply( + kind="error", + text=f"Could not apply change set {action_id}: the workspace changed after preview.", + change_set_id=action_id, + operation_kind="source_change", + ) + candidates = pending.candidate_sources + updated = tuple( + BrowserSource( + uri=source.uri, + text=candidates.get(_logical_path(source.uri), source.text), + version=source.version + (1 if _logical_path(source.uri) in candidates else 0), + ) + for source in self.compiler.sources + ) + existing_paths = {_logical_path(source.uri) for source in self.compiler.sources} + updated += tuple( + BrowserSource( + uri=_document_uri(path), + text=text, + version=1, + ) + for path, text in sorted(candidates.items()) + if path not in existing_paths + ) + revision = self.compiler.language.revision + 1 + self.compiler.open_workspace(revision, updated) + self._pending_change = None + self._workspace = self._load_workspace() + return ConversationReply( + kind="applied", + text=f"Applied change set {action_id}.", + change_set_id=action_id, + operation_kind="source_change", + focused_ref=pending.focus_ref, + changed=tuple(pending.changed), + compatibility=tuple(pending.compatibility), + written_paths=tuple(sorted(candidates)), + ) + + def discard(self, action_id: str) -> ConversationReply: + if action_id != self.pending_action_id: + return ConversationReply(kind="error", text=f"Pending action does not match {action_id}.") + operation_kind: Literal["source_change", "compile"] = ( + "compile" if self._pending_compilation is not None else "source_change" + ) + self._pending_change = None + self._pending_compilation = None + return ConversationReply( + kind="discarded", + text=f"Discarded pending action {action_id}.", + change_set_id=action_id, + operation_kind=operation_kind, + ) + + def reset(self) -> None: + self._pending_change = None + self._pending_compilation = None + self._workspace = self._load_workspace() + + def _load_workspace(self) -> Workspace: + sources: list[WorkspaceDocumentSource] = [] + self._path_to_uri.clear() + for source in self.compiler.sources: + path = _logical_path(source.uri) + self._path_to_uri[path] = source.uri + sources.append(WorkspaceDocumentSource(path=path, uri=source.uri, text=source.text)) + return load_workspace_from_sources(sources) + + def _assert_replaced(self, action_id: str | None) -> None: + if action_id != self.pending_action_id: + raise ValueError( + f"Replaced action ID {action_id!r} does not match backend action {self.pending_action_id!r}" + ) + + +class BrowserConversationService: + def __init__( + self, + compiler: BrowserCompiler, + *, + id_factory: Callable[[], str] | None = None, + clock: Callable[[], float] = time.monotonic, + max_sessions: int = _MAX_SESSIONS, + session_ttl_seconds: float = _SESSION_TTL_SECONDS, + ) -> None: + self.compiler = compiler + self.id_factory = id_factory + self.clock = clock + self.max_sessions = max_sessions + self.session_ttl_seconds = session_ttl_seconds + self._sessions: OrderedDict[str, _Session] = OrderedDict() + + def turn( + self, + *, + session_id: str, + workspace_revision: int, + message: str, + active_document_uri: str | None = None, + line: int | None = None, + character: int | None = None, + ) -> PendingPlanRequest | BrowserConversationReply: + session = self._session(session_id, workspace_revision) + session.engine.focused_ref = self._focused_ref(active_document_uri, line, character) + outcome = session.engine.begin_turn(message) + if isinstance(outcome, PendingPlanRequest): + return outcome + return self._result(outcome) + + def resume( + self, + *, + session_id: str, + request_id: str, + workspace_revision: int, + content: str, + ) -> PendingPlanRequest | BrowserConversationReply: + session = self._session(session_id, workspace_revision, create=False) + outcome = session.engine.resume_turn(request_id, content) + if isinstance(outcome, PendingPlanRequest): + return outcome + return self._result(outcome) + + def apply( + self, + *, + session_id: str, + action_id: str, + workspace_revision: int, + ) -> BrowserConversationReply: + session = self._session(session_id, workspace_revision, create=False) + try: + reply = session.engine.apply(action_id) + except ValueError as error: + reply = ConversationReply(kind="error", text=str(error)) + return self._result(reply) + + def fail( + self, + *, + session_id: str, + request_id: str, + workspace_revision: int, + error: str, + ) -> BrowserConversationReply: + session = self._session(session_id, workspace_revision, create=False) + return self._result(session.engine.fail_turn(request_id, RuntimeError(error))) + + def discard( + self, + *, + session_id: str, + action_id: str, + workspace_revision: int, + ) -> BrowserConversationReply: + session = self._session(session_id, workspace_revision, create=False) + try: + reply = session.engine.discard(action_id) + except ValueError as error: + reply = ConversationReply(kind="error", text=str(error)) + return self._result(reply) + + def reset(self, *, session_id: str) -> None: + try: + session = self._sessions.pop(session_id) + except KeyError: + session = None + if session is not None: + session.engine.reset() + + def _result(self, reply: ConversationReply) -> BrowserConversationReply: + return BrowserConversationReply( + reply=reply, + workspace_revision=self.compiler.language.revision, + sources=self.compiler.sources if reply.kind == "applied" else (), + ) + + def _session(self, session_id: str, revision: int, *, create: bool = True) -> _Session: + if revision != self.compiler.language.revision: + raise BrowserLanguageError("STALE_WORKSPACE") + now = self.clock() + for key, expired_session in tuple(self._sessions.items()): + if now - expired_session.last_used >= self.session_ttl_seconds: + expired_session.engine.reset() + del self._sessions[key] + session: _Session | None + try: + session = self._sessions.pop(session_id) + except KeyError: + session = None + if session is None: + if not create: + raise PlanningRequestError(f"Unknown conversation session: {session_id}") + backend = BrowserConversationBackend(self.compiler) + session = _Session( + engine=ConversationEngine( + backend=backend, + planner=ResumableConversationPlanner(id_factory=self.id_factory), + ), + backend=backend, + workspace_revision=revision, + last_used=now, + ) + elif session.workspace_revision != revision: + session.engine.reset() + backend = BrowserConversationBackend(self.compiler) + session = _Session( + engine=ConversationEngine( + backend=backend, + planner=ResumableConversationPlanner(id_factory=self.id_factory), + ), + backend=backend, + workspace_revision=revision, + last_used=now, + ) + session.last_used = now + self._sessions[session_id] = session + while len(self._sessions) > self.max_sessions: + _, expired = self._sessions.popitem(last=False) + expired.engine.reset() + return session + + def _focused_ref(self, uri: str | None, line: int | None, character: int | None) -> str | None: + del character + if uri is None or line is None: + return None + document = self.compiler.language.current_document(uri) + workspace = self.compiler.language.semantic_workspace() + if document is None or workspace is None: + return None + active_domain: str | None = None + active_name: str | None = None + active_version: int | None = None + import re + + for text in document.text.splitlines()[: line + 1]: + domain = re.match(r'^\s*domain\s+(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))', text) + if domain: + active_domain = domain.group(1) or domain.group(2) + declaration = re.match( + r"^\s*(?:entity|aggregate|event|value|projection)\s+([A-Za-z_][A-Za-z0-9_]*)\s*@\s*(\d+)", + text, + ) + if declaration: + active_name = declaration.group(1) + active_version = int(declaration.group(2)) + if active_domain and active_name and active_version is not None: + return f"{active_domain}.{active_name}@{active_version}" + return None + + +def _logical_path(uri: str) -> Path: + parsed = urlparse(uri) + raw = unquote(parsed.path).replace("\\", "/") + parts = PurePosixPath(raw).parts + relative_parts = tuple(part for part in parts if part != "/") + if ( + not relative_parts + or any(part in {"", ".", ".."} for part in relative_parts) + or not relative_parts[-1].endswith(".mdl") + ): + raise ValueError(f"Browser source URI has no safe relative document path: {uri}") + return Path(*relative_parts) + + +def _document_uri(path: Path) -> str: + return "file:///" + "/".join(quote(part, safe="") for part in path.parts) + + +def _compilation_preview(path: str, media_type: str, content: str) -> CompilationFilePreview: + logical = Path(path) + digest = hashlib.sha256(content.encode("utf-8")).hexdigest() + return cast( + "CompilationFilePreview", + _BrowserCompilationFile( + category="artifact", + destination=logical, + staged_path=logical, + status="created", + media_type=media_type, + ref=None, + before_hash=None, + after_hash=digest, + before_size=0, + after_size=len(content.encode("utf-8")), + before_text=None, + after_text=content, + diff_text=None, + resolved_parent=Path("."), + ), + ) + + +def _artifact_action_id(files: tuple[CompilationFilePreview, ...]) -> str: + payload = "\n".join(f"{item.destination.as_posix()}:{item.after_hash}" for item in files) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] diff --git a/cli/src/modelable/browser/dispatch.py b/cli/src/modelable/browser/dispatch.py index 3eac2480..a36870b1 100644 --- a/cli/src/modelable/browser/dispatch.py +++ b/cli/src/modelable/browser/dispatch.py @@ -2,13 +2,12 @@ import json from dataclasses import asdict +from pathlib import Path from typing import Any from modelable.browser.api import BrowserCompiler +from modelable.browser.conversation import BrowserConversationReply, BrowserConversationService from modelable.browser.dto import ( - BrowserAiExplainResult, - BrowserAiGenerateResult, - BrowserAiPendingResult, BrowserCompatibilityResult, BrowserCompileResult, BrowserCompletionResult, @@ -26,6 +25,7 @@ BrowserWorkspaceResult, ) from modelable.browser.errors import BrowserLanguageError, BrowserRequestValidationError +from modelable.llm.conversation_planner import PendingPlanRequest, PlanningRequestError _METHODS = { "workspace.open", @@ -42,8 +42,12 @@ "workspace.lineage", "workspace.compatibility", "workspace.governance", - "ai.generate", - "ai.explain", + "conversation.turn", + "conversation.resume", + "conversation.fail", + "conversation.apply", + "conversation.discard", + "conversation.reset", } _SOURCE_FIELDS = {"uri", "text", "version"} _LANGUAGE_POSITION_FIELDS = { @@ -53,7 +57,6 @@ "character", } _GRAPH_MODES = {"domain", "entity", "projection", "lineage"} -_AI_GENERATE_ACTIONS = {"generate_entity", "suggest_projection"} _ERROR_MESSAGES = { "INVALID_REQUEST": "Payload does not match method schema", "STALE_WORKSPACE": "Requested workspace revision is not current", @@ -62,6 +65,7 @@ "INVALID_RENAME": "Rename target is invalid or produces a conflict", } _compiler = BrowserCompiler() +_conversations = BrowserConversationService(_compiler) def _error_response(code: str) -> str: @@ -149,9 +153,9 @@ def _language_position( | BrowserLineageResult | BrowserCompatibilityResult | BrowserGovernanceResult - | BrowserAiPendingResult - | BrowserAiGenerateResult - | BrowserAiExplainResult + | PendingPlanRequest + | BrowserConversationReply + | None ) @@ -209,56 +213,108 @@ def _dispatch(method: str, payload: dict[str, Any]) -> _DispatchResult: if method == "workspace.governance": _require_exact_fields(payload, {"workspaceRevision"}) return _compiler.governance(_integer(payload["workspaceRevision"])) - if method == "ai.generate": - allowed = {"workspaceRevision", "action", "parameters", "llmResponseContent"} - if set(payload) - allowed or "workspaceRevision" not in payload or "action" not in payload: + if method == "conversation.turn": + allowed = { + "sessionId", + "workspaceRevision", + "message", + "activeDocumentUri", + "line", + "character", + } + if set(payload) != allowed: raise BrowserRequestValidationError("Payload does not match method schema") - action = payload["action"] - if not isinstance(action, str) or action not in _AI_GENERATE_ACTIONS: - raise BrowserRequestValidationError("action must be a valid generate action") - parameters = payload.get("parameters", {}) - if not isinstance(parameters, dict): - raise BrowserRequestValidationError("parameters must be an object") - llm_response = payload.get("llmResponseContent") - if llm_response is not None and not isinstance(llm_response, str): - raise BrowserRequestValidationError("llmResponseContent must be a string or null") - return _compiler.ai_generate( - _integer(payload["workspaceRevision"]), - action, - parameters, - llm_response, + if not isinstance(payload["sessionId"], str) or not isinstance(payload["message"], str): + raise BrowserRequestValidationError("Conversation fields have invalid types") + active_uri = payload["activeDocumentUri"] + if active_uri is not None and not isinstance(active_uri, str): + raise BrowserRequestValidationError("activeDocumentUri must be a string or null") + line = payload["line"] + character = payload["character"] + if (line is None) != (character is None): + raise BrowserRequestValidationError("line and character must both be present or null") + return _conversations.turn( + session_id=payload["sessionId"], + workspace_revision=_integer(payload["workspaceRevision"]), + message=payload["message"], + active_document_uri=active_uri, + line=None if line is None else _integer(line), + character=None if character is None else _integer(character), ) - if method == "ai.explain": - allowed = {"workspaceRevision", "parameters", "llmResponseContent"} - if set(payload) - allowed or "workspaceRevision" not in payload: - raise BrowserRequestValidationError("Payload does not match method schema") - parameters = payload.get("parameters", {}) - if not isinstance(parameters, dict): - raise BrowserRequestValidationError("parameters must be an object") - llm_response = payload.get("llmResponseContent") - if llm_response is not None and not isinstance(llm_response, str): - raise BrowserRequestValidationError("llmResponseContent must be a string or null") - return _compiler.ai_explain( - _integer(payload["workspaceRevision"]), - parameters, - llm_response, + if method == "conversation.resume": + _require_exact_fields( + payload, + {"sessionId", "requestId", "workspaceRevision", "llmResponseContent"}, + ) + if not all(isinstance(payload[key], str) for key in ("sessionId", "requestId", "llmResponseContent")): + raise BrowserRequestValidationError("Conversation fields have invalid types") + return _conversations.resume( + session_id=payload["sessionId"], + request_id=payload["requestId"], + workspace_revision=_integer(payload["workspaceRevision"]), + content=payload["llmResponseContent"], + ) + if method == "conversation.fail": + _require_exact_fields( + payload, + {"sessionId", "requestId", "workspaceRevision", "error"}, ) + if not all(isinstance(payload[key], str) for key in ("sessionId", "requestId", "error")): + raise BrowserRequestValidationError("Conversation fields have invalid types") + return _conversations.fail( + session_id=payload["sessionId"], + request_id=payload["requestId"], + workspace_revision=_integer(payload["workspaceRevision"]), + error=payload["error"], + ) + if method in {"conversation.apply", "conversation.discard"}: + _require_exact_fields(payload, {"sessionId", "actionId", "workspaceRevision"}) + if not isinstance(payload["sessionId"], str) or not isinstance(payload["actionId"], str): + raise BrowserRequestValidationError("Conversation fields have invalid types") + lifecycle = _conversations.apply if method == "conversation.apply" else _conversations.discard + return lifecycle( + session_id=payload["sessionId"], + action_id=payload["actionId"], + workspace_revision=_integer(payload["workspaceRevision"]), + ) + if method == "conversation.reset": + _require_exact_fields(payload, {"sessionId"}) + if not isinstance(payload["sessionId"], str): + raise BrowserRequestValidationError("sessionId must be a string") + _conversations.reset(session_id=payload["sessionId"]) + return None raise AssertionError(f"Unsupported validated browser compiler method: {method}") -def _serialize_result(result: _DispatchResult) -> dict[str, Any]: +def _serialize_result(result: _DispatchResult) -> Any: + if result is None: + return None if isinstance(result, BrowserWorkspaceResult): return { "workspace_revision": result.workspace_revision, "diagnostics": [asdict(diagnostic) for diagnostic in result.diagnostics], "source_hashes": dict(result.source_hashes), } - if isinstance(result, BrowserAiPendingResult): + if isinstance(result, PendingPlanRequest): return { "status": "pending_llm", - "llm_request": asdict(result.llm_request), + "request_id": result.request_id, + "attempt": result.attempt, + "llm_request": asdict(result.request), } - return asdict(result) + if isinstance(result, BrowserConversationReply): + return _jsonable(asdict(result)) + return _jsonable(asdict(result)) + + +def _jsonable(value: Any) -> Any: + if isinstance(value, Path): + return value.as_posix() + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + return value def dispatch_browser_request(method: str, payload_json: str) -> str: @@ -275,6 +331,8 @@ def dispatch_browser_request(method: str, payload_json: str) -> str: return _error_response("INVALID_REQUEST") except BrowserLanguageError as error: return _error_response(error.code) + except PlanningRequestError: + return _error_response("INVALID_REQUEST") return json.dumps( {"ok": True, "result": _serialize_result(result)}, @@ -286,5 +344,6 @@ def dispatch_browser_request(method: str, payload_json: str) -> str: def _reset_compiler_for_tests() -> None: """Reset module state for deterministic in-process tests.""" - global _compiler + global _compiler, _conversations _compiler = BrowserCompiler() + _conversations = BrowserConversationService(_compiler) diff --git a/cli/src/modelable/browser/dto.py b/cli/src/modelable/browser/dto.py index 640499ee..2d510d28 100644 --- a/cli/src/modelable/browser/dto.py +++ b/cli/src/modelable/browser/dto.py @@ -211,27 +211,3 @@ class BrowserGovernanceFinding: class BrowserGovernanceResult: workspace_revision: int findings: tuple[BrowserGovernanceFinding, ...] - - -@dataclass(frozen=True) -class BrowserLlmRequest: - system: str - user: str - temperature: float - response_format: str - - -@dataclass(frozen=True) -class BrowserAiPendingResult: - llm_request: BrowserLlmRequest - - -@dataclass(frozen=True) -class BrowserAiGenerateResult: - source: str - diagnostics: tuple[BrowserDiagnostic, ...] - - -@dataclass(frozen=True) -class BrowserAiExplainResult: - explanation: str diff --git a/cli/src/modelable/llm/__init__.py b/cli/src/modelable/llm/__init__.py index c060cd93..6d3285ed 100644 --- a/cli/src/modelable/llm/__init__.py +++ b/cli/src/modelable/llm/__init__.py @@ -8,14 +8,20 @@ build_workspace_summary, parse_model_ref, ) -from .providers import ( - AnthropicProvider, - LLMProvider, - LLMRequest, - LLMResponse, - OllamaProvider, - build_provider, +from .conversation_backend import ( + ConversationBackend, + ConversationPreviewFile, + ConversationReply, + ReplyKind, ) +from .conversation_engine import ConversationEngine, ConversationOutcome +from .conversation_planner import ( + PendingPlanRequest, + PlanningRequestError, + ResumableConversationPlanner, +) +from .provider_types import LLMProvider, LLMRequest, LLMResponse +from .providers import AnthropicProvider, OllamaProvider, build_provider from .redaction import redact_sensitive_values from .update_plan import UpdateChange, UpdatePlan, build_update_request, parse_update_plan @@ -23,11 +29,20 @@ "CHAT_SYSTEM_PROMPT", "AnthropicProvider", "ChatState", + "ConversationBackend", + "ConversationEngine", + "ConversationOutcome", + "ConversationPreviewFile", + "ConversationReply", "LLMProvider", "LLMRequest", "LLMResponse", "LlmConfig", "OllamaProvider", + "PendingPlanRequest", + "PlanningRequestError", + "ReplyKind", + "ResumableConversationPlanner", "UpdateChange", "UpdatePlan", "build_model_summary", diff --git a/cli/src/modelable/llm/chat.py b/cli/src/modelable/llm/chat.py index 2a72a622..6e272916 100644 --- a/cli/src/modelable/llm/chat.py +++ b/cli/src/modelable/llm/chat.py @@ -17,7 +17,7 @@ ) from modelable.llm.conversation import ConversationSession from modelable.llm.engine import AttachResult, UpdateResult, recommend_cli, update_definition -from modelable.llm.providers import LLMProvider, LLMRequest +from modelable.llm.provider_types import LLMProvider, LLMRequest from modelable.llm.qa import answer_question diff --git a/cli/src/modelable/llm/conversation.py b/cli/src/modelable/llm/conversation.py index d7cbb86b..97371c3f 100644 --- a/cli/src/modelable/llm/conversation.py +++ b/cli/src/modelable/llm/conversation.py @@ -3,35 +3,33 @@ import re import unicodedata import uuid -from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Literal, TypeIs - -from modelable.compiler.workspace import load_workspace -from modelable.diagnostics.model import Diagnostic, render_diagnostic -from modelable.llm.context import build_workspace_summary -from modelable.llm.conversation_plan import ( - ChangeSetPlan, - ClarificationPlan, - CompilePlan, - ConversationPlan, - Operation, - QueryPlan, - UnsupportedPlan, +from typing import TYPE_CHECKING, Literal, cast + +from modelable.compiler.workspace import Workspace +from modelable.diagnostics.model import render_diagnostic +from modelable.llm.conversation_backend import ( + ConversationCleanupError as ConversationCleanupError, +) +from modelable.llm.conversation_backend import ( + ConversationPreviewFile as ConversationPreviewFile, +) +from modelable.llm.conversation_backend import ( + ConversationReply as ConversationReply, +) +from modelable.llm.conversation_backend import ( + ReplyKind as ReplyKind, ) +from modelable.llm.conversation_engine import ConversationEngine +from modelable.llm.conversation_plan import CompilePlan, Operation from modelable.llm.conversation_planner import ( - ConversationPlanner, - PlannerContext, - parse_compile_command, + PendingPlanRequest, + ResumableConversationPlanner, ) -from modelable.llm.providers import LLMProvider +from modelable.llm.provider_types import LLMProvider from modelable.llm.workspace_editor import ( - AffectedDefinition, AppliedChangeSet, - ChangedDefinition, - CompatibilityFinding, PendingChangeSet, - WorkspaceEditError, WorkspaceEditor, ) from modelable.llm.workspace_query import QueryResult, WorkspaceQueryService @@ -39,56 +37,11 @@ if TYPE_CHECKING: from modelable.operations.compilation import ( AppliedCompilation, - CompilationFilePreview, CompilationService, PendingCompilation, - RegistryIdChange, ) from modelable.operations.file_transaction import FileTransactionCommittedError -type ReplyKind = Literal[ - "answer", - "clarification", - "preview", - "applied", - "discarded", - "unsupported", - "error", -] -type PendingAction = PendingChangeSet | PendingCompilation - - -class ConversationCleanupError(RuntimeError): - def __init__(self, errors: tuple[str, ...]) -> None: - self.errors = errors - super().__init__("Conversation cleanup failed:\n" + "\n".join(f"- {error}" for error in errors)) - - -@dataclass(frozen=True) -class ConversationPreviewFile: - path: Path - existed_before: bool - before_text: str - after_text: str - - -@dataclass(frozen=True) -class ConversationReply: - kind: ReplyKind - text: str - change_set_id: str | None = None - operation_kind: Literal["source_change", "compile"] | None = None - focused_ref: str | None = None - changed: tuple[ChangedDefinition, ...] = () - affected: tuple[AffectedDefinition, ...] = () - compatibility: tuple[CompatibilityFinding, ...] = () - diagnostics: tuple[Diagnostic, ...] = () - preview_files: tuple[ConversationPreviewFile, ...] = () - written_paths: tuple[Path, ...] = () - compilation_files: tuple[CompilationFilePreview, ...] = () - registry_id_changes: tuple[RegistryIdChange, ...] = () - audit_path: Path | None = None - class ConversationSession: def __init__( @@ -104,388 +57,112 @@ def __init__( model_name: str | None = None, confirmation_surface: Literal["cli-chat", "vscode-chat"] = "cli-chat", ) -> None: - if compilation_service is None: - from modelable.operations.compilation import CompilationService + from modelable.llm.filesystem_conversation import FilesystemConversationBackend - compilation_service = CompilationService() self.path = path self.provider = provider - self.focused_ref = focused_ref - self.history: list[tuple[str, str]] = [] - self._pending: PendingAction | None = None - self._cleanup_backlog: dict[str, PendingCompilation] = {} - self.compilation_service = compilation_service self.session_id = session_id or str(uuid.uuid4()) self.provider_name = provider_name self.model_name = model_name self.confirmation_surface = confirmation_surface - self.workspace = load_workspace(path) - self.planner = ConversationPlanner(provider, repair_attempts=repair_attempts) - self.editor: WorkspaceEditor | None = None - self._reload_services() + self.backend = FilesystemConversationBackend( + path=path, + compilation_service=compilation_service, + session_id=self.session_id, + provider_name=provider_name, + model_name=model_name, + confirmation_surface=confirmation_surface, + ) + self.engine = ConversationEngine( + backend=self.backend, + planner=ResumableConversationPlanner(repair_attempts=repair_attempts), + focused_ref=focused_ref, + completion_enabled=provider is not None, + ) @property - def pending(self) -> PendingAction | None: - return self._pending - - @pending.setter - def pending(self, value: PendingAction | None) -> None: - self._pending = value + def pending(self) -> PendingChangeSet | PendingCompilation | None: + return self.backend.pending @property def pending_action_id(self) -> str | None: - return _pending_id(self._pending) + return self.engine.pending_action_id @property def pending_operation_kind(self) -> Literal["source_change", "compile"] | None: - if _is_pending_compilation(self._pending): - return "compile" - if isinstance(self._pending, PendingChangeSet): - return "source_change" - return None + return cast( + Literal["source_change", "compile"] | None, + self.engine.pending_operation_kind, + ) @property def pending_cleanup_ids(self) -> tuple[str, ...]: - return tuple(sorted(self._cleanup_backlog)) + return self.backend.pending_cleanup_ids - def turn(self, message: str) -> ConversationReply: - try: - reply = self._turn(message) - except BaseException as error: - self._cleanup_after_exception(error) - raise - self.history.append(("user", message)) - self.history.append(("assistant", reply.text)) - return reply - - def _turn(self, message: str) -> ConversationReply: - normalized = message.strip() - lowered = normalized.lower() - if message == "/apply": - reply = self._apply_pending() - elif _is_pending_compilation(self.pending) and normalized.lower() == "/apply": - reply = ConversationReply( - kind="error", - text=( - "Compilation requires the exact case-sensitive /apply command with no surrounding whitespace. " - "Use /discard to cancel or another request to replace the preview." - ), - change_set_id=self.pending.action_id, - operation_kind="compile", - ) - elif normalized == "/apply": - reply = self._apply_pending() - elif lowered in {"apply", "apply it", "confirm"}: - if _is_pending_compilation(self.pending): - reply = ConversationReply( - kind="error", - text=( - "Compilation requires the exact case-sensitive /apply command. " - "Use /discard to cancel or another request to replace the preview." - ), - change_set_id=self.pending.action_id, - operation_kind="compile", - ) - else: - reply = self._apply_pending() - elif lowered in {"discard", "discard it", "cancel"} or normalized == "/discard": - reply = self._discard_pending() - else: - reply = self._plan_and_execute(normalized) - return reply - - def _plan_and_execute(self, message: str) -> ConversationReply: - command = message.split(maxsplit=1) - plan: ConversationPlan - if command and command[0] == "/compile": - plan = parse_compile_command(message) - else: - plan = self.planner.plan( - message, - PlannerContext( - workspace_summary=build_workspace_summary(self.workspace), - focused_ref=self.focused_ref, - history=tuple(self.history), - pending_plan=self.pending.plan if isinstance(self.pending, PendingChangeSet) else None, - ), - ) - if isinstance(plan, QueryPlan): - return ConversationReply( - kind="answer", - text=render_query_result(self.query_service.execute(plan)), - ) - if isinstance(plan, ClarificationPlan): - return ConversationReply( - kind="clarification", - text=f"{plan.question}\n\nReason: {plan.reason}", - ) - if isinstance(plan, UnsupportedPlan): - roadmap = f"\n\nRoadmap area: {plan.roadmap_area}" if plan.roadmap_area else "" - return ConversationReply( - kind="unsupported", - text=f"{plan.reason}{roadmap}", - ) - if isinstance(plan, ChangeSetPlan): - return self._preview_source_change(plan) - if isinstance(plan, CompilePlan): - return self._preview_compilation(plan) - return ConversationReply(kind="error", text="The request produced an unknown conversation plan.") - - def _preview_source_change(self, plan: ChangeSetPlan) -> ConversationReply: - replaced = self.pending - replaced_id = _pending_id(replaced) - try: - if self.editor is None: - self.editor = WorkspaceEditor(self.path, workspace=self.workspace) - pending = self.editor.preview(plan) - except WorkspaceEditError as error: - return ConversationReply(kind="error", text=f"Could not preview workspace changes: {error}") - cleanup_errors = self._dispose_actions((replaced,)) - if cleanup_errors: - return ConversationReply( - kind="error", - text=_render_cleanup_failure("Could not replace the pending action.", cleanup_errors), - change_set_id=_pending_id(replaced), - ) - self.pending = pending - replacement = ( - f"Replaced pending change set {replaced_id} with {pending.change_set_id}.\n\n" - if replaced_id is not None - else "" - ) - current_sources = {source.path: source.text for source in self.workspace.sources if source.path is not None} - preview_files = tuple( - ConversationPreviewFile( - path=path, - existed_before=path in current_sources, - before_text=current_sources.get(path, ""), - after_text=after_text, - ) - for path, after_text in sorted(pending.candidate_sources.items()) - ) - return ConversationReply( - kind="preview", - text=replacement + render_pending_change_set(pending), - change_set_id=pending.change_set_id, - operation_kind="source_change", - focused_ref=pending.focus_ref, - changed=tuple(pending.changed), - affected=tuple(pending.affected), - compatibility=tuple(pending.compatibility), - diagnostics=tuple(pending.diagnostics), - preview_files=preview_files, - ) + @property + def focused_ref(self) -> str | None: + return self.engine.focused_ref - def _preview_compilation(self, plan: CompilePlan) -> ConversationReply: - from modelable.operations.compilation import ( - CompilationError, - CompilationPolicy, - CompilationRequest, - ) + @focused_ref.setter + def focused_ref(self, value: str | None) -> None: + self.engine.focused_ref = value - replaced = self.pending - replaced_id = _pending_id(replaced) - try: - pending = self.compilation_service.preview( - CompilationRequest( - source=self.path, - target=plan.target, - out_dir=Path(plan.output) if plan.output is not None else None, - domains=tuple(plan.domains), - descriptor_set=plan.descriptor_set, - ), - policy=CompilationPolicy.conversation(), - ) - except CompilationError as error: - return ConversationReply( - kind="error", - text=f"Could not preview compilation: {_escape_inline(error)}", - ) - cleanup_errors = self._dispose_actions((replaced,)) - if cleanup_errors: - cleanup_errors += self._dispose_actions((pending,)) - self.pending = None - return ConversationReply( - kind="error", - text=_render_cleanup_failure( - "Could not replace the pending action; all staged actions remain tracked for cleanup.", - cleanup_errors, - ), - operation_kind="compile", - ) - self.pending = pending - replacement = ( - f"Replaced pending action {replaced_id} with compilation {pending.action_id}.\n\n" - if replaced_id is not None - else "" - ) - return ConversationReply( - kind="preview", - text=replacement + render_pending_compilation(pending, plan), - change_set_id=pending.action_id, - operation_kind="compile", - affected=pending.affected_definitions, - compilation_files=pending.files, - registry_id_changes=pending.registry_id_changes, - audit_path=pending.audit_path, - ) + @property + def history(self) -> list[tuple[str, str]]: + return self.engine.history - def _apply_pending(self) -> ConversationReply: - if self.pending is None: - return ConversationReply(kind="error", text="There is no pending action to apply.") - if _is_pending_compilation(self.pending): - return self._apply_pending_compilation(self.pending) - if self.editor is None: - return ConversationReply( - kind="error", - text=f"Could not apply change set {self.pending.change_set_id}: the preview editor is unavailable.", - change_set_id=self.pending.change_set_id, - ) - try: - applied = self.editor.apply(self.pending) - except WorkspaceEditError as error: - return ConversationReply( - kind="error", - text=f"Could not apply change set {self.pending.change_set_id}: {error}", - change_set_id=self.pending.change_set_id, - ) - self.workspace = applied.workspace - self.focused_ref = applied.focus_ref - self.pending = None - self._reload_services() - return ConversationReply( - kind="applied", - text=render_applied_change_set(applied), - change_set_id=applied.change_set_id, - operation_kind="source_change", - focused_ref=applied.focus_ref, - changed=tuple(applied.changed), - compatibility=tuple(applied.compatibility), - written_paths=applied.written_paths, - ) + @property + def workspace(self) -> Workspace: + return self.backend.workspace - def _apply_pending_compilation(self, pending: PendingCompilation) -> ConversationReply: - from modelable.operations.compilation import CompilationConfirmation - from modelable.operations.file_transaction import FileTransactionCommittedError + @property + def editor(self) -> WorkspaceEditor | None: + return self.backend.editor - confirmation = CompilationConfirmation( - session_id=self.session_id, - action_id=pending.action_id, - manifest_fingerprint=pending.manifest_fingerprint, - surface=self.confirmation_surface, - provider=self.provider_name, - model=self.model_name, - ) - try: - applied = self.compilation_service.apply(pending, confirmation=confirmation) - except FileTransactionCommittedError as error: - self._cleanup_backlog.pop(pending.action_id, None) - self.pending = None - audit_path = pending.audit_path - return ConversationReply( - kind="applied", - text=render_committed_compilation_cleanup_error(pending, error, audit_path), - change_set_id=pending.action_id, - operation_kind="compile", - affected=pending.affected_definitions, - written_paths=error.written_paths, - compilation_files=pending.files, - registry_id_changes=pending.registry_id_changes, - audit_path=audit_path, - ) - except Exception as error: - if not pending.staging_dir.exists(): - self._cleanup_backlog.pop(pending.action_id, None) - self.pending = None - else: - self._cleanup_backlog[pending.action_id] = pending - return ConversationReply( - kind="error", - text=(f"Could not apply compilation {_escape_inline(pending.action_id)}: {_escape_inline(error)}"), - change_set_id=pending.action_id, - operation_kind="compile", - ) - self._cleanup_backlog.pop(pending.action_id, None) - self.pending = None - return ConversationReply( - kind="applied", - text=render_applied_compilation(applied), - change_set_id=applied.action_id, - operation_kind="compile", - affected=applied.affected_definitions, - written_paths=applied.written_paths, - compilation_files=applied.files, - registry_id_changes=pending.registry_id_changes, - audit_path=applied.audit_path, - ) + @property + def query_service(self) -> WorkspaceQueryService: + return self.backend.query_service - def _discard_pending(self) -> ConversationReply: - if self.pending is None and not self._cleanup_backlog: - return ConversationReply(kind="error", text="There is no pending action to discard.") - cleanup_only = self.pending is None - cleanup_ids = tuple(sorted(self._cleanup_backlog)) - change_set_id = _pending_id(self.pending) or (cleanup_ids[0] if cleanup_ids else None) - operation_kind: Literal["source_change", "compile"] = ( - "compile" if cleanup_only or _is_pending_compilation(self.pending) else "source_change" - ) - cleanup_errors = self._dispose_actions((self.pending, *self._cleanup_backlog.values())) - if cleanup_errors: - return ConversationReply( - kind="error", - text=_render_cleanup_failure( - ( - "Could not fully discard staged compilation cleanup; cleanup will be retried." - if cleanup_only - else "Could not fully discard the pending action; cleanup will be retried." - ), - cleanup_errors, - ), - change_set_id=change_set_id, - operation_kind=operation_kind, - ) - self.pending = None - return ConversationReply( - kind="discarded", - text=( - f"Discarded staged compilation cleanup {', '.join(cleanup_ids)}." - if cleanup_only - else f"Discarded pending action {change_set_id}." - ), - change_set_id=change_set_id, - operation_kind=operation_kind, - ) + @property + def compilation_service(self) -> CompilationService: + return self.backend.compilation_service - def close(self) -> None: - cleanup_errors = self._dispose_actions((self.pending, *self._cleanup_backlog.values())) - if cleanup_errors: - raise ConversationCleanupError(cleanup_errors) - self.pending = None - - def _dispose_actions(self, actions: tuple[PendingAction | None, ...]) -> tuple[str, ...]: - errors: list[str] = [] - seen: set[str] = set() - for action in actions: - if not _is_pending_compilation(action) or action.action_id in seen: - continue - seen.add(action.action_id) - try: - self.compilation_service.discard(action) - except Exception as error: - self._cleanup_backlog[action.action_id] = action - errors.append(f"{action.action_id}: {error}") - else: - self._cleanup_backlog.pop(action.action_id, None) - return tuple(errors) - - def _cleanup_after_exception(self, error: BaseException) -> None: + def turn(self, message: str) -> ConversationReply: try: - self.close() - except Exception as cleanup_error: - error.add_note(str(cleanup_error)) + normalized = message.strip() + lowered = normalized.lower() + if ( + self.backend.pending is None + and self.backend.cleanup_action_id is not None + and (lowered in {"discard", "discard it", "cancel"} or normalized == "/discard") + ): + reply = self.backend.discard(self.backend.cleanup_action_id) + return self.engine.record_completed_reply(message, reply) + outcome = self.engine.begin_turn(message) + while isinstance(outcome, PendingPlanRequest): + if self.provider is None: + outcome = self.engine.fail_turn( + outcome.request_id, + RuntimeError("Pending planning requires a provider"), + ) + break + try: + response = self.provider.complete(outcome.request) + except Exception as error: + outcome = self.engine.fail_turn(outcome.request_id, error) + break + outcome = self.engine.resume_turn(outcome.request_id, response.content) + self.engine.synchronize_pending_action( + self.backend.pending_action_id, + self.backend.pending_operation_kind, + ) + return outcome + except BaseException as error: + self.backend.cleanup_after_exception(error) + raise - def _reload_services(self) -> None: - self.query_service = WorkspaceQueryService(self.workspace) - self.editor = None + def close(self) -> None: + self.backend.close() def render_query_result(result: QueryResult) -> str: @@ -701,20 +378,6 @@ def _render_cleanup_failure(summary: str, errors: tuple[str, ...]) -> str: ) -def _pending_id(pending: PendingAction | None) -> str | None: - if _is_pending_compilation(pending): - return pending.action_id - if isinstance(pending, PendingChangeSet): - return pending.change_set_id - return None - - -def _is_pending_compilation(pending: object) -> TypeIs[PendingCompilation]: - from modelable.operations.compilation import PendingCompilation - - return isinstance(pending, PendingCompilation) - - def _operation_target(operation: Operation) -> str: domain = getattr(operation, "domain", None) name = getattr(operation, "name", None) diff --git a/cli/src/modelable/llm/conversation_backend.py b/cli/src/modelable/llm/conversation_backend.py new file mode 100644 index 00000000..5e91584a --- /dev/null +++ b/cli/src/modelable/llm/conversation_backend.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Literal, Protocol + +if TYPE_CHECKING: + from modelable.diagnostics.model import Diagnostic + from modelable.llm.conversation_plan import ChangeSetPlan, CompilePlan, QueryPlan + from modelable.llm.workspace_editor import ( + AffectedDefinition, + ChangedDefinition, + CompatibilityFinding, + ) + from modelable.operations.compilation import CompilationFilePreview, RegistryIdChange + +type ReplyKind = Literal[ + "answer", + "clarification", + "preview", + "applied", + "discarded", + "unsupported", + "error", +] + + +class ConversationCleanupError(RuntimeError): + def __init__(self, errors: tuple[str, ...]) -> None: + self.errors = errors + super().__init__("Conversation cleanup failed:\n" + "\n".join(f"- {error}" for error in errors)) + + +@dataclass(frozen=True) +class ConversationPreviewFile: + path: Path + existed_before: bool + before_text: str + after_text: str + + +@dataclass(frozen=True) +class ConversationReply: + kind: ReplyKind + text: str + change_set_id: str | None = None + operation_kind: Literal["source_change", "compile"] | None = None + focused_ref: str | None = None + changed: tuple[ChangedDefinition, ...] = () + affected: tuple[AffectedDefinition, ...] = () + compatibility: tuple[CompatibilityFinding, ...] = () + diagnostics: tuple[Diagnostic, ...] = () + preview_files: tuple[ConversationPreviewFile, ...] = () + written_paths: tuple[Path, ...] = () + compilation_files: tuple[CompilationFilePreview, ...] = () + registry_id_changes: tuple[RegistryIdChange, ...] = () + audit_path: Path | None = None + + +class ConversationBackend(Protocol): + def workspace_summary(self) -> str: ... + + def execute_query(self, plan: QueryPlan) -> ConversationReply: ... + + def preview_source_change( + self, + plan: ChangeSetPlan, + replaced_action_id: str | None, + ) -> ConversationReply: ... + + def preview_compilation( + self, + plan: CompilePlan, + replaced_action_id: str | None, + ) -> ConversationReply: ... + + def apply(self, action_id: str) -> ConversationReply: ... + + def discard(self, action_id: str) -> ConversationReply: ... + + def reset(self) -> None: ... diff --git a/cli/src/modelable/llm/conversation_engine.py b/cli/src/modelable/llm/conversation_engine.py new file mode 100644 index 00000000..be7f01ab --- /dev/null +++ b/cli/src/modelable/llm/conversation_engine.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from modelable.llm.conversation_backend import ConversationBackend, ConversationReply +from modelable.llm.conversation_plan import ( + ChangeSetPlan, + ClarificationPlan, + CompilePlan, + ConversationPlan, + QueryPlan, + UnsupportedPlan, +) +from modelable.llm.conversation_planner import ( + PendingPlanRequest, + PlannerContext, + PlanningRequestError, + ResumableConversationPlanner, + parse_compile_command, +) + +type ConversationOutcome = ConversationReply | PendingPlanRequest + + +class ConversationEngine: + def __init__( + self, + *, + backend: ConversationBackend, + planner: ResumableConversationPlanner, + focused_ref: str | None = None, + completion_enabled: bool = True, + ) -> None: + self.backend = backend + self.planner = planner + self.focused_ref = focused_ref + self.completion_enabled = completion_enabled + self.history: list[tuple[str, str]] = [] + self.pending_action_id: str | None = None + self.pending_operation_kind: str | None = None + self._pending_change_plan: ChangeSetPlan | None = None + self._pending_request_id: str | None = None + self._pending_message: str | None = None + + def begin_turn(self, message: str) -> ConversationOutcome: + if self._pending_request_id is not None: + raise PlanningRequestError( + f"Planning request {self._pending_request_id} must be resumed or reset before starting another turn" + ) + normalized = message.strip() + lowered = normalized.lower() + if message == "/apply": + return self._complete_turn(message, self._apply_from_turn()) + if self.pending_operation_kind == "compile" and normalized.lower() == "/apply": + return self._complete_turn( + message, + ConversationReply( + kind="error", + text=( + "Compilation requires the exact case-sensitive /apply command with no surrounding whitespace. " + "Use /discard to cancel or another request to replace the preview." + ), + change_set_id=self.pending_action_id, + operation_kind="compile", + ), + ) + if normalized == "/apply" or lowered in {"apply", "apply it", "confirm"}: + if self.pending_operation_kind == "compile": + reply = ConversationReply( + kind="error", + text=( + "Compilation requires the exact case-sensitive /apply command. " + "Use /discard to cancel or another request to replace the preview." + ), + change_set_id=self.pending_action_id, + operation_kind="compile", + ) + else: + reply = self._apply_from_turn() + return self._complete_turn(message, reply) + if lowered in {"discard", "discard it", "cancel"} or normalized == "/discard": + return self._complete_turn(message, self._discard_from_turn()) + + command = normalized.split(maxsplit=1) + if command and command[0] == "/compile": + return self._complete_plan(normalized, parse_compile_command(normalized)) + context = PlannerContext( + workspace_summary=self.backend.workspace_summary(), + focused_ref=self.focused_ref, + history=tuple(self.history), + pending_plan=self._pending_change_plan, + ) + outcome = ( + self.planner.begin(normalized, context) + if self.completion_enabled + else self.planner.offline(normalized, context) + ) + if isinstance(outcome, PendingPlanRequest): + self._pending_request_id = outcome.request_id + self._pending_message = normalized + return outcome + return self._complete_plan(normalized, outcome) + + def resume_turn(self, request_id: str, content: str) -> ConversationOutcome: + if request_id != self._pending_request_id or self._pending_message is None: + raise PlanningRequestError(f"Unknown or completed planning request: {request_id}") + message = self._pending_message + outcome = self.planner.resume(request_id, content) + if isinstance(outcome, PendingPlanRequest): + self._pending_request_id = outcome.request_id + return outcome + self._pending_request_id = None + self._pending_message = None + return self._complete_plan(message, outcome) + + def fail_turn(self, request_id: str, error: Exception) -> ConversationReply: + if request_id != self._pending_request_id or self._pending_message is None: + raise PlanningRequestError(f"Unknown or completed planning request: {request_id}") + message = self._pending_message + plan = self.planner.fail(request_id, error) + self._pending_request_id = None + self._pending_message = None + return self._complete_plan(message, plan) + + def apply(self, action_id: str) -> ConversationReply: + self._assert_pending_action(action_id) + return self._complete_turn("/apply", self._apply(action_id)) + + def discard(self, action_id: str) -> ConversationReply: + self._assert_pending_action(action_id) + return self._complete_turn("/discard", self._discard(action_id)) + + def reset(self) -> None: + if self._pending_request_id is not None: + self.planner.cancel(self._pending_request_id) + self.backend.reset() + self.focused_ref = None + self.history.clear() + self.pending_action_id = None + self.pending_operation_kind = None + self._pending_change_plan = None + self._pending_request_id = None + self._pending_message = None + + def synchronize_pending_action( + self, + action_id: str | None, + operation_kind: str | None, + ) -> None: + self.pending_action_id = action_id + self.pending_operation_kind = operation_kind + if action_id is None: + self._pending_change_plan = None + + def record_completed_reply(self, message: str, reply: ConversationReply) -> ConversationReply: + return self._complete_turn(message, reply) + + def _complete_plan(self, message: str, plan: ConversationPlan) -> ConversationReply: + reply = self._execute_plan(plan) + return self._complete_turn(message, reply) + + def _execute_plan(self, plan: ConversationPlan) -> ConversationReply: + if isinstance(plan, QueryPlan): + return self.backend.execute_query(plan) + if isinstance(plan, ClarificationPlan): + return ConversationReply( + kind="clarification", + text=f"{plan.question}\n\nReason: {plan.reason}", + ) + if isinstance(plan, UnsupportedPlan): + roadmap = f"\n\nRoadmap area: {plan.roadmap_area}" if plan.roadmap_area else "" + return ConversationReply(kind="unsupported", text=f"{plan.reason}{roadmap}") + if isinstance(plan, ChangeSetPlan): + reply = self.backend.preview_source_change(plan, self.pending_action_id) + if reply.kind == "preview" and reply.change_set_id is not None: + self._track_preview(reply) + self._pending_change_plan = plan + return reply + if isinstance(plan, CompilePlan): + reply = self.backend.preview_compilation(plan, self.pending_action_id) + if reply.kind == "preview" and reply.change_set_id is not None: + self._track_preview(reply) + self._pending_change_plan = None + return reply + return ConversationReply(kind="error", text="The request produced an unknown conversation plan.") + + def _apply(self, action_id: str) -> ConversationReply: + reply = self.backend.apply(action_id) + if reply.kind == "applied": + self._clear_pending_action() + if reply.focused_ref is not None: + self.focused_ref = reply.focused_ref + return reply + + def _discard(self, action_id: str) -> ConversationReply: + reply = self.backend.discard(action_id) + if reply.kind == "discarded": + self._clear_pending_action() + return reply + + def _apply_from_turn(self) -> ConversationReply: + if self.pending_action_id is None: + return ConversationReply(kind="error", text="There is no pending action to apply.") + return self._apply(self.pending_action_id) + + def _discard_from_turn(self) -> ConversationReply: + if self.pending_action_id is None: + return ConversationReply(kind="error", text="There is no pending action to discard.") + return self._discard(self.pending_action_id) + + def _track_preview(self, reply: ConversationReply) -> None: + self.pending_action_id = reply.change_set_id + self.pending_operation_kind = reply.operation_kind + if reply.focused_ref is not None: + self.focused_ref = reply.focused_ref + + def _clear_pending_action(self) -> None: + self.pending_action_id = None + self.pending_operation_kind = None + self._pending_change_plan = None + + def _assert_pending_action(self, action_id: str) -> None: + if action_id != self.pending_action_id: + raise ValueError(f"Action ID {action_id!r} does not match pending action {self.pending_action_id!r}") + + def _complete_turn(self, message: str, reply: ConversationReply) -> ConversationReply: + self.history.append(("user", message)) + self.history.append(("assistant", reply.text)) + return reply diff --git a/cli/src/modelable/llm/conversation_plan.py b/cli/src/modelable/llm/conversation_plan.py index 8bc74e20..80dbf26d 100644 --- a/cli/src/modelable/llm/conversation_plan.py +++ b/cli/src/modelable/llm/conversation_plan.py @@ -380,6 +380,7 @@ def parse_conversation_plan(text: str) -> ConversationPlan: def conversation_plan_json_schema() -> dict[str, object]: schema = _CONVERSATION_PLAN_ADAPTER.json_schema() _close_object_schemas(schema) + _require_discriminators(schema) return schema @@ -392,3 +393,19 @@ def _close_object_schemas(node: object) -> None: elif isinstance(node, list): for value in node: _close_object_schemas(value) + + +def _require_discriminators(node: object) -> None: + if isinstance(node, dict): + properties = node.get("properties") + if isinstance(properties, dict): + kind = properties.get("kind") + if isinstance(kind, dict) and "const" in kind: + required = node.setdefault("required", []) + if isinstance(required, list) and "kind" not in required: + required.insert(0, "kind") + for value in node.values(): + _require_discriminators(value) + elif isinstance(node, list): + for value in node: + _require_discriminators(value) diff --git a/cli/src/modelable/llm/conversation_planner.py b/cli/src/modelable/llm/conversation_planner.py index b701e875..b39ac9a4 100644 --- a/cli/src/modelable/llm/conversation_planner.py +++ b/cli/src/modelable/llm/conversation_planner.py @@ -3,8 +3,10 @@ import json import re import shlex +from collections.abc import Callable from dataclasses import dataclass from typing import cast +from uuid import uuid4 from pydantic import ValidationError @@ -20,7 +22,7 @@ conversation_plan_json_schema, parse_conversation_plan, ) -from modelable.llm.providers import LLMProvider, LLMRequest +from modelable.llm.provider_types import LLMProvider, LLMRequest SYSTEM_PROMPT = """You plan grounded requests against a Modelable workspace. Return JSON only matching the supplied closed schema. Return exactly one of the five plan kinds: @@ -34,6 +36,14 @@ whether an address is inline or a reusable address model, or a projection source. For changes to an existing contract, default to append-version operations and target the appended version; do not rewrite a published version in place. +Use ChangeSetPlan, never CompilePlan, when the user asks to create or change Modelable +models, projections, fields, indexes, or annotations. CompilePlan is only for generating +artifacts from an already-defined workspace. +Examples: +{"kind":"change_set","summary":"Create billing.Invoice@1","operations":[{"kind":"create_model","domain":"billing","name":"Invoice","model_kind":"entity","fields":[{"name":"invoiceId","type":{"kind":"uuid"},"annotations":[{"kind":"key"}]}]}]} +{"kind":"change_set","summary":"Create billing.CustomerProjection@1","operations":[{"kind":"create_projection","domain":"billing","name":"CustomerProjection","source":{"model":"customer.Customer","version":1,"alias":"customer"},"fields":[{"name":"customerId","mapping":{"kind":"direct","source_alias":"customer","source_field":"customerId"}}]}]} +{"kind":"change_set","summary":"Add email in customer.Customer@2","operations":[{"kind":"append_model_version","source":"customer.Customer@1","version":2},{"kind":"add_field","target":"customer.Customer@2","field":{"name":"email","type":{"kind":"string"},"optional":true}}]} +{"kind":"clarification","question":"Which source model and consumer domain should I use?","reason":"A projection requires a grounded source and consumer."} CompilePlan permits only a target, domain filters, a normalized local relative output, the descriptor flag, and a summary. Examples: {"kind":"compile","target":"rust","domains":[],"output":null,"descriptor_set":false,"summary":"Compile the workspace to Rust."} @@ -56,6 +66,109 @@ class PlannerContext: pending_plan: ChangeSetPlan | None +@dataclass(frozen=True) +class PendingPlanRequest: + request_id: str + request: LLMRequest + attempt: int + + +@dataclass(frozen=True) +class _PendingPlanningState: + message: str + context: PlannerContext + attempt: int + + +class PlanningRequestError(ValueError): + pass + + +class ResumableConversationPlanner: + def __init__( + self, + *, + repair_attempts: int = 1, + id_factory: Callable[[], str] | None = None, + ) -> None: + if repair_attempts < 0: + raise ValueError("repair_attempts must be >= 0") + self.repair_attempts = repair_attempts + self.id_factory = id_factory or (lambda: str(uuid4())) + self._pending: dict[str, _PendingPlanningState] = {} + self._used_request_ids: set[str] = set() + + def offline(self, message: str, context: PlannerContext) -> ConversationPlan: + return ConversationPlanner._offline_plan(message, context) + + def begin(self, message: str, context: PlannerContext) -> ConversationPlan | PendingPlanRequest: + offline = self.offline(message, context) + if not _requires_provider(message, offline): + return offline + return self._register( + _PendingPlanningState( + message=message, + context=context, + attempt=0, + ), + validation_error=None, + ) + + def resume(self, request_id: str, content: str) -> ConversationPlan | PendingPlanRequest: + state = self._consume(request_id) + try: + plan = parse_conversation_plan(content) + _validate_plan_intent(state.message, plan) + return plan + except Exception as error: + if state.attempt >= self.repair_attempts: + return UnsupportedPlan( + request=state.message, + reason=f"The configured provider did not return a valid typed plan: {error}", + ) + repair_state = _PendingPlanningState( + message=state.message, + context=state.context, + attempt=state.attempt + 1, + ) + return self._register(repair_state, validation_error=str(error)) + + def fail(self, request_id: str, error: Exception) -> UnsupportedPlan: + state = self._consume(request_id) + phase = "initial plan request" if state.attempt == 0 else "plan repair request" + return ConversationPlanner._provider_failure(state.message, error, phase=phase) + + def cancel(self, request_id: str) -> None: + self._consume(request_id) + + def _register( + self, + state: _PendingPlanningState, + *, + validation_error: str | None, + ) -> PendingPlanRequest: + request_id = self.id_factory() + if not request_id or request_id in self._used_request_ids: + raise PlanningRequestError("Planning request IDs must be non-empty and unique") + self._used_request_ids.add(request_id) + self._pending[request_id] = state + return PendingPlanRequest( + request_id=request_id, + request=_request( + message=state.message, + context=state.context, + validation_error=validation_error, + ), + attempt=state.attempt, + ) + + def _consume(self, request_id: str) -> _PendingPlanningState: + try: + return self._pending.pop(request_id) + except KeyError as error: + raise PlanningRequestError(f"Unknown or completed planning request: {request_id}") from error + + def build_conversation_request(*, message: str, context: PlannerContext) -> LLMRequest: return _request(message=message, context=context, validation_error=None) @@ -63,51 +176,22 @@ def build_conversation_request(*, message: str, context: PlannerContext) -> LLMR class ConversationPlanner: def __init__(self, provider: LLMProvider | None, *, repair_attempts: int = 1) -> None: self.provider = provider - self.repair_attempts = repair_attempts + self.resumable = ResumableConversationPlanner(repair_attempts=repair_attempts) def plan(self, message: str, context: PlannerContext) -> ConversationPlan: if self.provider is None: return self._offline_plan(message, context) - request = build_conversation_request(message=message, context=context) - try: - response = self.provider.complete(request) - except Exception as error: - return self._provider_failure(message, error, phase="initial plan request") - try: - return parse_conversation_plan(response.content) - except Exception as error: - return self._repair(message, context, error) - - def _repair( - self, - message: str, - context: PlannerContext, - error: Exception, - ) -> ConversationPlan: - if self.provider is None: - raise RuntimeError("Conversation plan repair requires an LLM provider") - validation_error = str(error) - for _ in range(self.repair_attempts): - try: - response = self.provider.complete( - _request( - message=message, - context=context, - validation_error=validation_error, - ) - ) - except Exception as provider_error: - return self._provider_failure(message, provider_error, phase="plan repair request") + outcome = self.resumable.begin(message, context) + while isinstance(outcome, PendingPlanRequest): try: - return parse_conversation_plan(response.content) - except Exception as repair_error: - validation_error = str(repair_error) - return UnsupportedPlan( - request=message, - reason=f"The configured provider did not return a valid typed plan: {validation_error}", - ) + response = self.provider.complete(outcome.request) + except Exception as error: + return self.resumable.fail(outcome.request_id, error) + outcome = self.resumable.resume(outcome.request_id, response.content) + return outcome - def _offline_plan(self, message: str, context: PlannerContext) -> ConversationPlan: + @staticmethod + def _offline_plan(message: str, context: PlannerContext) -> ConversationPlan: stripped = message.strip() if stripped.startswith("/"): command, _, arguments = stripped.partition(" ") @@ -124,26 +208,35 @@ def _offline_plan(self, message: str, context: PlannerContext) -> ConversationPl question="What workspace question should I answer?", reason="/ask requires a deterministic workspace question.", ) - return self._offline_plan(arguments, context) + return ConversationPlanner._offline_plan(arguments, context) if command in {"/update", "/create", "/change"}: - return self._provider_required(message) + return ConversationPlanner._provider_required(message) if command == "/compile": return parse_compile_command(message) if command in {"/sync", "/publish"}: - return self._operational_unsupported(message) + return ConversationPlanner._operational_unsupported(message) return UnsupportedPlan( request=message, reason=f"Slash command {command} is not a conversational planning command.", ) lower = stripped.lower() + if re.search(r"\b(?:create|suggest|propose|draft)\b.*\bprojection\b", lower) and ( + lower == "create a projection" or "not chosen" in lower or "unspecified" in lower + ): + return ClarificationPlan( + question="Which source model and consumer domain should I use?", + reason="A projection requires a grounded source model and consumer domain.", + ) if any(operation in lower for operation in ("compile", "sync", "publish", "deploy", "external")): - return self._operational_unsupported(message) + return ConversationPlanner._operational_unsupported(message) if re.search( r"\b(?:add|create|change|rename|remove|delete|set|update|replace|make)\b", lower, ): - return self._provider_required(message) + return ConversationPlanner._provider_required(message) + if re.search(r"\b(?:suggest|propose|draft)\b.*\bprojection\b", lower): + return ConversationPlanner._provider_required(message) refs = _extract_refs(stripped) focused_refs = refs or ([context.focused_ref] if context.focused_ref else []) @@ -184,15 +277,13 @@ def _offline_plan(self, message: str, context: PlannerContext) -> ConversationPl refs=focused_refs, question=message, ) - return self._provider_required(message) + return ConversationPlanner._provider_required(message) @staticmethod def _provider_required(message: str) -> UnsupportedPlan: return UnsupportedPlan( request=message, - reason=( - "This request requires intent synthesis. Configure an LLM provider for conversational change planning." - ), + reason=_PROVIDER_REQUIRED_REASON, ) @staticmethod @@ -211,6 +302,26 @@ def _operational_unsupported(message: str) -> UnsupportedPlan: ) +_PROVIDER_REQUIRED_REASON = ( + "This request requires intent synthesis. Configure an LLM provider for conversational change planning." +) + + +def _requires_provider(message: str, plan: ConversationPlan) -> bool: + if isinstance(plan, UnsupportedPlan) and plan.reason == _PROVIDER_REQUIRED_REASON: + return True + normalized = message.strip().lower() + command, _, arguments = normalized.partition(" ") + if command == "/ask" and arguments.strip(): + return True + return ( + isinstance(plan, UnsupportedPlan) + and not normalized.startswith("/") + and "compile" in normalized + and not any(operation in normalized for operation in ("sync", "publish", "deploy", "external")) + ) + + def parse_compile_command(message: str) -> CompilePlan | ClarificationPlan: syntax = "/compile [--domain ...] [--out ] [--descriptor-set]" @@ -308,3 +419,14 @@ def _request( def _extract_refs(message: str) -> list[str]: return _REF_RE.findall(message) + + +def _validate_plan_intent(message: str, plan: ConversationPlan) -> None: + if isinstance(plan, CompilePlan) and re.search( + r"\b(?:add|create|change|rename|remove|delete|set|update|replace|make|suggest|propose|draft)\b", + message, + flags=re.IGNORECASE, + ): + raise ValueError( + "A source mutation request requires a change_set or clarification plan; compile only generates artifacts" + ) diff --git a/cli/src/modelable/llm/filesystem_conversation.py b/cli/src/modelable/llm/filesystem_conversation.py new file mode 100644 index 00000000..6e75cb4f --- /dev/null +++ b/cli/src/modelable/llm/filesystem_conversation.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal, TypeIs + +from modelable.compiler.workspace import load_workspace +from modelable.llm.context import build_workspace_summary +from modelable.llm.conversation_backend import ( + ConversationCleanupError, + ConversationPreviewFile, + ConversationReply, +) +from modelable.llm.conversation_plan import ChangeSetPlan, CompilePlan, QueryPlan +from modelable.llm.workspace_editor import ( + PendingChangeSet, + WorkspaceEditError, + WorkspaceEditor, +) +from modelable.llm.workspace_query import WorkspaceQueryService +from modelable.operations.compilation import ( + CompilationService, + PendingCompilation, +) + +type PendingAction = PendingChangeSet | PendingCompilation + + +class FilesystemConversationBackend: + def __init__( + self, + *, + path: Path, + compilation_service: CompilationService | None = None, + session_id: str, + provider_name: str | None = None, + model_name: str | None = None, + confirmation_surface: Literal["cli-chat", "vscode-chat"] = "cli-chat", + ) -> None: + self.path = path + self.compilation_service = compilation_service or CompilationService() + self.session_id = session_id + self.provider_name = provider_name + self.model_name = model_name + self.confirmation_surface = confirmation_surface + self.workspace = load_workspace(path) + self._pending: PendingAction | None = None + self._cleanup_backlog: dict[str, PendingCompilation] = {} + self.editor: WorkspaceEditor | None = None + self._reload_services() + + @property + def pending(self) -> PendingAction | None: + return self._pending + + @property + def pending_action_id(self) -> str | None: + return _pending_id(self._pending) + + @property + def pending_operation_kind(self) -> Literal["source_change", "compile"] | None: + if _is_pending_compilation(self._pending): + return "compile" + if isinstance(self._pending, PendingChangeSet): + return "source_change" + return None + + @property + def pending_cleanup_ids(self) -> tuple[str, ...]: + return tuple(sorted(self._cleanup_backlog)) + + @property + def cleanup_action_id(self) -> str | None: + cleanup_ids = sorted(self._cleanup_backlog) + return cleanup_ids[0] if cleanup_ids else None + + def workspace_summary(self) -> str: + return build_workspace_summary(self.workspace) + + def execute_query(self, plan: QueryPlan) -> ConversationReply: + from modelable.llm.conversation import render_query_result + + return ConversationReply( + kind="answer", + text=render_query_result(self.query_service.execute(plan)), + ) + + def preview_source_change( + self, + plan: ChangeSetPlan, + replaced_action_id: str | None, + ) -> ConversationReply: + from modelable.llm.conversation import ( + _render_cleanup_failure, + render_pending_change_set, + ) + + replaced = self._pending + actual_replaced_id = _pending_id(replaced) + if replaced_action_id != actual_replaced_id: + raise ValueError( + f"Replaced action ID {replaced_action_id!r} does not match backend action {actual_replaced_id!r}" + ) + try: + if self.editor is None: + self.editor = WorkspaceEditor(self.path, workspace=self.workspace) + pending = self.editor.preview(plan) + except WorkspaceEditError as error: + return ConversationReply(kind="error", text=f"Could not preview workspace changes: {error}") + cleanup_errors = self._dispose_actions((replaced,)) + if cleanup_errors: + return ConversationReply( + kind="error", + text=_render_cleanup_failure("Could not replace the pending action.", cleanup_errors), + change_set_id=actual_replaced_id, + ) + self._pending = pending + replacement = ( + f"Replaced pending change set {actual_replaced_id} with {pending.change_set_id}.\n\n" + if actual_replaced_id is not None + else "" + ) + current_sources = {source.path: source.text for source in self.workspace.sources if source.path is not None} + preview_files = tuple( + ConversationPreviewFile( + path=path, + existed_before=path in current_sources, + before_text=current_sources.get(path, ""), + after_text=after_text, + ) + for path, after_text in sorted(pending.candidate_sources.items()) + ) + return ConversationReply( + kind="preview", + text=replacement + render_pending_change_set(pending), + change_set_id=pending.change_set_id, + operation_kind="source_change", + focused_ref=pending.focus_ref, + changed=tuple(pending.changed), + affected=tuple(pending.affected), + compatibility=tuple(pending.compatibility), + diagnostics=tuple(pending.diagnostics), + preview_files=preview_files, + ) + + def preview_compilation( + self, + plan: CompilePlan, + replaced_action_id: str | None, + ) -> ConversationReply: + from modelable.llm.conversation import ( + _escape_inline, + _render_cleanup_failure, + render_pending_compilation, + ) + from modelable.operations.compilation import ( + CompilationError, + CompilationPolicy, + CompilationRequest, + ) + + replaced = self._pending + actual_replaced_id = _pending_id(replaced) + if replaced_action_id != actual_replaced_id: + raise ValueError( + f"Replaced action ID {replaced_action_id!r} does not match backend action {actual_replaced_id!r}" + ) + try: + pending = self.compilation_service.preview( + CompilationRequest( + source=self.path, + target=plan.target, + out_dir=Path(plan.output) if plan.output is not None else None, + domains=tuple(plan.domains), + descriptor_set=plan.descriptor_set, + ), + policy=CompilationPolicy.conversation(), + ) + except CompilationError as error: + return ConversationReply( + kind="error", + text=f"Could not preview compilation: {_escape_inline(error)}", + ) + cleanup_errors = self._dispose_actions((replaced,)) + if cleanup_errors: + cleanup_errors += self._dispose_actions((pending,)) + self._pending = None + return ConversationReply( + kind="error", + text=_render_cleanup_failure( + "Could not replace the pending action; all staged actions remain tracked for cleanup.", + cleanup_errors, + ), + operation_kind="compile", + ) + self._pending = pending + replacement = ( + f"Replaced pending action {actual_replaced_id} with compilation {pending.action_id}.\n\n" + if actual_replaced_id is not None + else "" + ) + return ConversationReply( + kind="preview", + text=replacement + render_pending_compilation(pending, plan), + change_set_id=pending.action_id, + operation_kind="compile", + affected=pending.affected_definitions, + compilation_files=pending.files, + registry_id_changes=pending.registry_id_changes, + audit_path=pending.audit_path, + ) + + def apply(self, action_id: str) -> ConversationReply: + if action_id != _pending_id(self._pending): + return ConversationReply( + kind="error", + text=f"Pending action does not match {action_id}.", + change_set_id=action_id, + ) + if _is_pending_compilation(self._pending): + return self._apply_pending_compilation(self._pending) + if not isinstance(self._pending, PendingChangeSet): + return ConversationReply(kind="error", text="There is no pending action to apply.") + if self.editor is None: + return ConversationReply( + kind="error", + text=f"Could not apply change set {self._pending.change_set_id}: the preview editor is unavailable.", + change_set_id=self._pending.change_set_id, + ) + try: + applied = self.editor.apply(self._pending) + except WorkspaceEditError as error: + return ConversationReply( + kind="error", + text=f"Could not apply change set {self._pending.change_set_id}: {error}", + change_set_id=self._pending.change_set_id, + ) + from modelable.llm.conversation import render_applied_change_set + + self.workspace = applied.workspace + self._pending = None + self._reload_services() + return ConversationReply( + kind="applied", + text=render_applied_change_set(applied), + change_set_id=applied.change_set_id, + operation_kind="source_change", + focused_ref=applied.focus_ref, + changed=tuple(applied.changed), + compatibility=tuple(applied.compatibility), + written_paths=applied.written_paths, + ) + + def discard(self, action_id: str) -> ConversationReply: + from modelable.llm.conversation import _render_cleanup_failure + + known_ids = {_pending_id(self._pending), *self._cleanup_backlog} + if action_id not in known_ids: + return ConversationReply( + kind="error", + text=f"Pending action does not match {action_id}.", + change_set_id=action_id, + ) + cleanup_only = self._pending is None + cleanup_ids = tuple(sorted(self._cleanup_backlog)) + operation_kind: Literal["source_change", "compile"] = ( + "compile" if cleanup_only or _is_pending_compilation(self._pending) else "source_change" + ) + cleanup_errors = self._dispose_actions((self._pending, *self._cleanup_backlog.values())) + if cleanup_errors: + return ConversationReply( + kind="error", + text=_render_cleanup_failure( + ( + "Could not fully discard staged compilation cleanup; cleanup will be retried." + if cleanup_only + else "Could not fully discard the pending action; cleanup will be retried." + ), + cleanup_errors, + ), + change_set_id=action_id, + operation_kind=operation_kind, + ) + self._pending = None + return ConversationReply( + kind="discarded", + text=( + f"Discarded staged compilation cleanup {', '.join(cleanup_ids)}." + if cleanup_only + else f"Discarded pending action {action_id}." + ), + change_set_id=action_id, + operation_kind=operation_kind, + ) + + def reset(self) -> None: + self.close() + self.workspace = load_workspace(self.path) + self._reload_services() + + def close(self) -> None: + cleanup_errors = self._dispose_actions((self._pending, *self._cleanup_backlog.values())) + if cleanup_errors: + raise ConversationCleanupError(cleanup_errors) + self._pending = None + + def cleanup_after_exception(self, error: BaseException) -> None: + try: + self.close() + except Exception as cleanup_error: + error.add_note(str(cleanup_error)) + + def _apply_pending_compilation(self, pending: PendingCompilation) -> ConversationReply: + from modelable.llm.conversation import ( + _escape_inline, + render_applied_compilation, + render_committed_compilation_cleanup_error, + ) + from modelable.operations.compilation import CompilationConfirmation + from modelable.operations.file_transaction import FileTransactionCommittedError + + confirmation = CompilationConfirmation( + session_id=self.session_id, + action_id=pending.action_id, + manifest_fingerprint=pending.manifest_fingerprint, + surface=self.confirmation_surface, + provider=self.provider_name, + model=self.model_name, + ) + try: + applied = self.compilation_service.apply(pending, confirmation=confirmation) + except FileTransactionCommittedError as error: + self._cleanup_backlog.pop(pending.action_id, None) + self._pending = None + audit_path = pending.audit_path + return ConversationReply( + kind="applied", + text=render_committed_compilation_cleanup_error(pending, error, audit_path), + change_set_id=pending.action_id, + operation_kind="compile", + affected=pending.affected_definitions, + written_paths=error.written_paths, + compilation_files=pending.files, + registry_id_changes=pending.registry_id_changes, + audit_path=audit_path, + ) + except Exception as error: + if not pending.staging_dir.exists(): + self._cleanup_backlog.pop(pending.action_id, None) + self._pending = None + else: + self._cleanup_backlog[pending.action_id] = pending + return ConversationReply( + kind="error", + text=f"Could not apply compilation {_escape_inline(pending.action_id)}: {_escape_inline(error)}", + change_set_id=pending.action_id, + operation_kind="compile", + ) + self._cleanup_backlog.pop(pending.action_id, None) + self._pending = None + return ConversationReply( + kind="applied", + text=render_applied_compilation(applied), + change_set_id=applied.action_id, + operation_kind="compile", + affected=applied.affected_definitions, + written_paths=applied.written_paths, + compilation_files=applied.files, + registry_id_changes=pending.registry_id_changes, + audit_path=applied.audit_path, + ) + + def _dispose_actions(self, actions: tuple[PendingAction | None, ...]) -> tuple[str, ...]: + errors: list[str] = [] + seen: set[str] = set() + for action in actions: + if not _is_pending_compilation(action) or action.action_id in seen: + continue + seen.add(action.action_id) + try: + self.compilation_service.discard(action) + except Exception as error: + self._cleanup_backlog[action.action_id] = action + errors.append(f"{action.action_id}: {error}") + else: + self._cleanup_backlog.pop(action.action_id, None) + return tuple(errors) + + def _reload_services(self) -> None: + self.query_service = WorkspaceQueryService(self.workspace) + self.editor = None + + +def _pending_id(pending: PendingAction | None) -> str | None: + if isinstance(pending, PendingChangeSet): + return pending.change_set_id + if _is_pending_compilation(pending): + return pending.action_id + return None + + +def _is_pending_compilation(pending: object) -> TypeIs[PendingCompilation]: + return isinstance(pending, PendingCompilation) diff --git a/cli/src/modelable/llm/provider_types.py b/cli/src/modelable/llm/provider_types.py new file mode 100644 index 00000000..629c0cab --- /dev/null +++ b/cli/src/modelable/llm/provider_types.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class LLMRequest: + system: str + user: str + temperature: float = 0.2 + response_format: str = "text" + schema: dict[str, object] | None = None + + +@dataclass(frozen=True) +class LLMResponse: + content: str + provider: str + model: str + prompt_tokens: int | None = None + completion_tokens: int | None = None + + +class LLMProvider(Protocol): + def complete(self, request: LLMRequest) -> LLMResponse: ... diff --git a/cli/src/modelable/llm/providers.py b/cli/src/modelable/llm/providers.py index b8e3b92c..e9a9ab43 100644 --- a/cli/src/modelable/llm/providers.py +++ b/cli/src/modelable/llm/providers.py @@ -3,31 +3,12 @@ import json from dataclasses import dataclass from os import environ -from typing import Any, Protocol, SupportsIndex, cast +from typing import Any, SupportsIndex, cast from urllib import error, request - -@dataclass(frozen=True) -class LLMRequest: - system: str - user: str - temperature: float = 0.2 - response_format: str = "text" - schema: dict[str, object] | None = None - - -@dataclass(frozen=True) -class LLMResponse: - content: str - provider: str - model: str - prompt_tokens: int | None = None - completion_tokens: int | None = None - - -class LLMProvider(Protocol): - def complete(self, request: LLMRequest) -> LLMResponse: - raise NotImplementedError +from modelable.llm.provider_types import LLMProvider as LLMProvider +from modelable.llm.provider_types import LLMRequest as LLMRequest +from modelable.llm.provider_types import LLMResponse as LLMResponse @dataclass(frozen=True) @@ -46,7 +27,9 @@ def complete(self, request: LLMRequest) -> LLMResponse: "stream": False, "options": {"temperature": request.temperature}, } - if request.response_format == "json" or request.schema is not None: + if request.schema is not None: + payload["format"] = request.schema + elif request.response_format == "json": payload["format"] = "json" response = self._post_json("/api/chat", payload) diff --git a/cli/src/modelable/llm/update_plan.py b/cli/src/modelable/llm/update_plan.py index 19f93d0b..e4e2220d 100644 --- a/cli/src/modelable/llm/update_plan.py +++ b/cli/src/modelable/llm/update_plan.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field -from modelable.llm.providers import LLMRequest +from modelable.llm.provider_types import LLMRequest class UpdateChange(BaseModel): diff --git a/cli/tests/support/__init__.py b/cli/tests/support/__init__.py new file mode 100644 index 00000000..a46f0e42 --- /dev/null +++ b/cli/tests/support/__init__.py @@ -0,0 +1 @@ +"""Shared deterministic conversation test support.""" diff --git a/cli/tests/support/conversation_simulator.py b/cli/tests/support/conversation_simulator.py new file mode 100644 index 00000000..5ca36134 --- /dev/null +++ b/cli/tests/support/conversation_simulator.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +import re + +from modelable.llm.provider_types import LLMRequest, LLMResponse + + +class ConversationSimulatorProvider: + def __init__( + self, + *, + malformed_first: bool = False, + failure: str | None = None, + ) -> None: + self.malformed_first = malformed_first + self.failure = failure + self.requests: list[LLMRequest] = [] + + def complete(self, request: LLMRequest) -> LLMResponse: + self.requests.append(request) + if self.failure is not None: + raise RuntimeError(self.failure) + if ( + self.malformed_first + and len(self.requests) == 1 + and "Previous response validation error" not in request.user + ): + content = "{malformed" + else: + content = json.dumps(_plan_for(_user_request(request.user))) + return LLMResponse( + content=content, + provider="simulator", + model="semantic-v1", + ) + + +def _user_request(prompt: str) -> str: + match = re.search( + r"User request:\n(?P.*?)(?:\n\nPrevious response validation error:|\Z)", + prompt, + flags=re.DOTALL, + ) + return match.group("message").strip() if match is not None else prompt.strip() + + +def _plan_for(message: str) -> dict[str, object]: + normalized = message.lower() + if "describe the workspace" in normalized: + return { + "kind": "query", + "query_kind": "summary", + "refs": [], + "question": message, + } + if "create an invoice" in normalized: + return { + "kind": "change_set", + "summary": "Create billing.Invoice@1", + "operations": [ + { + "kind": "create_model", + "domain": "billing", + "name": "Invoice", + "model_kind": "entity", + "fields": [ + { + "name": "invoiceId", + "type": {"kind": "uuid"}, + "annotations": [{"kind": "key"}], + } + ], + } + ], + } + if "suggest a projection" in normalized: + return { + "kind": "change_set", + "summary": "Create billing.CustomerProjection@1", + "operations": [ + { + "kind": "create_projection", + "domain": "billing", + "name": "CustomerProjection", + "source": { + "model": "customer.Customer", + "version": 1, + "alias": "customer", + }, + "fields": [ + { + "name": "customerId", + "mapping": { + "kind": "direct", + "source_alias": "customer", + "source_field": "customerId", + }, + } + ], + } + ], + } + if "create a projection" in normalized: + return { + "kind": "clarification", + "question": "Which source model and consumer domain should I use?", + "reason": "A projection requires a grounded source and consumer.", + } + if "add email" in normalized: + return { + "kind": "change_set", + "summary": "Add email to customer.Customer@2", + "operations": [ + { + "kind": "append_model_version", + "source": "customer.Customer@1", + "version": 2, + }, + { + "kind": "add_field", + "target": "customer.Customer@2", + "field": { + "name": "email", + "type": {"kind": "string"}, + }, + }, + ], + } + if "/compile json-schema" in normalized: + return { + "kind": "compile", + "target": "json-schema", + "domains": [], + "output": None, + "descriptor_set": False, + "summary": "Compile the workspace to JSON Schema.", + } + return { + "kind": "unsupported", + "request": message, + "reason": "The semantic simulator has no fixture for this request.", + } diff --git a/cli/tests/test_browser_ai.py b/cli/tests/test_browser_ai.py deleted file mode 100644 index 435a42b8..00000000 --- a/cli/tests/test_browser_ai.py +++ /dev/null @@ -1,263 +0,0 @@ -import json - -import pytest - -import modelable.browser.dispatch as browser_dispatch -from modelable.browser import ( - BrowserCompiler, - BrowserSource, - dispatch_browser_request, -) -from modelable.browser.ai import ( - build_explain_request, - build_generate_entity_request, - parse_explain_result, - parse_generate_result, -) -from modelable.browser.dto import BrowserAiPendingResult - -URI = "file:///customer.mdl" -SOURCE_TEXT = ( - "domain customer {\n" - ' owner: "team"\n' - " entity Customer @ 1 (additive) {\n" - " @key customer_id: uuid\n" - " customer_name: string\n" - " }\n" - "}\n" -) - - -@pytest.fixture(autouse=True) -def reset_browser_dispatch() -> None: - browser_dispatch._reset_compiler_for_tests() - - -def dispatch(method: str, payload: object) -> dict: - return json.loads(dispatch_browser_request(method, json.dumps(payload))) - - -def _open_workspace(compiler: BrowserCompiler) -> int: - result = compiler.open_workspace( - 1, - (BrowserSource(uri=URI, text=SOURCE_TEXT, version=1),), - ) - return result.workspace_revision - - -def test_build_generate_entity_request_returns_pending(): - compiler = BrowserCompiler() - _open_workspace(compiler) - - result = build_generate_entity_request( - compiler.language, - description="A product catalog item", - domain_name="catalog", - model_name="Product", - ) - - assert isinstance(result, BrowserAiPendingResult) - assert result.llm_request.temperature == 0.2 - assert result.llm_request.response_format == "text" - assert "Product" in result.llm_request.user - assert "catalog" in result.llm_request.user - - -def test_build_explain_request_returns_pending(): - compiler = BrowserCompiler() - _open_workspace(compiler) - - result = build_explain_request( - compiler.language, - ref="customer.Customer@1", - diagnostic_index=None, - ) - - assert isinstance(result, BrowserAiPendingResult) - assert "customer.Customer@1" in result.llm_request.user - - -def test_build_explain_request_with_diagnostic_index(): - compiler = BrowserCompiler() - _open_workspace(compiler) - - result = build_explain_request( - compiler.language, - ref=None, - diagnostic_index=0, - ) - - assert isinstance(result, BrowserAiPendingResult) - assert "workspace" in result.llm_request.user.lower() - - -def test_parse_generate_result_valid_source(): - source = ( - "domain catalog {\n" - ' owner: "team"\n' - " entity Product @ 1 (additive) {\n" - " @key product_id: uuid\n" - " name: string\n" - " }\n" - "}\n" - ) - result = parse_generate_result(source) - assert result.source == source - assert len([d for d in result.diagnostics if d.severity == "error"]) == 0 - - -def test_parse_generate_result_strips_code_fences(): - source = '```mdl\ndomain x { owner: "t" entity Y @ 1 (additive) { @key y_id: uuid } }\n```' - result = parse_generate_result(source) - assert not result.source.startswith("```") - assert not result.source.endswith("```") - - -def test_parse_generate_result_invalid_source(): - result = parse_generate_result("this is not valid mdl") - assert len(result.diagnostics) > 0 - assert any(d.severity == "error" for d in result.diagnostics) - - -def test_parse_explain_result(): - result = parse_explain_result(" This model represents a customer. ") - assert result.explanation == "This model represents a customer." - - -def test_dispatch_ai_generate_phase_one(): - result = dispatch( - "workspace.open", - { - "workspaceRevision": 1, - "sources": [{"uri": URI, "text": SOURCE_TEXT, "version": 1}], - }, - ) - assert result["ok"] - - result = dispatch( - "ai.generate", - { - "workspaceRevision": 1, - "action": "generate_entity", - "parameters": {"description": "A product", "domainName": "catalog"}, - }, - ) - assert result["ok"] - assert result["result"]["status"] == "pending_llm" - assert "system" in result["result"]["llm_request"] - assert "user" in result["result"]["llm_request"] - - -def test_dispatch_ai_generate_phase_two(): - dispatch( - "workspace.open", - { - "workspaceRevision": 1, - "sources": [{"uri": URI, "text": SOURCE_TEXT, "version": 1}], - }, - ) - - generated_source = ( - "domain catalog {\n" - ' owner: "team"\n' - " entity Product @ 1 (additive) {\n" - " @key product_id: uuid\n" - " name: string\n" - " }\n" - "}\n" - ) - result = dispatch( - "ai.generate", - { - "workspaceRevision": 1, - "action": "generate_entity", - "parameters": {"description": "A product"}, - "llmResponseContent": generated_source, - }, - ) - assert result["ok"] - assert "source" in result["result"] - assert "diagnostics" in result["result"] - - -def test_dispatch_ai_explain_phase_one(): - dispatch( - "workspace.open", - { - "workspaceRevision": 1, - "sources": [{"uri": URI, "text": SOURCE_TEXT, "version": 1}], - }, - ) - - result = dispatch( - "ai.explain", - { - "workspaceRevision": 1, - "parameters": {"ref": "customer.Customer@1"}, - }, - ) - assert result["ok"] - assert result["result"]["status"] == "pending_llm" - - -def test_dispatch_ai_explain_phase_two(): - dispatch( - "workspace.open", - { - "workspaceRevision": 1, - "sources": [{"uri": URI, "text": SOURCE_TEXT, "version": 1}], - }, - ) - - result = dispatch( - "ai.explain", - { - "workspaceRevision": 1, - "parameters": {}, - "llmResponseContent": "This workspace defines a customer domain.", - }, - ) - assert result["ok"] - assert result["result"]["explanation"] == "This workspace defines a customer domain." - - -def test_dispatch_ai_generate_invalid_action(): - dispatch( - "workspace.open", - { - "workspaceRevision": 1, - "sources": [{"uri": URI, "text": SOURCE_TEXT, "version": 1}], - }, - ) - - result = dispatch( - "ai.generate", - { - "workspaceRevision": 1, - "action": "invalid_action", - "parameters": {}, - }, - ) - assert not result["ok"] - assert result["error"]["code"] == "INVALID_REQUEST" - - -def test_dispatch_ai_generate_stale_workspace(): - dispatch( - "workspace.open", - { - "workspaceRevision": 1, - "sources": [{"uri": URI, "text": SOURCE_TEXT, "version": 1}], - }, - ) - - result = dispatch( - "ai.generate", - { - "workspaceRevision": 99, - "action": "generate_entity", - "parameters": {}, - }, - ) - assert not result["ok"] - assert result["error"]["code"] == "STALE_WORKSPACE" diff --git a/cli/tests/test_browser_api.py b/cli/tests/test_browser_api.py index faf76d2e..586d1089 100644 --- a/cli/tests/test_browser_api.py +++ b/cli/tests/test_browser_api.py @@ -34,12 +34,16 @@ def dispatch(method: str, payload: object) -> dict: def test_open_workspace_returns_hashes_and_no_diagnostics(): - result = BrowserCompiler().open_workspace( + compiler = BrowserCompiler() + sources = (BrowserSource(uri="inmemory:///customer.mdl", text=VALID, version=1),) + + result = compiler.open_workspace( 1, - (BrowserSource(uri="inmemory:///customer.mdl", text=VALID, version=1),), + sources, ) assert result.workspace_revision == 1 + assert compiler.sources == sources assert result.diagnostics == () assert set(result.source_hashes) == {"inmemory:///customer.mdl"} assert len(result.source_hashes["inmemory:///customer.mdl"]) == 64 diff --git a/cli/tests/test_browser_conversation.py b/cli/tests/test_browser_conversation.py new file mode 100644 index 00000000..24eda046 --- /dev/null +++ b/cli/tests/test_browser_conversation.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from modelable.browser.api import BrowserCompiler +from modelable.browser.conversation import BrowserConversationService, _logical_path +from modelable.browser.dto import BrowserSource +from modelable.llm.conversation_planner import PendingPlanRequest + +CUSTOMER_URI = "inmemory:///customer.mdl" +BILLING_URI = "inmemory:///billing.mdl" +CUSTOMER_SOURCE = """\ +domain customer { + owner: "customer-team" + entity Customer @ 1 (additive) { + @key customerId: uuid + name: string + } +} +""" +BILLING_SOURCE = """\ +domain billing { + owner: "billing-team" +} +""" + + +def valid_projection_plan() -> dict[str, object]: + return { + "kind": "change_set", + "summary": "Create billing.CustomerProjection@1", + "operations": [ + { + "kind": "create_projection", + "domain": "billing", + "name": "CustomerProjection", + "source": { + "model": "customer.Customer", + "version": 1, + "alias": "customer", + }, + "fields": [ + { + "name": "customerId", + "mapping": { + "kind": "direct", + "source_alias": "customer", + "source_field": "customerId", + }, + } + ], + } + ], + } + + +def test_browser_conversation_previews_and_applies_projection() -> None: + compiler = BrowserCompiler() + compiler.open_workspace( + 1, + ( + BrowserSource(uri=CUSTOMER_URI, text=CUSTOMER_SOURCE, version=1), + BrowserSource(uri=BILLING_URI, text=BILLING_SOURCE, version=1), + ), + ) + service = BrowserConversationService(compiler, id_factory=lambda: "request-1") + + pending = service.turn( + session_id="session-1", + workspace_revision=1, + message="Suggest a projection for billing", + active_document_uri=CUSTOMER_URI, + line=3, + character=10, + ) + assert isinstance(pending, PendingPlanRequest) + preview = service.resume( + session_id="session-1", + request_id=pending.request_id, + workspace_revision=1, + content=json.dumps(valid_projection_plan()), + ) + applied = service.apply( + session_id="session-1", + action_id=preview.reply.change_set_id or "", + workspace_revision=1, + ) + + assert preview.reply.kind == "preview" + assert "projection" in preview.reply.preview_files[0].after_text + assert applied.workspace_revision == 2 + assert "projection CustomerProjection" in compiler.sources[1].text + + +def test_browser_conversation_preserves_nested_workspace_paths() -> None: + assert _logical_path("file:///domains/customer.mdl") == Path("domains/customer.mdl") + assert _logical_path("file:///consumers/customer.mdl") == Path("consumers/customer.mdl") + + +def test_browser_conversation_rejects_unsafe_workspace_paths() -> None: + with pytest.raises(ValueError, match="safe relative document path"): + _logical_path("file:///domains/../customer.mdl") + + +def test_browser_conversation_previews_and_promotes_compilation() -> None: + compiler = BrowserCompiler() + compiler.open_workspace( + 1, + (BrowserSource(uri=CUSTOMER_URI, text=CUSTOMER_SOURCE, version=1),), + ) + service = BrowserConversationService(compiler, id_factory=lambda: "request-1") + + pending = service.turn( + session_id="session-1", + workspace_revision=1, + message="Compile this workspace to JSON Schema", + ) + assert isinstance(pending, PendingPlanRequest) + preview = service.resume( + session_id="session-1", + request_id=pending.request_id, + workspace_revision=1, + content=json.dumps( + { + "kind": "compile", + "target": "json-schema", + "domains": [], + "output": None, + "descriptor_set": False, + "summary": "Compile to JSON Schema", + } + ), + ) + applied = service.apply( + session_id="session-1", + action_id=preview.reply.change_set_id or "", + workspace_revision=1, + ) + + assert preview.reply.kind == "preview" + assert preview.reply.compilation_files + assert preview.reply.compilation_files[0].after_text is not None + assert applied.reply.kind == "applied" + assert applied.reply.compilation_files == preview.reply.compilation_files + + +def test_browser_conversation_invalidates_preview_after_external_workspace_change() -> None: + compiler = BrowserCompiler() + compiler.open_workspace( + 1, + ( + BrowserSource(uri=CUSTOMER_URI, text=CUSTOMER_SOURCE, version=1), + BrowserSource(uri=BILLING_URI, text=BILLING_SOURCE, version=1), + ), + ) + service = BrowserConversationService(compiler, id_factory=lambda: "request-1") + pending = service.turn( + session_id="session-1", + workspace_revision=1, + message="Suggest a projection for billing", + active_document_uri=CUSTOMER_URI, + line=3, + character=10, + ) + assert isinstance(pending, PendingPlanRequest) + preview = service.resume( + session_id="session-1", + request_id=pending.request_id, + workspace_revision=1, + content=json.dumps(valid_projection_plan()), + ) + externally_changed = BILLING_SOURCE.replace("billing-team", "new-team") + compiler.open_workspace( + 2, + ( + BrowserSource(uri=CUSTOMER_URI, text=CUSTOMER_SOURCE, version=1), + BrowserSource(uri=BILLING_URI, text=externally_changed, version=2), + ), + ) + + applied = service.apply( + session_id="session-1", + action_id=preview.reply.change_set_id or "", + workspace_revision=2, + ) + + assert applied.reply.kind == "error" + assert compiler.sources[1].text == externally_changed + + +def test_browser_conversation_rejects_compile_options_it_cannot_honor() -> None: + compiler = BrowserCompiler() + compiler.open_workspace( + 1, + (BrowserSource(uri=CUSTOMER_URI, text=CUSTOMER_SOURCE, version=1),), + ) + service = BrowserConversationService(compiler, id_factory=lambda: "request-1") + pending = service.turn( + session_id="session-1", + workspace_revision=1, + message="Compile only customer to JSON Schema", + ) + assert isinstance(pending, PendingPlanRequest) + + result = service.resume( + session_id="session-1", + request_id=pending.request_id, + workspace_revision=1, + content=json.dumps( + { + "kind": "compile", + "target": "json-schema", + "domains": ["customer"], + "output": None, + "descriptor_set": False, + "summary": "Compile customer to JSON Schema", + } + ), + ) + + assert result.reply.kind == "error" + assert "does not support domain filters" in result.reply.text + + +def test_browser_conversation_provider_failure_preserves_prior_preview() -> None: + compiler = BrowserCompiler() + compiler.open_workspace( + 1, + ( + BrowserSource(uri=CUSTOMER_URI, text=CUSTOMER_SOURCE, version=1), + BrowserSource(uri=BILLING_URI, text=BILLING_SOURCE, version=1), + ), + ) + request_ids = iter(("request-1", "request-2")) + service = BrowserConversationService(compiler, id_factory=lambda: next(request_ids)) + initial = service.turn( + session_id="session-1", + workspace_revision=1, + message="Suggest a projection for billing", + active_document_uri=CUSTOMER_URI, + line=3, + character=10, + ) + assert isinstance(initial, PendingPlanRequest) + preview = service.resume( + session_id="session-1", + request_id=initial.request_id, + workspace_revision=1, + content=json.dumps(valid_projection_plan()), + ) + refinement = service.turn( + session_id="session-1", + workspace_revision=1, + message="Add another field to the projection", + ) + assert isinstance(refinement, PendingPlanRequest) + + failed = service.fail( + session_id="session-1", + request_id=refinement.request_id, + workspace_revision=1, + error="provider unavailable", + ) + applied = service.apply( + session_id="session-1", + action_id=preview.reply.change_set_id or "", + workspace_revision=1, + ) + + assert failed.reply.kind == "unsupported" + assert applied.reply.kind == "applied" diff --git a/cli/tests/test_browser_packaging.py b/cli/tests/test_browser_packaging.py index 6b13af09..a417fdff 100644 --- a/cli/tests/test_browser_packaging.py +++ b/cli/tests/test_browser_packaging.py @@ -44,6 +44,9 @@ def test_browser_module_selection_excludes_desktop_surfaces() -> None: selected = {path.as_posix() for path in selected_source_paths()} assert "modelable/browser/api.py" in selected + assert "modelable/llm/conversation_plan.py" in selected + assert "modelable/llm/conversation_planner.py" in selected + assert "modelable/llm/provider_types.py" in selected assert "modelable/language/completion.py" in selected assert "modelable/language/hover.py" in selected assert "modelable/grammar/modelable.lark" in selected diff --git a/cli/tests/test_conversation.py b/cli/tests/test_conversation.py index 6726d54f..5dbd1320 100644 --- a/cli/tests/test_conversation.py +++ b/cli/tests/test_conversation.py @@ -16,6 +16,7 @@ QueryPlan, UnsupportedPlan, ) +from modelable.llm.filesystem_conversation import FilesystemConversationBackend from modelable.llm.providers import LLMRequest, LLMResponse from modelable.llm.workspace_editor import WorkspaceApplyError from modelable.operations.compilation import ( @@ -150,6 +151,8 @@ def test_preview_and_apply_complete_entity(tmp_path: Path) -> None: provider=FakeProvider(plan), ) + assert isinstance(session.backend, FilesystemConversationBackend) + preview = session.turn("add a customer entity with address") assert preview.kind == "preview" diff --git a/cli/tests/test_conversation_engine.py b/cli/tests/test_conversation_engine.py new file mode 100644 index 00000000..d46164c9 --- /dev/null +++ b/cli/tests/test_conversation_engine.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +import pytest +from support.conversation_simulator import ConversationSimulatorProvider + +from modelable.llm.conversation_backend import ConversationReply +from modelable.llm.conversation_engine import ConversationEngine +from modelable.llm.conversation_plan import ChangeSetPlan, CompilePlan, QueryPlan +from modelable.llm.conversation_planner import ( + ConversationPlanner, + PendingPlanRequest, + PlannerContext, + PlanningRequestError, + ResumableConversationPlanner, +) + + +def valid_create_customer_plan() -> dict[str, object]: + return { + "kind": "change_set", + "summary": "Create customer.Customer@1", + "operations": [ + { + "kind": "create_model", + "domain": "customer", + "name": "Customer", + "model_kind": "entity", + "fields": [ + { + "name": "customerId", + "type": {"kind": "uuid"}, + "annotations": [{"kind": "key"}], + } + ], + } + ], + } + + +@dataclass +class RecordingBackend: + previewed_plans: list[ChangeSetPlan] = field(default_factory=list) + applied_ids: list[str] = field(default_factory=list) + discarded_ids: list[str] = field(default_factory=list) + reset_calls: int = 0 + next_action: int = 1 + + def workspace_summary(self) -> str: + return "domain customer\n owner: customer-team" + + def execute_query(self, plan: QueryPlan) -> ConversationReply: + return ConversationReply(kind="answer", text=f"query:{plan.query_kind}") + + def preview_source_change( + self, + plan: ChangeSetPlan, + replaced_action_id: str | None, + ) -> ConversationReply: + self.previewed_plans.append(plan) + action_id = f"change-{self.next_action}" + self.next_action += 1 + return ConversationReply( + kind="preview", + text=f"preview:{action_id}:replaced:{replaced_action_id}", + change_set_id=action_id, + operation_kind="source_change", + focused_ref="customer.Customer@1", + ) + + def preview_compilation( + self, + plan: CompilePlan, + replaced_action_id: str | None, + ) -> ConversationReply: + return ConversationReply( + kind="preview", + text=f"compile:{plan.target}:replaced:{replaced_action_id}", + change_set_id="compile-1", + operation_kind="compile", + ) + + def apply(self, action_id: str) -> ConversationReply: + self.applied_ids.append(action_id) + return ConversationReply(kind="applied", text=f"applied:{action_id}", change_set_id=action_id) + + def discard(self, action_id: str) -> ConversationReply: + self.discarded_ids.append(action_id) + return ConversationReply(kind="discarded", text=f"discarded:{action_id}", change_set_id=action_id) + + def reset(self) -> None: + self.reset_calls += 1 + + +def engine_with_request_ids(*request_ids: str) -> tuple[ConversationEngine, RecordingBackend]: + ids = iter(request_ids) + backend = RecordingBackend() + engine = ConversationEngine( + backend=backend, + planner=ResumableConversationPlanner(id_factory=lambda: next(ids)), + ) + return engine, backend + + +def test_engine_resumes_plan_and_tracks_exact_pending_action() -> None: + engine, backend = engine_with_request_ids("request-1") + pending = engine.begin_turn("Create a customer") + assert isinstance(pending, PendingPlanRequest) + + reply = engine.resume_turn( + pending.request_id, + json.dumps(valid_create_customer_plan()), + ) + + assert reply.kind == "preview" + assert reply.change_set_id == "change-1" + assert engine.pending_action_id == "change-1" + assert backend.previewed_plans[0].kind == "change_set" + assert engine.history == [ + ("user", "Create a customer"), + ("assistant", reply.text), + ] + + +def test_engine_refinement_replaces_pending_action() -> None: + engine, _ = engine_with_request_ids("request-1", "request-2") + first = engine.begin_turn("Create a customer") + assert isinstance(first, PendingPlanRequest) + engine.resume_turn(first.request_id, json.dumps(valid_create_customer_plan())) + + second = engine.begin_turn("Add an email field") + assert isinstance(second, PendingPlanRequest) + reply = engine.resume_turn(second.request_id, json.dumps(valid_create_customer_plan())) + + assert reply.change_set_id == "change-2" + assert "replaced:change-1" in reply.text + assert engine.pending_action_id == "change-2" + + +def test_engine_apply_and_discard_use_exact_pending_id() -> None: + engine, backend = engine_with_request_ids("request-1", "request-2") + pending = engine.begin_turn("Create a customer") + assert isinstance(pending, PendingPlanRequest) + engine.resume_turn(pending.request_id, json.dumps(valid_create_customer_plan())) + + with pytest.raises(ValueError, match="does not match"): + engine.apply("other") + + applied = engine.apply("change-1") + assert applied.kind == "applied" + assert backend.applied_ids == ["change-1"] + assert engine.pending_action_id is None + + next_pending = engine.begin_turn("Create another customer") + assert isinstance(next_pending, PendingPlanRequest) + engine.resume_turn(next_pending.request_id, json.dumps(valid_create_customer_plan())) + discarded = engine.discard("change-2") + assert discarded.kind == "discarded" + assert backend.discarded_ids == ["change-2"] + assert engine.pending_action_id is None + + +def test_engine_records_deterministic_reply_without_completion() -> None: + engine, _ = engine_with_request_ids("unused") + + reply = engine.begin_turn("/describe customer.Customer@1") + + assert isinstance(reply, ConversationReply) + assert reply.kind == "answer" + assert engine.history == [ + ("user", "/describe customer.Customer@1"), + ("assistant", "query:summary"), + ] + + +def test_reset_invalidates_pending_completion_and_preview() -> None: + engine, backend = engine_with_request_ids("request-1") + pending = engine.begin_turn("Create a customer") + assert isinstance(pending, PendingPlanRequest) + + engine.reset() + + with pytest.raises(PlanningRequestError): + engine.resume_turn(pending.request_id, "{}") + assert backend.reset_calls == 1 + assert engine.pending_action_id is None + assert engine.history == [] + + +def test_engine_converts_completion_failure_into_completed_reply() -> None: + engine, _ = engine_with_request_ids("request-1") + pending = engine.begin_turn("Create a customer") + assert isinstance(pending, PendingPlanRequest) + + reply = engine.fail_turn(pending.request_id, RuntimeError("provider unavailable")) + + assert reply.kind == "unsupported" + assert "provider unavailable" in reply.text + assert engine.history[-1] == ("assistant", reply.text) + + +def test_engine_returns_error_when_apply_has_no_pending_action() -> None: + engine, _ = engine_with_request_ids("unused") + + reply = engine.begin_turn("/apply") + + assert isinstance(reply, ConversationReply) + assert reply.kind == "error" + assert "no pending action" in reply.text.lower() + + +def test_engine_without_completion_uses_offline_planner() -> None: + backend = RecordingBackend() + engine = ConversationEngine( + backend=backend, + planner=ResumableConversationPlanner(id_factory=lambda: "unused"), + completion_enabled=False, + ) + + reply = engine.begin_turn("Create a customer") + + assert isinstance(reply, ConversationReply) + assert reply.kind == "unsupported" + assert "Configure an LLM provider" in reply.text + + +def test_semantic_simulator_drives_typed_conversation_planner() -> None: + planner = ConversationPlanner(ConversationSimulatorProvider()) + + plan = planner.plan( + "Create an invoice with an invoice id", + PlannerContext( + workspace_summary="domain billing", + focused_ref=None, + history=(), + pending_plan=None, + ), + ) + + assert isinstance(plan, ChangeSetPlan) + assert plan.operations[0].kind == "create_model" diff --git a/cli/tests/test_conversation_plan.py b/cli/tests/test_conversation_plan.py index 2df8027e..5603720d 100644 --- a/cli/tests/test_conversation_plan.py +++ b/cli/tests/test_conversation_plan.py @@ -14,8 +14,15 @@ CreateModel, ImplementedTarget, QueryPlan, + conversation_plan_json_schema, parse_conversation_plan, ) +from modelable.llm.conversation_planner import ( + PendingPlanRequest, + PlannerContext, + PlanningRequestError, + ResumableConversationPlanner, +) from modelable.llm.providers import LLMRequest, LLMResponse from modelable.operations.compilation import TARGETS @@ -31,6 +38,153 @@ def valid_compile_payload() -> dict[str, object]: } +def valid_create_customer_plan() -> dict[str, object]: + return { + "kind": "change_set", + "summary": "Create customer.Customer@1", + "operations": [ + { + "kind": "create_model", + "domain": "customer", + "name": "Customer", + "model_kind": "entity", + "fields": [ + { + "name": "customerId", + "type": {"kind": "uuid"}, + "annotations": [{"kind": "key"}], + } + ], + } + ], + } + + +def planner_context() -> PlannerContext: + return PlannerContext( + workspace_summary="domain customer\n owner: customer-team", + focused_ref=None, + history=(), + pending_plan=None, + ) + + +def test_resumable_planner_returns_request_then_valid_plan() -> None: + planner = ResumableConversationPlanner(id_factory=lambda: "request-1") + pending = planner.begin("Create customer.Customer", planner_context()) + + assert isinstance(pending, PendingPlanRequest) + assert pending.request_id == "request-1" + assert pending.request.schema is not None + + plan = planner.resume( + "request-1", + json.dumps(valid_create_customer_plan()), + ) + + assert isinstance(plan, ChangeSetPlan) + + +def test_resumable_planner_requests_one_bounded_repair() -> None: + ids = iter(("initial", "repair")) + planner = ResumableConversationPlanner( + repair_attempts=1, + id_factory=lambda: next(ids), + ) + initial = planner.begin("Create customer.Customer", planner_context()) + assert isinstance(initial, PendingPlanRequest) + + repair = planner.resume(initial.request_id, "{malformed") + + assert isinstance(repair, PendingPlanRequest) + assert repair.request_id == "repair" + assert repair.attempt == 1 + assert "validation error" in repair.request.user.lower() + + +def test_resumable_planner_repairs_compile_plan_for_source_mutation() -> None: + planner = ResumableConversationPlanner(id_factory=iter(("initial", "repair")).__next__) + pending = planner.begin("Create a billing projection from customer.Customer@1", planner_context()) + assert isinstance(pending, PendingPlanRequest) + + repair = planner.resume( + pending.request_id, + json.dumps( + { + "kind": "compile", + "target": "protobuf", + "domains": ["customer"], + "output": None, + "descriptor_set": False, + "summary": "Create a projection", + } + ), + ) + + assert isinstance(repair, PendingPlanRequest) + assert "source mutation" in repair.request.user.lower() + + +def test_offline_planner_clarifies_ungrounded_projection_creation() -> None: + planner = ResumableConversationPlanner() + + plan = planner.begin("Create a projection", planner_context()) + + assert plan.kind == "clarification" + + +def test_resumable_planner_rejects_unknown_duplicate_and_late_request_ids() -> None: + ids = iter(("initial", "repair")) + planner = ResumableConversationPlanner( + repair_attempts=1, + id_factory=lambda: next(ids), + ) + + with pytest.raises(PlanningRequestError, match="Unknown or completed"): + planner.resume("missing", "{}") + + initial = planner.begin("Create customer.Customer", planner_context()) + assert isinstance(initial, PendingPlanRequest) + repair = planner.resume(initial.request_id, "{malformed") + assert isinstance(repair, PendingPlanRequest) + + with pytest.raises(PlanningRequestError, match="Unknown or completed"): + planner.resume(initial.request_id, "{}") + + plan = planner.resume(repair.request_id, json.dumps(valid_create_customer_plan())) + assert isinstance(plan, ChangeSetPlan) + + with pytest.raises(PlanningRequestError, match="Unknown or completed"): + planner.resume(repair.request_id, json.dumps(valid_create_customer_plan())) + + +def test_resumable_planner_returns_deterministic_plans_without_completion() -> None: + planner = ResumableConversationPlanner(id_factory=lambda: "unused") + + plan = planner.begin("/describe customer.Customer@1", planner_context()) + + assert isinstance(plan, QueryPlan) + assert plan.refs == ["customer.Customer@1"] + + +def test_resumable_planner_requests_completion_for_free_form_compile() -> None: + planner = ResumableConversationPlanner(id_factory=lambda: "compile-request") + + outcome = planner.begin("Compile this workspace to Rust", planner_context()) + + assert isinstance(outcome, PendingPlanRequest) + assert outcome.request_id == "compile-request" + + +def test_resumable_planner_requests_completion_for_configured_ask() -> None: + planner = ResumableConversationPlanner(id_factory=lambda: "ask-request") + + outcome = planner.begin("/ask Explain customer ownership", planner_context()) + + assert isinstance(outcome, PendingPlanRequest) + assert outcome.request_id == "ask-request" + + def test_compile_plan_is_closed_and_schema_validated() -> None: plan = parse_conversation_plan(json.dumps(valid_compile_payload())) @@ -554,6 +708,17 @@ def assert_closed_objects(node: object) -> None: assert json.dumps(request.schema, sort_keys=True) in request.system +def test_conversation_schema_requires_every_kind_discriminator() -> None: + schema = conversation_plan_json_schema() + definitions = schema["$defs"] + + for name, definition in definitions.items(): + properties = definition.get("properties", {}) + kind = properties.get("kind") + if isinstance(kind, dict) and "const" in kind: + assert "kind" in definition.get("required", []), name + + def test_conversation_system_prompt_states_safety_and_ambiguity_rules() -> None: from modelable.llm.conversation_planner import PlannerContext, build_conversation_request diff --git a/cli/tests/test_llm_provider_integration.py b/cli/tests/test_llm_provider_integration.py index 8060f7fc..8b35cd95 100644 --- a/cli/tests/test_llm_provider_integration.py +++ b/cli/tests/test_llm_provider_integration.py @@ -182,6 +182,47 @@ def fake_urlopen(req: request.Request, timeout: float): assert captured["timeout"] == 5.0 +def test_ollama_provider_posts_full_json_schema(monkeypatch): + captured: dict[str, object] = {} + schema = { + "type": "object", + "properties": {"kind": {"const": "query"}}, + "required": ["kind"], + "additionalProperties": False, + } + + class DummyResponse: + def read(self) -> bytes: + return b'{"message":{"content":"{\\"kind\\":\\"query\\"}"}}' + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def fake_urlopen(req: request.Request, timeout: float): + captured["payload"] = json.loads(req.data.decode("utf-8") if req.data else "{}") + return DummyResponse() + + monkeypatch.setattr("modelable.llm.providers.request.urlopen", fake_urlopen) + provider = OllamaProvider( + base_url="http://localhost:11434", + model="qwen2.5-coder:14b", + ) + + provider.complete( + LLMRequest( + system="Return a plan.", + user="Describe the workspace.", + response_format="json", + schema=schema, + ) + ) + + assert captured["payload"]["format"] == schema + + def test_update_definition_uses_injected_provider(tmp_path): mdl = tmp_path / "workspace.mdl" original = """ diff --git a/cli/tests/test_ollama_conversation_conformance.py b/cli/tests/test_ollama_conversation_conformance.py new file mode 100644 index 00000000..872a3c85 --- /dev/null +++ b/cli/tests/test_ollama_conversation_conformance.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from modelable.llm.conversation_plan import ( + ChangeSetPlan, + ClarificationPlan, + ConversationPlan, + QueryPlan, + UnsupportedPlan, +) +from modelable.llm.conversation_planner import ( + ConversationPlanner, + PendingPlanRequest, + PlannerContext, + ResumableConversationPlanner, +) +from modelable.llm.providers import OllamaProvider +from modelable.llm.workspace_editor import WorkspaceEditError, WorkspaceEditor + +pytestmark = pytest.mark.ollama + +WORKSPACE = """\ +domain customer { + owner: "customer-team" + entity Customer @ 1 (additive) { + @key customerId: uuid + name: string + } +} +domain billing { + owner: "billing-team" +} +""" + + +def _provider() -> OllamaProvider: + if os.environ.get("MODELABLE_OLLAMA_TESTS") != "1": + pytest.skip("set MODELABLE_OLLAMA_TESTS=1 to run local Ollama conformance") + model = os.environ.get("MODELABLE_OLLAMA_MODEL") + if not model: + pytest.fail("MODELABLE_OLLAMA_MODEL is required when Ollama conformance is enabled") + return OllamaProvider( + base_url=os.environ.get("MODELABLE_LLM_BASE_URL", "http://127.0.0.1:11434"), + model=model, + ) + + +def _context() -> PlannerContext: + return PlannerContext( + workspace_summary=WORKSPACE, + focused_ref="customer.Customer@1", + history=(), + pending_plan=None, + ) + + +@pytest.mark.parametrize( + ("message", "expected_type"), + [ + ("Describe the workspace", QueryPlan), + ( + "Create billing.Invoice with a UUID invoiceId key and decimal total. " + "Ownership is billing-team and identity is invoiceId.", + ChangeSetPlan, + ), + ( + "Create a billing projection named CustomerProjection from customer.Customer@1, " + "mapping customerId and name directly.", + ChangeSetPlan, + ), + ("Add an optional email string field to customer.Customer@1 by appending version 2.", ChangeSetPlan), + ("Create a projection, but I have not chosen its source or consumer domain.", ClarificationPlan), + ], +) +def test_ollama_returns_valid_typed_conversation_plans( + tmp_path: Path, + message: str, + expected_type: type[ConversationPlan], +) -> None: + plan = ConversationPlanner(_provider()).plan(message, _context()) + + if isinstance(plan, UnsupportedPlan): + assert "valid typed plan" in plan.reason + return + assert isinstance(plan, expected_type), plan + if isinstance(plan, ChangeSetPlan): + source = tmp_path / "workspace.mdl" + source.write_text(WORKSPACE, encoding="utf-8") + try: + preview = WorkspaceEditor(tmp_path).preview(plan) + except WorkspaceEditError as error: + assert str(error) + return + assert preview.candidate_sources + assert not [diagnostic for diagnostic in preview.diagnostics if diagnostic.severity == "error"] + assert all(text.count("{") == text.count("}") for text in preview.candidate_sources.values()) + + +def test_ollama_can_complete_a_bounded_repair_request() -> None: + provider = _provider() + planner = ResumableConversationPlanner(repair_attempts=1) + initial = planner.begin("Create billing.Invoice with invoiceId as its UUID key.", _context()) + assert isinstance(initial, PendingPlanRequest) + repair = planner.resume(initial.request_id, "{malformed") + assert isinstance(repair, PendingPlanRequest) + + response = provider.complete(repair.request) + repaired = planner.resume(repair.request_id, response.content) + + assert isinstance(repaired, ChangeSetPlan), repaired diff --git a/cli/tests/test_validate_surface_detection.py b/cli/tests/test_validate_surface_detection.py index 9be842aa..5212c77a 100644 --- a/cli/tests/test_validate_surface_detection.py +++ b/cli/tests/test_validate_surface_detection.py @@ -111,7 +111,10 @@ def test_shared_model_graph_change_runs_all_export_smokes() -> None: "cli/src/modelable/grammar/modelable.lark", "cli/src/modelable/graph/export.py", "cli/src/modelable/language/completion.py", + "cli/src/modelable/llm/conversation_plan.py", + "cli/src/modelable/llm/conversation_planner.py", "cli/src/modelable/llm/context.py", + "cli/src/modelable/llm/provider_types.py", "cli/src/modelable/parser/ir.py", "cli/src/modelable/planner/planner.py", "cli/src/modelable/validation/semantic.py", diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 644715a2..d809734f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1333,6 +1333,12 @@ Provider resolution is identical to `modelable chat`: workspace and environment configuration remain Python-owned. The extension does not parse, validate, or write `.mdl` files and does not apply a VS Code `WorkspaceEdit`. +The CLI, VS Code participant, and browser playground share this typed Python +planner and lifecycle engine. Their interfaces differ, but grounded queries, +clarification, preview, refinement, Apply, Discard, and reset use the same +closed plan vocabulary. Provider output contains typed operations rather than +raw Modelable source; Python renders and validates the canonical source. + **Defined in:** section 12. ### 10.6 `registry` — Federated registry management diff --git a/docs/maintainers.md b/docs/maintainers.md index 762c55aa..e4d61cf0 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -153,10 +153,23 @@ staging. Also verify that: prompt, response, source/artifact content, credentials, tokens, environment values, or unrelated paths. -Keep conversational planning local-only. Registry synchronization, publishing, -external services, WebLLM, and the VS Code native-model adapter require -separate accepted designs. Preview text over 2 MiB must continue to fail with -guidance to use direct `modelable compile`. +Keep conversational planning local-only. CLI, VS Code, and the playground use +the same typed Python conversation engine with filesystem and in-memory +adapters. Browser tests use the semantic simulator; real-model checks are +opt-in: + +```text +cd cli +$env:MODELABLE_OLLAMA_TESTS='1' +$env:MODELABLE_OLLAMA_MODEL='qwen2.5-coder:14b' +uv run pytest tests/test_ollama_conversation_conformance.py -v -n 0 +``` + +The suite uses `MODELABLE_LLM_BASE_URL` or `http://127.0.0.1:11434`, never +downloads models, and does not make Ollama a playground provider. Registry +synchronization, publishing, and external actions remain outside the +conversation plan vocabulary. Preview text over 2 MiB must continue to fail +with guidance to use direct `modelable compile`. For release pipeline or packaging metadata changes, also run: diff --git a/docs/playground-design.md b/docs/playground-design.md index 16f336fd..622cad56 100644 --- a/docs/playground-design.md +++ b/docs/playground-design.md @@ -580,8 +580,8 @@ Responsibilities: - Download model assets after explicit user action. - Report download and initialization progress. - Execute prompts locally. -- Stream tokens to the UI where useful. -- Return normalized responses to the compiler workflow. +- Return schema-constrained typed plans to the compiler workflow. +- Keep model inference and chat history local to the current page session. ## 12.2 Provider abstraction @@ -611,12 +611,14 @@ interface LlmProvider { } ``` -Potential providers: +Implemented providers: - `WebGpuProvider` using WebLLM. -- `OllamaProvider` calling a user-controlled local Ollama endpoint. -- `RemoteByokProvider` using a user-supplied key where browser CORS policies permit. -- `HeuristicProvider` for deterministic non-LLM behavior. +- `SimulatorProvider` for deterministic semantic tests and local fallback. + +The playground does not expose Ollama or a remote provider. Maintainers can use +a developer-controlled Ollama server for opt-in conformance testing of the +shared Python planner. ## 12.3 Python integration @@ -628,10 +630,12 @@ Preferred workflow: 2. Compiler worker returns the request to TypeScript. 3. TypeScript invokes the selected provider. 4. TypeScript returns the response to the compiler worker. -5. Python validates and applies the generated content. -6. The UI shows a diff before modifying the workspace. +5. Python validates the typed plan and renders canonical Modelable source. +6. The UI shows the exact source or artifact preview before explicit Apply. -AI-generated changes must never bypass parser and validator checks. +WebLLM receives the complete JSON schema through `response_format`. Provider +responses remain untrusted and must pass independent Python validation. Models +never return raw `.mdl` source for application. ## 12.4 AI interaction design @@ -1050,7 +1054,7 @@ median graph operations. The completed design is archived in - Plugin contracts. - Additional visualization modes. -- Optional local Ollama provider. +- Additional opt-in provider conformance suites. - Optional GitHub integration using explicit user authorization. ## 25. Architectural decisions diff --git a/docs/superpowers/plans/2026-07-27-shared-conversation-engine.md b/docs/superpowers/plans/2026-07-27-shared-conversation-engine.md new file mode 100644 index 00000000..c1ba9a71 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-shared-conversation-engine.md @@ -0,0 +1,1457 @@ +# Shared Conversation Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make CLI, VS Code, and the browser playground use one Python-owned typed conversation engine while WebLLM and other providers supply schema-constrained plans rather than raw Modelable source. + +**Architecture:** Extract browser-safe provider types and a resumable typed planner, then place conversation lifecycle logic behind a platform-neutral engine and environment port. Preserve CLI and VS Code through a filesystem adapter; add a Pyodide in-memory adapter and a TypeScript completion driver for the browser. + +**Tech Stack:** Python 3.14, Pydantic 2, JSON Schema 2020-12, Pyodide, TypeScript 7, React 19, Web Workers, `@mlc-ai/web-llm`, Vitest, Playwright, VS Code LSP/chat APIs, Ollama `/api/chat`. + +**Design:** [Shared Conversation Engine Design](../specs/2026-07-27-shared-conversation-engine-design.md) + +## Global Constraints + +- Python owns prompts, typed plans, validation, canonical rendering, previews, and lifecycle semantics. +- TypeScript interfaces own presentation and transport only. +- Models return closed typed plans; no provider output is interpreted as raw `.mdl` source. +- Every source mutation and artifact promotion requires an exact preview and explicit confirmation. +- Browser inference remains local through WebLLM. +- Ollama is an opt-in developer conformance provider, not a web UI provider. +- Browser chat history lasts only for the current page session. +- Streaming tokens, remote operations, autonomous tools, and persistent browser chat are out of scope. +- Full JSON schemas must reach WebLLM and Ollama, and responses must still be independently validated. +- Existing CLI and VS Code conversation behavior must remain compatible throughout migration. +- Run all four commands from `cli/` before every commit: + `uv run ruff format .`, + `uv run ruff check .`, + the mypy baseline ratchet, and + `uv run pytest --tb=short`. + +--- + +## File Map + +### Shared Python conversation core + +- Create `cli/src/modelable/llm/provider_types.py`: browser-safe `LLMRequest`, `LLMResponse`, and `LLMProvider`. +- Modify `cli/src/modelable/llm/providers.py`: transport implementations only. +- Modify `cli/src/modelable/llm/conversation_planner.py`: resumable planning plus synchronous compatibility driver. +- Create `cli/src/modelable/llm/conversation_backend.py`: environment port and common preview/apply records. +- Create `cli/src/modelable/llm/conversation_engine.py`: platform-neutral history, planning, pending-action, and reply lifecycle. +- Modify `cli/src/modelable/llm/conversation.py`: filesystem compatibility facade over the shared engine. +- Create `cli/src/modelable/llm/filesystem_conversation.py`: filesystem workspace and compilation adapter. + +### Browser Python adapter + +- Create `cli/src/modelable/browser/conversation.py`: in-memory backend and bounded browser session registry. +- Modify `cli/src/modelable/browser/api.py`: retain synchronized source snapshots and expose conversation operations. +- Modify `cli/src/modelable/browser/dto.py`: browser conversation request/reply DTOs. +- Modify `cli/src/modelable/browser/dispatch.py`: `conversation.*` methods and serializers. +- Modify `cli/scripts/build_browser_wheel.py`: include browser-safe shared conversation modules. + +### Browser TypeScript interface + +- Modify `web/src/ai/types.ts`: full-schema provider request remains canonical. +- Modify `web/src/ai/ai.worker.ts`: schema-constrained WebLLM requests. +- Rename `web/src/ai/heuristic-provider.ts` to `web/src/ai/simulator-provider.ts`: deterministic semantic test provider. +- Modify `web/src/client.ts` and `web/src/protocol.ts`: resumable browser conversation transport. +- Modify `web/src/editor/types.ts` and `web/src/editor/SourceEditor.tsx`: expose active cursor position. +- Modify `web/src/ai/chat-types.ts`, `web/src/ai/ChatPanel.tsx`, and `web/src/App.tsx`: common reply rendering and lifecycle. + +### Tests and documentation + +- Add focused Python tests for provider types, resumable planning, engine contracts, filesystem compatibility, browser sessions, and Ollama conformance. +- Extend WebLLM worker/provider, client/protocol, App, ChatPanel, and Playwright tests. +- Preserve and extend VS Code conversation tests. +- Update `docs/cli-reference.md`, `docs/playground-design.md`, `docs/maintainers.md`, and `vscode/README.md`. + +--- + +### Task 1: Separate Browser-Safe Provider Types and Pass Full Schemas to Ollama + +**Files:** +- Create: `cli/src/modelable/llm/provider_types.py` +- Modify: `cli/src/modelable/llm/providers.py` +- Modify: `cli/src/modelable/llm/__init__.py` +- Modify: `cli/src/modelable/llm/conversation_planner.py` +- Modify: `cli/src/modelable/llm/update_plan.py` +- Modify: `cli/tests/test_llm_provider_integration.py` +- Modify: `cli/scripts/build_browser_wheel.py` +- Test: `cli/tests/test_browser_packaging.py` + +**Interfaces:** +- Produces: `LLMRequest`, `LLMResponse`, and `LLMProvider` from `modelable.llm.provider_types`. +- Produces: `OllamaProvider.complete()` sending `request.schema` as `/api/chat.format` when present. + +- [ ] **Step 1: Write failing provider and browser-package tests** + +Add a transport assertion: + +```python +def test_ollama_provider_posts_full_json_schema(monkeypatch): + schema = { + "type": "object", + "properties": {"kind": {"const": "query"}}, + "required": ["kind"], + "additionalProperties": False, + } + captured = install_ollama_response(monkeypatch, '{"kind":"query"}') + provider = OllamaProvider( + base_url="http://localhost:11434", + model="qwen2.5-coder:14b", + ) + + provider.complete( + LLMRequest( + system="Return a plan.", + user="Describe the workspace.", + response_format="json", + schema=schema, + ) + ) + + assert captured["payload"]["format"] == schema +``` + +Extend the browser wheel selection test to require: + +```python +assert Path("modelable/llm/provider_types.py") in selected_source_paths() +``` + +- [ ] **Step 2: Run the focused tests and confirm the red state** + +Run: + +```powershell +cd cli +uv run pytest tests/test_llm_provider_integration.py -k "full_json_schema" -v +uv run pytest tests/test_browser_packaging.py -k "module_selection" -v +``` + +Expected: the Ollama payload contains `"json"` instead of the schema, and `provider_types.py` is absent. + +- [ ] **Step 3: Extract provider-neutral types** + +Create `provider_types.py` with: + +```python +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class LLMRequest: + system: str + user: str + temperature: float = 0.2 + response_format: str = "text" + schema: dict[str, object] | None = None + + +@dataclass(frozen=True) +class LLMResponse: + content: str + provider: str + model: str + prompt_tokens: int | None = None + completion_tokens: int | None = None + + +class LLMProvider(Protocol): + def complete(self, request: LLMRequest) -> LLMResponse: ... +``` + +Import these types from `providers.py`, `conversation_planner.py`, and +`update_plan.py`. Re-export them from `modelable.llm`. + +- [ ] **Step 4: Send the exact schema through Ollama** + +Replace the generic format branch with: + +```python +if request.schema is not None: + payload["format"] = request.schema +elif request.response_format == "json": + payload["format"] = "json" +``` + +Add `llm/provider_types.py` to `INCLUDE_FILES`. + +- [ ] **Step 5: Run provider, planner, and browser-build tests** + +Run: + +```powershell +cd cli +uv run pytest tests/test_llm_provider_integration.py tests/test_conversation_plan.py tests/test_browser_packaging.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Run mandatory gates and commit** + +Run the four required `cli/` commands, then: + +```powershell +git add cli/src/modelable/llm/provider_types.py ` + cli/src/modelable/llm/providers.py ` + cli/src/modelable/llm/__init__.py ` + cli/src/modelable/llm/conversation_planner.py ` + cli/src/modelable/llm/update_plan.py ` + cli/scripts/build_browser_wheel.py ` + cli/tests/test_llm_provider_integration.py ` + cli/tests/test_browser_packaging.py +git commit -m "refactor: separate llm provider contracts" +``` + +--- + +### Task 2: Add a Resumable Typed Conversation Planner + +**Files:** +- Modify: `cli/src/modelable/llm/conversation_planner.py` +- Modify: `cli/src/modelable/llm/__init__.py` +- Modify: `cli/scripts/build_browser_wheel.py` +- Test: `cli/tests/test_conversation_plan.py` +- Test: `cli/tests/test_browser_packaging.py` + +**Interfaces:** +- Consumes: `LLMRequest`, `LLMResponse`, and `LLMProvider` from Task 1. +- Produces: `PendingPlanRequest`. +- Produces: `ResumableConversationPlanner.begin(message, context)`. +- Produces: `ResumableConversationPlanner.resume(request_id, content)`. +- Preserves: `ConversationPlanner.plan(message, context) -> ConversationPlan`. + +- [ ] **Step 1: Write failing begin/resume/repair tests** + +Add tests covering a valid response and a repair: + +```python +def test_resumable_planner_returns_request_then_valid_plan(): + planner = ResumableConversationPlanner(id_factory=lambda: "request-1") + pending = planner.begin("Create customer.Customer", planner_context()) + + assert isinstance(pending, PendingPlanRequest) + assert pending.request_id == "request-1" + assert pending.request.schema == conversation_plan_json_schema() + + plan = planner.resume( + "request-1", + json.dumps(valid_create_customer_plan()), + ) + assert isinstance(plan, ChangeSetPlan) + + +def test_resumable_planner_requests_one_bounded_repair(): + ids = iter(("initial", "repair")) + planner = ResumableConversationPlanner( + repair_attempts=1, + id_factory=lambda: next(ids), + ) + initial = planner.begin("Create customer.Customer", planner_context()) + repair = planner.resume(initial.request_id, "{malformed") + + assert isinstance(repair, PendingPlanRequest) + assert repair.request_id == "repair" + assert repair.attempt == 1 + assert "validation error" in repair.request.user.lower() +``` + +Also test that late, duplicate, and unknown request IDs raise +`PlanningRequestError`. + +- [ ] **Step 2: Run the focused tests and confirm they fail** + +Run: + +```powershell +cd cli +uv run pytest tests/test_conversation_plan.py -k "resumable" -v +``` + +Expected: imports fail because the resumable types do not exist. + +- [ ] **Step 3: Implement the resumable state machine** + +Add: + +```python +@dataclass(frozen=True) +class PendingPlanRequest: + request_id: str + request: LLMRequest + attempt: int + + +@dataclass +class _PendingPlanningState: + message: str + context: PlannerContext + attempt: int + + +class PlanningRequestError(ValueError): + pass +``` + +`ResumableConversationPlanner` stores pending state by request ID. `begin()` +returns deterministic offline plans immediately and otherwise creates the +initial completion request. `resume()` consumes the request ID exactly once, +parses the response, and either returns a plan or registers the next bounded +repair request. Exhaustion returns the same typed `UnsupportedPlan` currently +used by `ConversationPlanner`. + +- [ ] **Step 4: Drive the resumable planner from the synchronous API** + +Refactor `ConversationPlanner.plan()` to: + +```python +outcome = self.resumable.begin(message, context) +while isinstance(outcome, PendingPlanRequest): + if self.provider is None: + raise RuntimeError("Pending planning requires a provider") + response = self.provider.complete(outcome.request) + outcome = self.resumable.resume(outcome.request_id, response.content) +return outcome +``` + +Preserve current provider-failure and repair error wording. + +- [ ] **Step 5: Add planner modules to the browser wheel** + +Add `llm/conversation_plan.py` and `llm/conversation_planner.py` to +`INCLUDE_FILES`. Assert both are selected and contain no forbidden imports. + +- [ ] **Step 6: Run the complete planner and provider suite** + +Run: + +```powershell +cd cli +uv run pytest tests/test_conversation_plan.py tests/test_conversation.py tests/test_llm_provider_integration.py tests/test_browser_packaging.py -v +``` + +Expected: PASS with existing synchronous behavior unchanged. + +- [ ] **Step 7: Run mandatory gates and commit** + +Run the four required `cli/` commands, then: + +```powershell +git add cli/src/modelable/llm/conversation_planner.py ` + cli/src/modelable/llm/__init__.py ` + cli/scripts/build_browser_wheel.py ` + cli/tests/test_conversation_plan.py ` + cli/tests/test_browser_packaging.py +git commit -m "feat: add resumable conversation planning" +``` + +--- + +### Task 3: Extract the Platform-Neutral Conversation Engine + +**Files:** +- Create: `cli/src/modelable/llm/conversation_backend.py` +- Create: `cli/src/modelable/llm/conversation_engine.py` +- Modify: `cli/src/modelable/llm/conversation.py` +- Modify: `cli/src/modelable/llm/__init__.py` +- Test: `cli/tests/test_conversation_engine.py` + +**Interfaces:** +- Consumes: `ResumableConversationPlanner`, `PendingPlanRequest`, and `ConversationPlan`. +- Produces: `ConversationBackend` protocol. +- Produces: `ConversationEngine.begin_turn()`, `resume_turn()`, `apply()`, `discard()`, and `reset()`. +- Produces: `ConversationOutcome = ConversationReply | PendingPlanRequest`. + +- [ ] **Step 1: Write a fake-backend engine contract test** + +Create a `RecordingBackend` that implements: + +```python +class ConversationBackend(Protocol): + def workspace_summary(self) -> str: ... + def execute_query(self, plan: QueryPlan) -> ConversationReply: ... + def preview_source_change( + self, + plan: ChangeSetPlan, + replaced_action_id: str | None, + ) -> ConversationReply: ... + def preview_compilation( + self, + plan: CompilePlan, + replaced_action_id: str | None, + ) -> ConversationReply: ... + def apply(self, action_id: str) -> ConversationReply: ... + def discard(self, action_id: str) -> ConversationReply: ... + def reset(self) -> None: ... +``` + +Test: + +```python +def test_engine_resumes_plan_and_tracks_exact_pending_action(): + engine, backend = engine_with_scripted_plan(valid_create_customer_plan()) + pending = engine.begin_turn("Create a customer") + reply = engine.resume_turn( + pending.request_id, + json.dumps(valid_create_customer_plan()), + ) + + assert reply.kind == "preview" + assert reply.change_set_id == "change-1" + assert engine.pending_action_id == "change-1" + assert backend.previewed_plans[0].kind == "change_set" +``` + +Add cases for deterministic query, refinement replacing a pending action, +exact apply/discard IDs, reset, and history updates only after a completed +reply. + +- [ ] **Step 2: Run the engine tests and confirm they fail** + +Run: + +```powershell +cd cli +uv run pytest tests/test_conversation_engine.py -v +``` + +Expected: the new modules are missing. + +- [ ] **Step 3: Move common reply types out of the filesystem session** + +Move `ReplyKind`, `ConversationPreviewFile`, and `ConversationReply` to +`conversation_backend.py`. Re-export them from `conversation.py` and +`modelable.llm` so existing imports remain valid. + +Keep `ConversationPreviewFile.path` as `Path` for compatibility. Browser +logical document paths will use safe relative `Path` values and serialize back +to browser URIs at the adapter boundary. + +- [ ] **Step 4: Implement `ConversationEngine`** + +The constructor accepts: + +```python +def __init__( + self, + *, + backend: ConversationBackend, + planner: ResumableConversationPlanner, + focused_ref: str | None = None, +) -> None: +``` + +`begin_turn()` handles apply/discard aliases and deterministic `/compile` +parsing before starting the planner. `resume_turn()` accepts only the current +pending request ID. `_execute_plan()` delegates query, source preview, and +compilation preview to the backend, updates pending action identity from the +reply, and records history. + +- [ ] **Step 5: Verify stale and cancellation semantics** + +Add: + +```python +def test_reset_invalidates_pending_completion_and_preview(): + pending = engine.begin_turn("Create a customer") + engine.reset() + + with pytest.raises(PlanningRequestError): + engine.resume_turn(pending.request_id, "{}") + assert backend.reset_calls == 1 + assert engine.pending_action_id is None +``` + +Run: + +```powershell +cd cli +uv run pytest tests/test_conversation_engine.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Run existing conversation tests** + +Run: + +```powershell +cd cli +uv run pytest tests/test_conversation.py tests/test_conversation_plan.py -v +``` + +Expected: PASS; the compatibility exports prevent import churn. + +- [ ] **Step 7: Run mandatory gates and commit** + +Run the four required `cli/` commands, then: + +```powershell +git add cli/src/modelable/llm/conversation_backend.py ` + cli/src/modelable/llm/conversation_engine.py ` + cli/src/modelable/llm/conversation.py ` + cli/src/modelable/llm/__init__.py ` + cli/tests/test_conversation_engine.py +git commit -m "refactor: extract shared conversation engine" +``` + +--- + +### Task 4: Move Filesystem Effects Behind a Compatibility Adapter + +**Files:** +- Create: `cli/src/modelable/llm/filesystem_conversation.py` +- Modify: `cli/src/modelable/llm/conversation.py` +- Modify: `cli/src/modelable/lsp/conversation_service.py` +- Test: `cli/tests/test_conversation.py` +- Test: `cli/tests/test_lsp_conversation_service.py` +- Test: `cli/tests/test_lsp_conversation_integration.py` +- Test: `vscode/src/test/suite/conversation.test.ts` + +**Interfaces:** +- Consumes: `ConversationBackend` and `ConversationEngine` from Task 3. +- Produces: `FilesystemConversationBackend`. +- Preserves: `ConversationSession` constructor, properties, and `turn()` API. + +- [ ] **Step 1: Add compatibility assertions before refactoring** + +Extend tests to assert: + +```python +class OneResponseProvider: + def __init__(self, plan: ConversationPlan) -> None: + self.plan = plan + + def complete(self, request: LLMRequest) -> LLMResponse: + return LLMResponse( + content=self.plan.model_dump_json(), + provider="test", + model="one-response", + ) + + +def test_conversation_session_remains_filesystem_compatible(tmp_path): + session = ConversationSession( + path=write_customer_workspace(tmp_path), + provider=OneResponseProvider(valid_add_email_plan()), + ) + + preview = session.turn("Add an email field") + applied = session.turn("/apply") + + assert preview.kind == "preview" + assert applied.kind == "applied" + assert session.pending_action_id is None +``` + +Retain existing compilation cleanup, rollback, LSP serialization, dirty-buffer, +and VS Code protocol tests. + +- [ ] **Step 2: Run the compatibility tests in their green pre-refactor state** + +Run: + +```powershell +cd cli +uv run pytest tests/test_conversation.py tests/test_lsp_conversation_service.py tests/test_lsp_conversation_integration.py -v +``` + +Expected: PASS before moving code. + +- [ ] **Step 3: Implement `FilesystemConversationBackend`** + +Move filesystem-specific logic from `ConversationSession`: + +- `WorkspaceEditor` creation and preview; +- `CompilationService` staging; +- pending `PendingChangeSet` or `PendingCompilation`; +- exact apply/discard and cleanup; +- workspace reload; +- preview file construction; and +- query execution. + +The backend owns the concrete pending object. It rejects an `action_id` that +does not match `_pending_id(self.pending)`. + +- [ ] **Step 4: Turn `ConversationSession` into a compatibility facade** + +Construct: + +```python +self.backend = FilesystemConversationBackend( + path=path, + compilation_service=compilation_service, + provider_name=provider_name, + model_name=model_name, + confirmation_surface=confirmation_surface, +) +self.engine = ConversationEngine( + backend=self.backend, + planner=ResumableConversationPlanner(repair_attempts=repair_attempts), + focused_ref=focused_ref, +) +``` + +`turn()` drives pending plan requests synchronously through the configured +provider. Proxy `pending`, `pending_action_id`, `pending_operation_kind`, +`workspace`, `history`, `focused_ref`, cleanup, and close behavior for LSP and +CLI callers. + +- [ ] **Step 5: Run CLI, LSP, and VS Code tests** + +Run: + +```powershell +cd cli +uv run pytest tests/test_conversation.py ` + tests/test_workspace_editor.py ` + tests/test_lsp_conversation_protocol.py ` + tests/test_lsp_conversation_service.py ` + tests/test_lsp_conversation_integration.py -v +cd ..\vscode +npm run check +npm run build +npm test +``` + +Expected: PASS with unchanged public behavior. + +- [ ] **Step 6: Run mandatory gates and commit** + +Run the four required `cli/` commands, then: + +```powershell +git add cli/src/modelable/llm/filesystem_conversation.py ` + cli/src/modelable/llm/conversation.py ` + cli/src/modelable/lsp/conversation_service.py ` + cli/tests/test_conversation.py ` + cli/tests/test_lsp_conversation_service.py ` + cli/tests/test_lsp_conversation_integration.py ` + vscode/src/test/suite/conversation.test.ts +git commit -m "refactor: adapt filesystem conversations" +``` + +--- + +### Task 5: Add Deterministic Semantic Simulation + +**Files:** +- Create: `cli/tests/support/__init__.py` +- Create: `cli/tests/support/conversation_simulator.py` +- Create: `web/src/ai/simulator-provider.ts` +- Create: `web/src/ai/simulator-provider.test.ts` +- Delete: `web/src/ai/heuristic-provider.ts` +- Delete: `web/src/ai/heuristic-provider.test.ts` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.test.tsx` +- Test: `cli/tests/test_conversation_engine.py` + +**Interfaces:** +- Consumes: full-schema `LLMRequest`. +- Produces: deterministic valid, malformed-then-repaired, clarification, and provider-failure responses. +- Browser test selector changes from `ai=heuristic` to `ai=simulator`. + +- [ ] **Step 1: Write failing simulator behavior tests** + +Add TypeScript tests: + +```typescript +it('returns a typed create-model plan for an invoice request', async () => { + const provider = new SimulatorProvider(); + const response = await provider.complete(conversationRequest( + 'Create an invoice with an invoice id', + )); + expect(JSON.parse(response.content)).toMatchObject({ + kind: 'change_set', + operations: [{ kind: 'create_model', name: 'Invoice' }], + }); +}); + +it('returns malformed output once then a valid repair', async () => { + const provider = new SimulatorProvider({ malformedFirst: true }); + expect((await provider.complete(initialRequest)).content).toBe('{malformed'); + expect(JSON.parse((await provider.complete(repairRequest)).content).kind) + .toBe('change_set'); +}); +``` + +Add Python engine tests using `ScriptedConversationProvider`. + +- [ ] **Step 2: Run tests and confirm failure** + +Run: + +```powershell +cd web +npm test -- --run src/ai/simulator-provider.test.ts +cd ..\cli +uv run pytest tests/test_conversation_engine.py -k "simulator" -v +``` + +Expected: simulator modules are missing. + +- [ ] **Step 3: Implement semantic simulator providers** + +The simulator recognizes these deterministic intent phrases: + +- `describe the workspace` -> `QueryPlan(summary)`; +- `create an invoice` -> `ChangeSetPlan(CreateModel)`; +- `suggest a projection` -> `ChangeSetPlan(CreateProjection)`; +- `add email` -> append-version plus add-field operations; +- `/compile json-schema` -> `CompilePlan`; +- ambiguous `create a projection` -> `ClarificationPlan`. + +It returns JSON plans only and never renders `.mdl`. Repair mode detects +`"Previous response validation error"` in the request and returns the valid +plan on the next call. + +- [ ] **Step 4: Replace browser heuristic test activation** + +Use `SimulatorProvider` only when the explicit test query parameter selects it. +Without WebGPU, production still supports deterministic engine questions but +does not pretend the simulator is a real model. + +- [ ] **Step 5: Run focused web and engine tests** + +Run: + +```powershell +cd web +npm test -- --run src/ai src/App.test.tsx +cd ..\cli +uv run pytest tests/test_conversation_engine.py tests/test_conversation_plan.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Run mandatory gates and commit** + +Run the four required `cli/` commands, then: + +```powershell +git add cli/tests/support/conversation_simulator.py ` + cli/tests/support/__init__.py ` + cli/tests/test_conversation_engine.py ` + web/src/ai/simulator-provider.ts ` + web/src/ai/simulator-provider.test.ts ` + web/src/ai/heuristic-provider.ts ` + web/src/ai/heuristic-provider.test.ts ` + web/src/App.tsx ` + web/src/App.test.tsx +git commit -m "test: add semantic conversation simulator" +``` + +--- + +### Task 6: Implement the In-Memory Browser Conversation Backend + +**Files:** +- Create: `cli/src/modelable/browser/conversation.py` +- Modify: `cli/src/modelable/browser/api.py` +- Modify: `cli/src/modelable/browser/dto.py` +- Modify: `cli/src/modelable/browser/__init__.py` +- Modify: `cli/scripts/build_browser_wheel.py` +- Test: `cli/tests/test_browser_conversation.py` +- Test: `cli/tests/test_browser_packaging.py` + +**Interfaces:** +- Consumes: `ConversationEngine`, `ConversationBackend`, and resumable planning. +- Produces: `BrowserConversationService.turn()`, `resume()`, `apply()`, `discard()`, and `reset()`. +- Produces: `BrowserConversationPendingResult` and `BrowserConversationReply`. + +- [ ] **Step 1: Write failing browser service tests** + +Cover a complete projection flow: + +```python +def valid_projection_plan() -> dict[str, object]: + return { + "kind": "change_set", + "summary": "Add a billing projection for Customer", + "operations": [ + { + "kind": "create_projection", + "source_ref": "customer.Customer", + "consumer_domain": "billing", + "name": "CustomerProjection", + "fields": ["id", "name"], + } + ], + } + + +def test_browser_conversation_previews_and_applies_projection(): + compiler = browser_compiler_with_customer_and_billing() + service = BrowserConversationService(compiler) + + pending = service.turn( + session_id="session-1", + workspace_revision=1, + message="Suggest a projection for billing", + active_document_uri=CUSTOMER_URI, + line=3, + character=10, + ) + preview = service.resume( + session_id="session-1", + request_id=pending.request_id, + workspace_revision=1, + content=json.dumps(valid_projection_plan()), + ) + applied = service.apply( + session_id="session-1", + action_id=preview.change_set_id, + workspace_revision=1, + ) + + assert preview.kind == "preview" + assert "projection" in preview.preview_files[0].after_text + assert applied.workspace_revision == 2 +``` + +Add tests for focus resolution, clarification with no focus, stale revision, +late completion, repair, source-change replacement, compilation artifact +promotion, discard, reset, and a bounded session registry. + +- [ ] **Step 2: Run tests and confirm failure** + +Run: + +```powershell +cd cli +uv run pytest tests/test_browser_conversation.py -v +``` + +Expected: browser conversation service is missing. + +- [ ] **Step 3: Retain exact synchronized sources in `BrowserCompiler`** + +Store the latest validated `BrowserSource` tuple on `open_workspace()`. Expose a +read-only `sources` property. Updating the browser workspace must continue to +advance `LanguageWorkspace.revision` through the existing synchronization +path. + +- [ ] **Step 4: Implement `BrowserConversationBackend`** + +For source previews: + +- convert browser URIs to safe relative logical `Path` identifiers; +- load a pathful in-memory `Workspace`; +- call the real `WorkspaceEditor.preview()` only; +- retain `PendingChangeSet` without invoking filesystem apply; and +- on apply, restage against current hashes, update the compiler's sources, and + synchronize the next workspace revision. + +For compilation: + +- call existing in-memory browser compilation for the requested target; +- retain exact artifact content and hashes; +- return those bytes only after matching Apply; and +- discard without modifying output state. + +- [ ] **Step 5: Implement bounded session and completion state** + +Use a maximum of 32 sessions and 30-minute monotonic idle expiry, matching the +LSP service. Each session owns one engine, one active completion request, and +one pending action. Reset or expiry invalidates all three. + +- [ ] **Step 6: Include browser-safe modules in the wheel** + +Add: + +```text +llm/conversation_backend.py +llm/conversation_engine.py +llm/conversation_plan.py +llm/conversation_planner.py +llm/provider_types.py +llm/workspace_editor.py +llm/workspace_query.py +``` + +Include `workspace_editor.py`, but have the browser backend call only its pure +`preview()` path. Add a browser test that replaces `WorkspaceEditor.apply()` +with a function that fails immediately, proving browser preview/apply never +uses the filesystem mutation path. Keep the forbidden-import scan unchanged. + +- [ ] **Step 7: Run browser Python tests** + +Run: + +```powershell +cd cli +uv run pytest tests/test_browser_conversation.py ` + tests/test_browser_ai.py ` + tests/test_browser_packaging.py ` + tests/test_workspace_editor.py -v +``` + +Expected: PASS. + +- [ ] **Step 8: Run mandatory gates and commit** + +Run the four required `cli/` commands, then: + +```powershell +git add cli/src/modelable/browser/conversation.py ` + cli/src/modelable/browser/api.py ` + cli/src/modelable/browser/dto.py ` + cli/src/modelable/browser/__init__.py ` + cli/scripts/build_browser_wheel.py ` + cli/tests/test_browser_conversation.py ` + cli/tests/test_browser_packaging.py +git commit -m "feat: add browser conversation backend" +``` + +--- + +### Task 7: Add Browser Conversation Protocol and Schema-Constrained WebLLM + +**Files:** +- Modify: `cli/src/modelable/browser/dispatch.py` +- Modify: `cli/tests/test_browser_conversation.py` +- Modify: `web/src/protocol.ts` +- Modify: `web/src/protocol.test.ts` +- Modify: `web/src/client.ts` +- Modify: `web/src/client.test.ts` +- Modify: `web/src/ai/ai.worker.ts` +- Modify: `web/src/ai/webgpu-provider.test.ts` +- Modify: `web/src/ai/types.ts` + +**Interfaces:** +- Consumes: browser service methods from Task 6. +- Produces: `conversation.turn`, `conversation.resume`, `conversation.apply`, `conversation.discard`, and `conversation.reset`. +- Produces: `BrowserCompilerClient.conversationTurn()` and lifecycle methods. + +- [ ] **Step 1: Write failing strict protocol tests** + +Add Python and TypeScript fixtures for: + +```json +{ + "status": "pending_llm", + "session_id": "session-1", + "request_id": "request-1", + "attempt": 0, + "llm_request": { + "system": "Return a plan.", + "user": "Create an invoice.", + "temperature": 0.1, + "response_format": "json", + "schema": {"type": "object"} + } +} +``` + +Assert exact keys, request ID reuse rejection, workspace revision checks, and +common final reply validation. + +- [ ] **Step 2: Write a failing WebLLM schema test** + +Extract completion request construction as `createWebLlmCompletionRequest()` and +assert: + +```typescript +expect(createWebLlmCompletionRequest(request).response_format).toEqual({ + type: 'json_object', + schema: request.schema, +}); +``` + +- [ ] **Step 3: Run focused tests and confirm failure** + +Run: + +```powershell +cd cli +uv run pytest tests/test_browser_conversation.py -k "dispatch" -v +cd ..\web +npm test -- --run src/protocol.test.ts src/client.test.ts src/ai/webgpu-provider.test.ts +``` + +Expected: conversation methods and schema forwarding are absent. + +- [ ] **Step 4: Implement strict Python dispatch methods** + +Validate exact fields for every method. `conversation.resume` must require +`sessionId`, `requestId`, `workspaceRevision`, and `llmResponseContent`. +Serialize schemas without altering keys or values. + +- [ ] **Step 5: Implement TypeScript protocol types and guards** + +Add discriminated `BrowserConversationPendingResult` and +`BrowserConversationReply` types. Keep pending LLM and final replies distinct; +do not accept unknown reply kinds or extra protocol fields. + +- [ ] **Step 6: Implement the TypeScript completion loop** + +`conversationTurn()` sends the initial request and loops: + +```typescript +while (isBrowserConversationPendingResult(result)) { + const response = await provider.complete(toLlmRequest(result.llm_request)); + result = await this.conversationResume({ + sessionId, + requestId: result.request_id, + workspaceRevision, + llmResponseContent: response.content, + }); +} +return result; +``` + +The loop accepts an `AbortSignal`, stops on reset/dispose, and records provider +metadata only in the UI-side message. + +- [ ] **Step 7: Pass schemas to WebLLM** + +For JSON requests with a schema, send: + +```typescript +response_format: { + type: 'json_object', + schema: request.schema, +} +``` + +For generic JSON without a schema, send `{ type: 'json_object' }`. Text +explanation replies no longer need a separate raw-source path; grounded +deterministic answers come from Python. + +- [ ] **Step 8: Run Python and web protocol tests** + +Run: + +```powershell +cd cli +uv run pytest tests/test_browser_conversation.py tests/test_browser_ai.py -v +cd ..\web +npm test -- --run src/protocol.test.ts src/client.test.ts src/ai +npm run check +``` + +Expected: PASS. + +- [ ] **Step 9: Run mandatory gates and commit** + +Run the four required `cli/` commands, then: + +```powershell +git add cli/src/modelable/browser/dispatch.py ` + cli/tests/test_browser_conversation.py ` + web/src/protocol.ts ` + web/src/protocol.test.ts ` + web/src/client.ts ` + web/src/client.test.ts ` + web/src/ai/ai.worker.ts ` + web/src/ai/webgpu-provider.test.ts ` + web/src/ai/types.ts +git commit -m "feat: bridge browser conversations to webllm" +``` + +--- + +### Task 8: Move the Playground Chat UI onto the Shared Engine + +**Files:** +- Modify: `web/src/editor/types.ts` +- Modify: `web/src/editor/SourceEditor.tsx` +- Modify: `web/src/editor/SourceEditor.test.tsx` +- Modify: `web/src/ai/chat-types.ts` +- Modify: `web/src/ai/ChatPanel.tsx` +- Modify: `web/src/ai/ChatPanel.test.tsx` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.test.tsx` +- Modify: `web/src/style.css` + +**Interfaces:** +- Consumes: `BrowserCompilerClient.conversationTurn()` and lifecycle methods from Task 7. +- Produces: free-form shared chat, focused shortcuts, common preview rendering, apply/discard/reset, and artifact promotion. + +- [ ] **Step 1: Add failing editor-focus tests** + +Extend `SourceEditorHandle`: + +```typescript +getPosition(): { + uri: string; + line: number; + character: number; +} | null; +``` + +Test that it returns the active Monaco model URI and zero-based cursor position, +and returns `null` before editor initialization. + +- [ ] **Step 2: Add failing App conversation tests** + +Cover: + +```typescript +test('free-form chat calls conversationTurn instead of aiGenerate', async () => { + await sendChat('Create an invoice'); + expect(client.conversationTurn).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Create an invoice' }), + expect.anything(), + expect.anything(), + ); + expect(client.aiGenerate).not.toHaveBeenCalled(); +}); + +test('suggest projection sends focused cursor context', async () => { + await user.click(screen.getByRole('button', { name: 'Suggest projection' })); + expect(client.conversationTurn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Suggest a projection for the focused model', + activeDocumentUri: 'file:///customer.mdl', + position: { line: 3, character: 8 }, + }), + expect.anything(), + expect.anything(), + ); +}); +``` + +Add tests for clarification, multi-file preview, refinement, exact Apply, +Discard, Reset, stale reply, provider error, and compilation artifact +promotion. + +- [ ] **Step 3: Run UI tests and confirm failure** + +Run: + +```powershell +cd web +npm test -- --run src/editor/SourceEditor.test.tsx src/ai/ChatPanel.test.tsx src/App.test.tsx +``` + +Expected: cursor API and conversation UI behavior are absent. + +- [ ] **Step 4: Expose cursor position** + +Read `editorRef.current?.getPosition()` and the active model URI from Monaco. +Convert Monaco's one-based line/column to zero-based protocol values. + +- [ ] **Step 5: Replace browser-specific chat message variants** + +Use one assistant message type carrying: + +- common reply kind and text; +- pending status; +- action ID and operation kind; +- preview files; +- compilation files; +- diagnostics; +- provider/model metadata; and +- accepted/discarded outcome. + +Render Markdown for textual replies, exact diffs for source previews, artifact +lists for compilation previews, and context-appropriate lifecycle buttons. + +- [ ] **Step 6: Route chat and shortcuts through the conversation client** + +Free-form send uses the exact entered message. Shortcuts submit: + +- Explain: `Describe the focused definition or workspace`. +- Generate: retain the user's natural-language description. +- Suggest Projection: `Suggest a projection for the focused model`. + +Do not construct `sourceRef` or `consumerDomain` in TypeScript. Python resolves +focus and clarifies consumer intent. + +- [ ] **Step 7: Apply exact source and artifact results** + +For source changes, update every returned virtual document in one workspace +state transition and synchronize the returned workspace revision. For +compilation, promote returned exact artifacts into the existing output panel +and download collection. Never regenerate during Apply. + +- [ ] **Step 8: Remove obsolete browser AI methods** + +After all UI tests use conversation methods, remove `ai.generate`, +`ai.explain`, `runAiGenerate`, `runAiExplain`, and raw-source result types from +the browser protocol and client. Retain a migration assertion that no web source +references those method names. + +- [ ] **Step 9: Run complete web unit, type, and build gates** + +Run: + +```powershell +cd web +npm test +npm run check +npm run build +``` + +Expected: all pass. + +- [ ] **Step 10: Run mandatory gates and commit** + +Run the four required `cli/` commands, then: + +```powershell +git add web/src/editor/types.ts ` + web/src/editor/SourceEditor.tsx ` + web/src/editor/SourceEditor.test.tsx ` + web/src/ai/chat-types.ts ` + web/src/ai/ChatPanel.tsx ` + web/src/ai/ChatPanel.test.tsx ` + web/src/App.tsx ` + web/src/App.test.tsx ` + web/src/style.css ` + web/src/client.ts ` + web/src/protocol.ts ` + cli/src/modelable/browser/dispatch.py +git commit -m "feat: align playground conversations" +``` + +--- + +### Task 9: Add Cross-Surface and Browser End-to-End Coverage + +**Files:** +- Modify: `web/tests/ai-actions.spec.ts` +- Modify: `web/tests/helpers.ts` +- Modify: `cli/tests/test_conversation_engine.py` +- Modify: `cli/tests/test_lsp_conversation_integration.py` +- Modify: `vscode/src/test/suite/conversation.test.ts` + +**Interfaces:** +- Consumes: the shared engine, filesystem adapter, browser adapter, and simulator. +- Produces: parity assertions across CLI, VS Code, and browser. + +- [ ] **Step 1: Add failing browser conversation scenarios** + +Replace heuristic tests with simulator-backed scenarios: + +- grounded workspace question; +- create entity preview/apply; +- focused projection preview/apply with canonical braces; +- ambiguous projection clarification; +- refine a pending model change; +- discard without mutation; +- compile JSON Schema preview/apply/download; +- malformed-first response repaired; +- reset invalidates pending state; and +- stale workspace blocks Apply. + +For source generation, assert compiler validation succeeds after Apply rather +than checking only visible text. + +- [ ] **Step 2: Add a common semantic parity table** + +In Python, parameterize expected reply kinds: + +```python +@pytest.mark.parametrize( + ("message", "expected_kind"), + [ + ("Describe the workspace", "answer"), + ("Create an invoice", "preview"), + ("Suggest a projection for billing", "preview"), + ("Create a projection", "clarification"), + ("/compile json-schema", "preview"), + ], +) +def test_engine_reply_kind_parity(message, expected_kind, engine_driver): + assert engine_driver.turn(message).kind == expected_kind +``` + +Run the same cases through filesystem and in-memory backend fixtures. + +- [ ] **Step 3: Extend VS Code tests without changing its interface** + +Assert the VS Code participant still renders shared reply text, exact diffs, +Apply/Discard follow-ups, and compilation previews after the engine refactor. + +- [ ] **Step 4: Run all conversation-focused suites** + +Run: + +```powershell +cd cli +uv run pytest tests/test_conversation_engine.py ` + tests/test_conversation.py ` + tests/test_browser_conversation.py ` + tests/test_lsp_conversation_integration.py -v +cd ..\web +npx playwright test tests/ai-actions.spec.ts --project=chromium +cd ..\vscode +npm run check +npm run build +npm test +``` + +Expected: PASS. + +- [ ] **Step 5: Run mandatory gates and commit** + +Run the four required `cli/` commands, then: + +```powershell +git add web/tests/ai-actions.spec.ts ` + web/tests/helpers.ts ` + cli/tests/test_conversation_engine.py ` + cli/tests/test_lsp_conversation_integration.py ` + vscode/src/test/suite/conversation.test.ts +git commit -m "test: verify cross-surface conversation parity" +``` + +--- + +### Task 10: Add Opt-In Ollama Conformance, Documentation, and Final Verification + +**Files:** +- Create: `cli/tests/test_ollama_conversation_conformance.py` +- Modify: `cli/pyproject.toml` +- Modify: `docs/maintainers.md` +- Modify: `docs/cli-reference.md` +- Modify: `docs/playground-design.md` +- Modify: `vscode/README.md` +- Modify: `docs/superpowers/specs/2026-07-27-shared-conversation-engine-design.md` +- Modify: `docs/superpowers/plans/2026-07-27-shared-conversation-engine.md` + +**Interfaces:** +- Consumes: the complete shared conversation implementation. +- Produces: opt-in real-model verification and accurate public/maintainer documentation. + +- [ ] **Step 1: Write the opt-in Ollama conformance module** + +Register: + +```toml +"ollama: requires a developer-controlled local Ollama server and model (opt-in via MODELABLE_OLLAMA_TESTS=1)" +``` + +Skip unless `MODELABLE_OLLAMA_TESTS=1`. Require +`MODELABLE_OLLAMA_MODEL`; use `MODELABLE_LLM_BASE_URL` or +`http://127.0.0.1:11434`. + +Test entity creation, grounded projection, update, clarification, and repair. +Assertions must validate typed plans and canonical rendered source, not exact +prose or field ordering beyond semantic requirements. + +- [ ] **Step 2: Run conformance against the local server** + +Run: + +```powershell +cd cli +$env:MODELABLE_OLLAMA_TESTS='1' +$env:MODELABLE_OLLAMA_MODEL='qwen2.5-coder:14b' +uv run pytest tests/test_ollama_conversation_conformance.py -v +``` + +Expected: every case either produces the required valid semantic result or the +documented bounded actionable failure. Tests must not download or mutate +models. + +- [ ] **Step 3: Update user and maintainer documentation** + +Document: + +- one shared engine and interface-specific adapters; +- browser session-only history; +- schema-constrained WebLLM planning; +- focused projection clarification; +- exact source and artifact previews; +- simulator-based browser tests; +- opt-in Ollama commands and environment variables; and +- the absence of an Ollama web provider. + +Keep the design and plan status active throughout the implementation PR. After +that PR merges, archive both files in the required follow-up on `main`. + +- [ ] **Step 4: Run doc/spec review** + +Run structural, cross-reference, coverage, and quality review across every +changed `docs/` file. Include `Doc/spec review: all phases passed` in the PR +body or list every warning. + +- [ ] **Step 5: Run full CLI gates** + +From `cli/`: + +```powershell +uv run ruff format . +uv run ruff check . +uv run python ../.github/scripts/check_mypy_baseline.py --baseline mypy-baseline.txt -- uv run mypy src/modelable --no-error-summary --show-error-codes +uv run pytest --tb=short +``` + +Expected: all pass. + +- [ ] **Step 6: Run full web gates** + +From `web/`: + +```powershell +npm test +npm run check +npm run build +npx playwright test --project=chromium +npm run check:budgets +``` + +Expected: all pass. + +- [ ] **Step 7: Run full VS Code gates** + +From `vscode/`: + +```powershell +npm run check +npm run build +npm test +npm run package +``` + +Expected: all pass. + +- [ ] **Step 8: Verify obsolete paths and repository state** + +Run: + +```powershell +rg -n "ai\\.generate|ai\\.explain|HeuristicProvider|llmResponseContent" web/src cli/src/modelable/browser +git diff --check +git status -sb +``` + +Expected: no obsolete browser AI path remains, diff check passes, and only +intended files are modified. + +- [ ] **Step 9: Commit final docs and conformance tests** + +```powershell +git add cli/tests/test_ollama_conversation_conformance.py ` + cli/pyproject.toml ` + docs/maintainers.md ` + docs/cli-reference.md ` + docs/playground-design.md ` + vscode/README.md ` + docs/superpowers/specs/2026-07-27-shared-conversation-engine-design.md ` + docs/superpowers/plans/2026-07-27-shared-conversation-engine.md +git commit -m "docs: document shared conversation engine" +``` + +--- + +## Completion Checklist + +- [ ] CLI, VS Code, and browser use the same planner and conversation engine. +- [ ] Browser free-form chat no longer routes unconditionally to entity generation. +- [ ] Suggest Projection grounds focus or returns clarification. +- [ ] WebLLM and Ollama receive the full closed JSON schema. +- [ ] LLM responses contain typed plans rather than raw Modelable source. +- [ ] Python renders and validates canonical source. +- [ ] Filesystem and browser adapters promote exact staged effects. +- [ ] Simulator tests cover normal, repair, clarification, and failure paths. +- [ ] Opt-in Ollama conformance covers entity, projection, update, clarification, and repair. +- [ ] CLI, web, browser E2E, VS Code, package, and budget gates pass. +- [ ] Doc/spec review passes. +- [ ] Design and plan remain active until the implementation PR merges. diff --git a/docs/superpowers/specs/2026-07-27-shared-conversation-engine-design.md b/docs/superpowers/specs/2026-07-27-shared-conversation-engine-design.md new file mode 100644 index 00000000..f37aaf5c --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-shared-conversation-engine-design.md @@ -0,0 +1,447 @@ +# Shared Conversation Engine — Design + +**Date:** 2026-07-27 + +## Status + +Approved in design and written-spec review on 2026-07-27. Implementation is +tracked in the [Shared Conversation Engine plan](../plans/2026-07-27-shared-conversation-engine.md). + +This design supersedes the browser-specific raw-source generation path from the +shipped [Playground Local AI design](archived/2026-07-22-playground-local-ai-design.md) +while preserving its local-first, preview-before-apply, and explicit model +download constraints. It aligns the playground with the shipped +[VS Code Conversational Foundation](archived/2026-07-18-vscode-conversational-foundation-design.md) +and the CLI conversation surface. + +## Summary + +Modelable will use one Python-owned conversation engine across the CLI, VS Code, +and browser playground. The engine will own typed planning, history, focused +definitions, validation, preview state, refinement, and apply/discard +semantics. Each interface will provide only its platform-specific transport, +presentation, and effect adapters. + +The browser will stop asking WebLLM to generate raw `.mdl` source. Models will +return closed, schema-constrained conversation plans. Python will validate those +plans and render canonical Modelable source, so punctuation and brace correctness +are no longer delegated to a probabilistic model. + +## Context and Current Failure Modes + +The shipped conversation surfaces have diverged: + +- CLI and VS Code share `ConversationSession`, the closed conversation-plan + schema, bounded response repair, validated previews, and explicit + apply/discard behavior. +- The playground uses separate `ai.generate` and `ai.explain` methods. Every + free-form chat message is routed as `generate_entity`. +- The playground's Suggest Projection shortcut sends empty parameters even + though Python requires both `sourceRef` and `consumerDomain`. +- Browser generation requests use unconstrained text output and expect the + model to produce complete `.mdl` source. +- The browser heuristic provider echoes prompts rather than simulating useful + conversation behavior. +- `LlmRequest.schema` exists in the TypeScript provider contract but is not + carried through the browser protocol or passed to WebLLM. +- The Ollama adapter requests generic JSON mode instead of passing the closed + schema already supplied by the shared planner. + +An empirical run of the current browser prompts against the local Ollama server +confirmed that this is a contract problem rather than a WebLLM-only rendering +bug. Both `phi3.5:latest` and `qwen2.5-coder:14b` returned invalid Modelable for +entity or projection generation. The outputs omitted required domain structure, +used non-Modelable field syntax, or invented projection grammar. + +## Goals + +- Use one canonical conversation engine and feature model across CLI, VS Code, + and the playground. +- Keep interface-specific presentation and effects outside the engine. +- Replace raw-source LLM generation with closed typed plans and canonical + Python rendering. +- Support the same grounded questions, source changes, projection changes, + clarification, refinement, compilation, preview, apply, and discard lifecycle + on all three surfaces. +- Preserve browser-local execution through Pyodide and WebLLM. +- Support asynchronous browser inference through a resumable planning protocol. +- Pass full JSON schemas to providers that support constrained generation. +- Provide a deterministic semantic simulator for normal tests. +- Provide opt-in real-model conformance tests through a developer-controlled + local Ollama server. +- Preserve explicit user confirmation for every mutating or artifact-promoting + action. + +## Non-Goals + +- Expose Ollama as a selectable provider in the web UI. +- Persist browser conversation history across reloads. +- Stream generated tokens. +- Add remote operations, autonomous tools, registry actions, publishing, or + deployment. +- Make exact natural-language wording identical across interfaces. +- Change Modelable parsing, validation, compatibility, or compilation + semantics. +- Make browser code emulate a filesystem merely to reuse filesystem-specific + implementation details. + +## Design Principles + +1. **One semantic engine, multiple adapters.** Planning and lifecycle behavior + must not be reimplemented in TypeScript or per interface. +2. **Models propose typed intent, not source text.** Python owns source syntax, + rendering, and validation. +3. **Preview exact effects before confirmation.** Apply must promote the exact + staged source or artifacts that the user reviewed. +4. **Platform effects remain explicit.** Filesystem writes, Monaco updates, + downloads, LSP requests, and terminal rendering stay outside the core. +5. **Real-model tests supplement deterministic contracts.** Normal tests must + remain fast and reproducible. + +## Architecture Decision Scope + +No ADR change is required. This design preserves the existing architectural +decisions that Python owns Modelable semantics, validation, planning, and +workspace mutation while TypeScript interfaces own presentation and transport. +It also preserves local browser inference and explicit confirmation boundaries. +The work extracts a shared application-service boundary and adds environment +adapters; it does not introduce a new system dependency, persistence model, +deployment topology, or trust boundary. + +## Architecture + +```text +CLI terminal ───────────────┐ +VS Code chat + LSP ─────────┼──> Conversation Engine +Browser chat + Pyodide ─────┘ | + +--> typed planner + +--> query service + +--> preview lifecycle + +--> common replies + | + environment adapter ports + | | + filesystem adapter browser adapter +``` + +The implementation will separate three layers. + +### Resumable planning + +The planning layer builds the existing closed `ConversationPlan` request and +validates provider responses. It will expose a resumable state machine: + +```text +begin(message, context) + -> ConversationPlan + -> PendingCompletion(request_id, llm_request, attempt) + +resume(request_id, response_content) + -> ConversationPlan + -> PendingCompletion(repair_request_id, llm_request, next_attempt) +``` + +The existing synchronous `ConversationPlanner.plan()` API remains as a driver +that loops over this state machine with a synchronous `LLMProvider`. This +preserves CLI and VS Code behavior. The browser pauses at +`PendingCompletion`, invokes WebLLM outside Pyodide, and resumes the same +planning operation. + +Pending request IDs are opaque, session-scoped, and single-use. A response for +an unknown, superseded, or already-consumed request is rejected. + +### Conversation engine + +The engine owns: + +- session history; +- focused definition; +- pending completion state; +- pending source-change or compilation state; +- deterministic command routing; +- typed plan execution; +- clarification and unsupported replies; +- refinement and replacement rules; +- preview identity; +- stale-state checks; +- apply and discard transitions; and +- the common structured reply. + +The engine does not own: + +- network calls; +- filesystem paths or writes; +- LSP session registration; +- Monaco models or browser storage; +- download behavior; +- terminal output; or +- VS Code response rendering. + +### Environment ports + +The engine depends on narrow interfaces for workspace effects. + +The workspace port provides: + +- current versioned documents; +- a semantic workspace and summary; +- focused-definition resolution; +- deterministic queries; +- source-change preview; +- exact preview fingerprints; +- source-change promotion; and +- workspace refresh after promotion. + +The compilation port provides: + +- exact artifact staging; +- artifact metadata and previews; +- freshness verification; +- promotion; and +- cleanup or discard. + +The filesystem adapter will wrap the existing `WorkspaceEditor`, +`CompilationService`, file transactions, audit records, and reload behavior. +The browser adapter will operate on versioned in-memory documents and staged +artifact bytes. It will not write a virtual filesystem as an intermediate +effect. + +## Shared Features and Interface Differences + +All interfaces expose: + +- grounded workspace summary, ownership, lineage, dependents, indexes, + compatibility, and validation questions; +- create model and create projection requests; +- append-version and draft updates; +- clarification when ownership, identity, reusable-model, or projection-source + intent is ambiguous; +- refinement or replacement of a pending proposal; +- local compilation requests; +- validated previews; +- explicit apply and discard; +- typed unsupported and error replies; and +- deterministic commands that work without an LLM. + +### CLI + +The CLI retains synchronous provider calls, terminal rendering, filesystem +previews, exact writes, and compilation audits. + +### VS Code + +VS Code retains its thin-client role. The LSP adapter owns workspace selection, +dirty-buffer checks, session registration, editor anchors, and diff documents. + +### Browser + +The browser interface will: + +- route free-form messages through the shared engine; +- keep Generate, Explain, and Suggest Projection as intent shortcuts rather + than separate semantic operations; +- resolve focus from the active document and cursor through Python language + services; +- preview source changes as exact before/after virtual documents; +- apply source changes to the versioned in-memory workspace; +- preview compilation artifacts as exact staged bytes; +- promote accepted compilation results to the output panel and download + collection; +- retain conversation history for the current page session; and +- expose provider/model metadata without persisting prompt contents. + +Suggest Projection will ask for clarification when source or consumer domain +cannot be grounded. It will never call model-summary construction with an empty +reference. + +## Browser Conversation Protocol + +The browser's bespoke `ai.generate` and `ai.explain` flow will be replaced by: + +- `conversation.turn` +- `conversation.resume` +- `conversation.apply` +- `conversation.discard` +- `conversation.reset` + +`conversation.turn` includes: + +- session ID; +- workspace revision; +- message; +- active document URI; and +- optional zero-based cursor position. + +It returns either a common conversation reply or a pending completion: + +```json +{ + "status": "pending_llm", + "sessionId": "session-id", + "requestId": "request-id", + "attempt": 0, + "llmRequest": { + "system": "…", + "user": "…", + "temperature": 0.1, + "responseFormat": "json", + "schema": {} + } +} +``` + +`conversation.resume` carries the session ID, request ID, workspace revision, +and provider response content. It may return a repair request through the same +pending shape. + +Apply and discard require the current pending-action ID. Apply also requires the +workspace revision and exact preview fingerprints. Reset invalidates pending +provider requests and staged actions. + +## Provider Contract + +`LLMRequest.schema` becomes end-to-end rather than advisory. + +- WebLLM receives `response_format: {type: "json_object", schema}`. +- Ollama receives the full schema object in `/api/chat`'s `format` field rather + than the string `"json"`. +- Providers without native schema constraints receive the schema in the system + prompt and remain subject to the same parser and repair loop. +- The engine always validates provider output independently, even when the + provider claims schema conformance. + +The structured plan contains typed operations such as `create_model`, +`create_projection`, and `add_projection_field`. It never contains raw source +patches or unrestricted paths. Python renders source through the canonical +compiler renderer. + +## Error Handling + +- Provider transport failures produce typed actionable replies and preserve + valid session history. +- Invalid structured output triggers the configured bounded repair loop. +- Exhausted repairs report a concise validation summary without exposing + prompts, workspace source, or provider internals. +- Missing or ambiguous projection context produces clarification. +- Workspace revision or fingerprint changes invalidate stale previews. +- Cancellation invalidates the active pending request and cannot leave a + half-applied action. +- Duplicate or late completion responses are rejected. +- Preview promotion is atomic within each environment adapter. +- Adapter failures do not allow replies to claim that source or artifacts were + promoted. + +## Deterministic Simulator + +The existing heuristic prompt echo will be replaced with a semantic simulator. +It will inspect the typed request contract and return deterministic plans for: + +- workspace questions; +- entity creation; +- projection creation; +- model and projection updates; +- compilation; +- clarification; +- an invalid first response followed by a valid repair; and +- provider failure. + +The simulator is a provider test double, not a second planner. It does not +implement Modelable editing rules. All returned plans still pass through the +real parser, engine, preview, validation, and adapter lifecycle. + +## Testing Strategy + +### Shared contract tests + +Run one engine behavior suite against filesystem and in-memory adapters: + +- deterministic query without provider; +- entity and projection creation; +- model and projection refinement; +- pending-action replacement; +- compilation preview and promotion; +- apply/discard identity checks; +- stale workspace rejection; +- clarification; +- provider failure; +- malformed response and repair; and +- cancellation cleanup. + +### Provider tests + +- WebLLM worker tests assert that the full schema reaches + `response_format`, response content is preserved, repair requests are + resumable, and disposal rejects pending requests. +- Ollama unit tests assert that the full schema is sent in `format`. +- Parser tests validate closed schemas independently of provider behavior. + +### Interface tests + +- CLI tests retain terminal and filesystem assertions. +- VS Code tests retain LSP lifecycle, dirty-buffer, diff, and rendering + assertions. +- Browser unit and Playwright tests use the simulator to cover chat, focus, + projection creation, clarification, refinement, preview, apply, discard, + compilation artifacts, and reset. + +### Opt-in real-model conformance + +Ollama tests run only when explicitly enabled and choose the model through an +environment variable. They assert semantic outcomes rather than exact prose: + +- a valid typed entity-creation plan; +- a valid typed projection plan with grounded source mappings; +- a valid update plan; +- appropriate clarification for ambiguity; and +- successful repair or an actionable bounded failure. + +The suite never downloads models and is excluded from normal CI. A separate +optional WebGPU smoke may exercise a real WebLLM model locally; normal CI does +not require WebGPU or a model download. + +## Security and Privacy + +- Browser inference remains local through WebLLM. +- Ollama conformance uses only a developer-controlled local endpoint. +- No provider credentials are introduced into the playground. +- Provider responses remain untrusted and pass through closed-schema + validation. +- Raw patches, arbitrary filesystem paths, shell commands, and remote + operations remain outside the plan vocabulary. +- Mutating actions require explicit confirmation. +- Logs contain request IDs, timing, provider/model identity, and error classes, + but not prompts, source contents, or model responses. + +## Migration + +1. Extract resumable planning while preserving the synchronous planner API and + existing CLI/VS Code behavior. +2. Introduce the environment ports and move filesystem-specific effects behind + the filesystem adapter. +3. Add the semantic simulator and shared engine contract suite. +4. Carry the full schema through provider contracts; constrain WebLLM and + Ollama output. +5. Add the in-memory browser adapter and browser conversation protocol. +6. Move web chat and shortcut controls to the shared engine. +7. Add browser, Ollama, and optional WebLLM conformance tests. +8. Remove the obsolete raw-source browser AI path after parity tests pass. + +Each migration step must leave CLI and VS Code behavior green. The browser does +not switch until the shared engine supports source changes, projection +creation, deterministic questions, compilation, preview, apply, discard, and +repair. + +## Acceptance Criteria + +- CLI, VS Code, and browser use the same typed planner and conversation engine. +- Free-form browser chat no longer routes unconditionally to entity generation. +- Browser Suggest Projection grounds or clarifies source and consumer domain. +- WebLLM and Ollama receive the full closed response schema. +- No LLM-generated raw `.mdl` source is applied or previewed. +- Python renders syntactically valid source from validated typed operations. +- All interfaces preserve exact preview-before-apply behavior. +- Browser compilation promotes exact staged artifacts to output/download state. +- The semantic simulator drives deterministic cross-interface tests. +- Opt-in Ollama tests exercise real entity, projection, update, clarification, + and repair behavior. +- Existing CLI and VS Code conversation behavior remains compatible. diff --git a/docs/superpowers/specs/archived/2026-07-18-vscode-conversational-foundation-design.md b/docs/superpowers/specs/archived/2026-07-18-vscode-conversational-foundation-design.md index 3b4eefc9..b97a695d 100644 --- a/docs/superpowers/specs/archived/2026-07-18-vscode-conversational-foundation-design.md +++ b/docs/superpowers/specs/archived/2026-07-18-vscode-conversational-foundation-design.md @@ -2,6 +2,8 @@ **Date:** 2026-07-18 +**Cross-surface alignment:** [Shared Conversation Engine](../2026-07-27-shared-conversation-engine-design.md) + ## 1. Summary This design adds a native `@modelable` chat participant to the existing VS Code diff --git a/docs/superpowers/specs/archived/2026-07-22-playground-local-ai-design.md b/docs/superpowers/specs/archived/2026-07-22-playground-local-ai-design.md index eaa0c970..f937e8c0 100644 --- a/docs/superpowers/specs/archived/2026-07-22-playground-local-ai-design.md +++ b/docs/superpowers/specs/archived/2026-07-22-playground-local-ai-design.md @@ -4,11 +4,13 @@ Shipped on 2026-07-22. +**Superseded conversation architecture:** [Shared Conversation Engine](../2026-07-27-shared-conversation-engine-design.md) + Execution was broken into reviewable tasks in the -[Playground Local AI implementation plan](../plans/2026-07-22-playground-local-ai.md). +[Playground Local AI implementation plan](../../plans/archived/2026-07-22-playground-local-ai.md). This specification defines Phase 6 of the -[Modelable Playground Architecture](../../playground-design.md). It builds on +[Modelable Playground Architecture](../../../playground-design.md). It builds on the shipped multi-file workspace, browser language services, visualization, and analysis views to add local AI-assisted generation and explanation through WebLLM, with validated preview and explicit user acceptance for all mutating diff --git a/vscode/README.md b/vscode/README.md index af100784..d6b3117c 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -46,6 +46,11 @@ model, and projection questions work without a model provider. Creating or updating definitions requires the provider configured for the workspace or CLI environment; the extension adds no separate provider setting. +The participant uses the same Python conversation engine as the CLI and browser +playground. VS Code remains an interface adapter for workspace selection, +native rendering, diffs, and follow-up actions; planning, validation, canonical +source rendering, history, and pending-action identity remain Python-owned. + Modelable selects the workspace containing the active `.mdl` editor. If there is no active model editor, exactly one open folder containing `workspace.mdl` must be available. Multiple candidates are ambiguous, and the participant asks diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 8038134e..d90eeb66 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -153,6 +153,13 @@ vi.mock('./editor/SourceEditor', async () => { version: file?.version ?? 1, }; }, + getPosition() { + return { + uri: `file:///${propsRef.current.activeFile}`, + line: 0, + character: 0, + }; + }, applyFormattedText(path: string, text: string) { sourceEditorSpies.applyFormattedText(path, text); propsRef.current.onContentChange(path, text); @@ -301,8 +308,10 @@ class FakeCompilerClient { findings: [], }), ); - readonly aiGenerate = vi.fn(); - readonly aiExplain = vi.fn(); + readonly conversationTurn = vi.fn(); + readonly conversationApply = vi.fn(); + readonly conversationDiscard = vi.fn(); + readonly conversationReset = vi.fn(); readonly dispose = vi.fn(); } @@ -1444,7 +1453,7 @@ describe('App', () => { await initialize(client); fireEvent.click( - screen.getByRole('button', { name: 'Use heuristic AI' }), + screen.getByRole('button', { name: 'Use simulator' }), ); await waitFor(() => { expect( @@ -1459,17 +1468,31 @@ describe('App', () => { ).toBeTruthy(); }); - test('generate entity sends a chat message and calls aiGenerate', async () => { + test('free-form chat uses the shared conversation engine', async () => { const client = new FakeCompilerClient(); - client.aiGenerate.mockResolvedValue({ - source: 'entity Order {\n v1 {}\n}\n', - diagnostics: [], + client.conversationTurn.mockResolvedValue({ + reply: { + kind: 'preview', + text: 'Previewed Order', + change_set_id: 'change-1', + operation_kind: 'source_change', + focused_ref: null, + preview_files: [{ + path: 'customer.mdl', + existed_before: true, + before_text: '', + after_text: 'entity Order {\n v1 {}\n}\n', + }], + compilation_files: [], + }, + workspace_revision: 1, + sources: [], }); render( client} />); await initialize(client); fireEvent.click( - screen.getByRole('button', { name: 'Use heuristic AI' }), + screen.getByRole('button', { name: 'Use simulator' }), ); await waitFor(() => { expect( @@ -1484,12 +1507,11 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: 'Send' })); await waitFor(() => { - expect(client.aiGenerate).toHaveBeenCalledTimes(1); - }); - expect(client.aiGenerate.mock.calls[0]?.[1]).toBe('generate_entity'); - expect(client.aiGenerate.mock.calls[0]?.[2]).toEqual({ - description: 'Order for e-commerce', + expect(client.conversationTurn).toHaveBeenCalledTimes(1); }); + expect(client.conversationTurn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ message: 'Order for e-commerce' }), + ); await waitFor(() => { expect(screen.getByText('Order for e-commerce')).toBeTruthy(); @@ -1497,16 +1519,26 @@ describe('App', () => { }); }); - test('explain calls aiExplain and shows explanation in chat', async () => { + test('explain uses the shared conversation engine', async () => { const client = new FakeCompilerClient(); - client.aiExplain.mockResolvedValue({ - explanation: 'This model defines a Customer entity.', + client.conversationTurn.mockResolvedValue({ + reply: { + kind: 'answer', + text: 'This model defines a Customer entity.', + change_set_id: null, + operation_kind: null, + focused_ref: null, + preview_files: [], + compilation_files: [], + }, + workspace_revision: 1, + sources: [], }); render( client} />); await initialize(client); fireEvent.click( - screen.getByRole('button', { name: 'Use heuristic AI' }), + screen.getByRole('button', { name: 'Use simulator' }), ); await waitFor(() => { expect( @@ -1517,7 +1549,7 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: 'Explain workspace' })); await waitFor(() => { - expect(client.aiExplain).toHaveBeenCalledTimes(1); + expect(client.conversationTurn).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect( @@ -1528,15 +1560,43 @@ describe('App', () => { test('accept applies generated source to workspace', async () => { const client = new FakeCompilerClient(); - client.aiGenerate.mockResolvedValue({ - source: 'domain commerce {\n entity Order {\n v1 {}\n }\n}\n', - diagnostics: [], + const generated = 'domain commerce {\n entity Order {\n v1 {}\n }\n}\n'; + client.conversationTurn.mockResolvedValue({ + reply: { + kind: 'preview', + text: 'Previewed Order', + change_set_id: 'change-1', + operation_kind: 'source_change', + focused_ref: null, + preview_files: [{ + path: 'customer.mdl', + existed_before: true, + before_text: '', + after_text: generated, + }], + compilation_files: [], + }, + workspace_revision: 1, + sources: [], + }); + client.conversationApply.mockResolvedValue({ + reply: { + kind: 'applied', + text: 'Applied', + change_set_id: 'change-1', + operation_kind: 'source_change', + focused_ref: null, + preview_files: [], + compilation_files: [], + }, + workspace_revision: 2, + sources: [{ uri: 'file:///customer.mdl', text: generated, version: 2 }], }); render( client} />); await initialize(client); fireEvent.click( - screen.getByRole('button', { name: 'Use heuristic AI' }), + screen.getByRole('button', { name: 'Use simulator' }), ); await waitFor(() => { expect( @@ -1560,16 +1620,167 @@ describe('App', () => { expect(editor.value).toContain('entity Order'); }); + test('failed conversation apply preserves the workspace and preview', async () => { + const client = new FakeCompilerClient(); + const generated = 'domain commerce { entity Order @ 1 (additive) {} }'; + client.conversationTurn.mockResolvedValue({ + reply: { + kind: 'preview', + text: 'Previewed Order', + change_set_id: 'change-1', + operation_kind: 'source_change', + focused_ref: null, + preview_files: [{ + path: 'customer.mdl', + existed_before: true, + before_text: '', + after_text: generated, + }], + compilation_files: [], + }, + workspace_revision: 1, + sources: [], + }); + client.conversationApply.mockResolvedValue({ + reply: { + kind: 'error', + text: 'The workspace changed after preview.', + change_set_id: 'change-1', + operation_kind: 'source_change', + focused_ref: null, + preview_files: [], + compilation_files: [], + }, + workspace_revision: 2, + sources: [], + }); + render( client} />); + await initialize(client); + const original = screen.getByLabelText('Model source').value; + + fireEvent.click(screen.getByRole('button', { name: 'Use simulator' })); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Suggest projection' })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Suggest projection' })); + await waitFor(() => { + expect(screen.getByText(/entity Order/)).toBeTruthy(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Accept' })); + + await waitFor(() => { + expect(screen.getByText('The workspace changed after preview.')).toBeTruthy(); + }); + expect(screen.getByLabelText('Model source').value).toBe(original); + expect(screen.queryByText('Accepted')).toBeNull(); + }); + + test('accept promotes conversation compilation artifacts to output', async () => { + const client = new FakeCompilerClient(); + const compilationFile = { + destination: 'dist/customer.schema.json', + media_type: 'application/json', + after_text: '{"type":"object"}', + after_hash: 'abc123', + }; + client.conversationTurn.mockResolvedValue({ + reply: { + kind: 'preview', + text: 'Previewed compilation', + change_set_id: 'compile-1', + operation_kind: 'compile', + focused_ref: null, + preview_files: [], + compilation_files: [compilationFile], + }, + workspace_revision: 1, + sources: [], + }); + client.conversationApply.mockResolvedValue({ + reply: { + kind: 'applied', + text: 'Applied compilation', + change_set_id: 'compile-1', + operation_kind: 'compile', + focused_ref: null, + preview_files: [], + compilation_files: [compilationFile], + }, + workspace_revision: 1, + sources: [], + }); + render( client} />); + await initialize(client); + + fireEvent.click(screen.getByRole('button', { name: 'Use simulator' })); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Suggest projection' })).toBeTruthy(); + }); + fireEvent.change( + screen.getByPlaceholderText('e.g. Add a creditScore field to Customer'), + { target: { value: 'Compile this workspace to JSON Schema' } }, + ); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + expect(screen.getByText('dist/customer.schema.json')).toBeTruthy(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Accept' })); + + await waitFor(() => { + expect(client.conversationApply).toHaveBeenCalledWith( + expect.any(String), + 'compile-1', + 1, + ); + }); + expect( + screen.getByRole('list', { name: 'Generated artifacts' }).textContent, + ).toContain('dist/customer.schema.json'); + }); + + test('/reset clears the shared browser conversation session', async () => { + const client = new FakeCompilerClient(); + client.conversationReset.mockResolvedValue(undefined); + render( client} />); + await initialize(client); + + fireEvent.click(screen.getByRole('button', { name: 'Use simulator' })); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Suggest projection' })).toBeTruthy(); + }); + fireEvent.change( + screen.getByPlaceholderText('e.g. Add a creditScore field to Customer'), + { target: { value: '/reset' } }, + ); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + expect(client.conversationReset).toHaveBeenCalledWith(expect.any(String)); + }); + expect(client.conversationTurn).not.toHaveBeenCalled(); + }); + test('discard closes preview without modifying source', async () => { const client = new FakeCompilerClient(); - client.aiExplain.mockResolvedValue({ - explanation: 'Test explanation', + client.conversationTurn.mockResolvedValue({ + reply: { + kind: 'answer', + text: 'Test explanation', + change_set_id: null, + operation_kind: null, + focused_ref: null, + preview_files: [], + compilation_files: [], + }, + workspace_revision: 1, + sources: [], }); render( client} />); await initialize(client); fireEvent.click( - screen.getByRole('button', { name: 'Use heuristic AI' }), + screen.getByRole('button', { name: 'Use simulator' }), ); await waitFor(() => { expect( diff --git a/web/src/App.tsx b/web/src/App.tsx index 5a011b25..12aed98f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -63,8 +63,7 @@ import { providerStateReducer, } from './ai/provider-state'; import { detectWebGpu, WebGpuProvider } from './ai/webgpu-provider'; -import { HeuristicProvider } from './ai/heuristic-provider'; -import type { AiGenerateAction, AiGenerateParameters } from './ai/types'; +import { SimulatorProvider } from './ai/simulator-provider'; import { generateChatMessageId, isAssistantGenerateMessage, @@ -197,6 +196,7 @@ export function App({ ); const [aiPending, setAiPending] = useState(false); const [chatMessages, setChatMessages] = useState([]); + const conversationSessionIdRef = useRef(crypto.randomUUID()); const sourceEditorRef = useRef(null); const clientRef = useRef(null); const languageControllerRef = @@ -616,8 +616,8 @@ export function App({ useEffect(() => { const params = new URLSearchParams(window.location.search); - if (params.get('ai') === 'heuristic') { - const provider = new HeuristicProvider(); + if (params.get('ai') === 'simulator') { + const provider = new SimulatorProvider(); aiDispatch({ type: 'download_start', provider }); void provider.initialize().then(() => aiDispatch({ type: 'ready' })); return; @@ -651,7 +651,7 @@ export function App({ }, [aiState.status]); const handleAiFallback = useCallback((): void => { - const provider = new HeuristicProvider(); + const provider = new SimulatorProvider(); aiDispatch({ type: 'download_start', provider }); void provider.initialize().then(() => aiDispatch({ type: 'ready' })); }, []); @@ -673,16 +673,13 @@ export function App({ [], ); - const runAiGenerate = useCallback( - ( - action: AiGenerateAction, - parameters: AiGenerateParameters, - userText: string, - ): void => { + const runConversation = useCallback( + (userText: string): void => { const client = clientRef.current; const provider = aiState.provider; if ( client === null || + client.conversationTurn === undefined || provider === null || aiState.status !== 'ready' || state.runtime !== 'ready' || @@ -699,27 +696,61 @@ export function App({ appendMessage({ id: assistantId, role: 'assistant', - kind: action === 'suggest_projection' ? 'generate' : 'generate', + kind: 'explain', diagnostics: [], providerInfo: { provider: provider.id, model: provider.model }, pending: true, }); setAiPending(true); setRightTab('assistant'); - void client - .aiGenerate( - workspaceRef.current.revision, - action, - parameters, - provider, - ) + const position = sourceEditorRef.current?.getPosition() ?? null; + void client.conversationTurn( + { + sessionId: conversationSessionIdRef.current, + workspaceRevision: workspaceRef.current.revision, + message: userText, + activeDocumentUri: position?.uri ?? null, + position: position === null + ? null + : { line: position.line, character: position.character }, + }, + provider, + ) .then( (result) => { - updateAssistantMessage(assistantId, { - source: result.source, - diagnostics: result.diagnostics, - pending: false, - }); + const preview = result.reply.preview_files[0]; + setChatMessages((messages) => + messages.map((message) => { + if (message.role !== 'assistant' || message.id !== assistantId) { + return message; + } + if ( + result.reply.kind === 'preview' && + (preview !== undefined || result.reply.compilation_files.length > 0) + ) { + return { + id: assistantId, + role: 'assistant', + kind: 'generate', + actionId: result.reply.change_set_id ?? undefined, + previewFiles: result.reply.preview_files, + compilationFiles: result.reply.compilation_files, + diagnostics: [], + providerInfo: { provider: provider.id, model: provider.model }, + pending: false, + }; + } + return { + id: assistantId, + role: 'assistant', + kind: 'explain', + explanation: result.reply.text, + diagnostics: [], + providerInfo: { provider: provider.id, model: provider.model }, + pending: false, + }; + }), + ); }, (error: unknown) => { updateAssistantMessage(assistantId, { @@ -730,7 +761,7 @@ export function App({ message: error instanceof Error ? error.message - : 'AI generation failed', + : 'Conversation failed', uri: '', line: null, column: null, @@ -747,81 +778,33 @@ export function App({ [aiState.provider, aiState.status, aiPending, appendMessage, state.runtime, updateAssistantMessage], ); - const runAiExplain = useCallback( - (userText: string): void => { - const client = clientRef.current; - const provider = aiState.provider; - if ( - client === null || - provider === null || - aiState.status !== 'ready' || - state.runtime !== 'ready' || - aiPending - ) { - return; - } - const assistantId = generateChatMessageId(); - appendMessage({ - id: generateChatMessageId(), - role: 'user', - text: userText, - }); - appendMessage({ - id: assistantId, - role: 'assistant', - kind: 'explain', - diagnostics: [], - providerInfo: { provider: provider.id, model: provider.model }, - pending: true, - }); - setAiPending(true); - setRightTab('assistant'); - void client.aiExplain(workspaceRef.current.revision, {}, provider).then( - (result) => { - updateAssistantMessage(assistantId, { - explanation: result.explanation, - pending: false, - }); - }, - (error: unknown) => { - updateAssistantMessage(assistantId, { - diagnostics: [ - { - code: 'AI_ERROR', - severity: 'error', - message: - error instanceof Error - ? error.message - : 'AI explanation failed', - uri: '', - line: null, - column: null, - end_line: null, - end_column: null, - }, - ], - pending: false, - }); - }, - ).finally(() => setAiPending(false)); - }, - [aiState.provider, aiState.status, aiPending, appendMessage, state.runtime, updateAssistantMessage], - ); - const handleChatSend = useCallback( (text: string): void => { - runAiGenerate('generate_entity', { description: text }, text); + if (text.trim() === '/reset') { + const reset = clientRef.current?.conversationReset; + if (reset !== undefined) { + setAiPending(true); + void reset(conversationSessionIdRef.current) + .then(() => { + conversationSessionIdRef.current = crypto.randomUUID(); + setChatMessages([]); + }) + .finally(() => setAiPending(false)); + return; + } + } + runConversation(text); }, - [runAiGenerate], + [runConversation], ); const handleAiExplain = useCallback((): void => { - runAiExplain('Explain the workspace'); - }, [runAiExplain]); + runConversation('Describe the focused definition or workspace'); + }, [runConversation]); const handleAiSuggestProjection = useCallback((): void => { - runAiGenerate('suggest_projection', {}, 'Suggest a projection'); - }, [runAiGenerate]); + runConversation('Suggest a projection for the focused model'); + }, [runConversation]); const markLatestGenerateOutcome = useCallback( (outcome: 'accepted' | 'discarded'): void => { @@ -845,7 +828,119 @@ export function App({ ); const handleAiAccept = useCallback( - (source: string): void => { + (source: string, actionId?: string): void => { + const client = clientRef.current; + if (actionId !== undefined && client?.conversationApply !== undefined) { + void client.conversationApply( + conversationSessionIdRef.current, + actionId, + workspaceRef.current.revision, + ).then((result) => { + if (result.reply.kind !== 'applied') { + setChatMessages((messages) => + messages.map((message) => + message.role === 'assistant' && + message.kind === 'generate' && + message.actionId === actionId + ? { + ...message, + diagnostics: [ + { + code: 'AI_APPLY_ERROR', + severity: 'error', + message: result.reply.text, + uri: '', + line: null, + column: null, + end_line: null, + end_column: null, + }, + ], + } + : message, + ), + ); + return; + } + if (result.reply.operation_kind === 'compile') { + const artifacts = result.reply.compilation_files + .filter((file) => file.after_text !== null) + .map((file) => ({ + path: file.destination, + media_type: file.media_type, + content: file.after_text ?? '', + source_refs: [], + })); + dispatch({ + type: 'operationSucceeded', + operation: 'generate', + revision: workspaceRef.current.revision, + diagnostics: [], + artifacts, + duration: 0, + }); + setRightTab('output'); + markLatestGenerateOutcome('accepted'); + return; + } + const current = workspaceRef.current; + const currentByPath = new Map( + current.files.map((file) => [file.path, file]), + ); + const returnedByPath = new Map( + result.sources.map((item) => [ + decodeURIComponent(new URL(item.uri).pathname.slice(1)), + item, + ]), + ); + for (const item of result.sources) { + const path = decodeURIComponent(new URL(item.uri).pathname.slice(1)); + const existing = currentByPath.get(path); + if (existing?.content !== item.text) { + sourceEditorRef.current?.applyFormattedText(path, item.text); + } + } + const updated = { + ...current, + revision: result.workspace_revision, + files: [...returnedByPath.entries()].map(([path, item]) => ({ + path, + content: item.text, + version: item.version, + })), + }; + replaceWorkspace(updated, true); + markLatestGenerateOutcome('accepted'); + }).catch((error: unknown) => { + setChatMessages((messages) => + messages.map((message) => + message.role === 'assistant' && + message.kind === 'generate' && + message.actionId === actionId + ? { + ...message, + diagnostics: [ + { + code: 'AI_APPLY_ERROR', + severity: 'error', + message: + error instanceof Error + ? error.message + : 'Could not apply conversation preview', + uri: '', + line: null, + column: null, + end_line: null, + end_column: null, + }, + ], + } + : message, + ), + ); + }); + return; + } if (source === '') { return; } @@ -876,6 +971,19 @@ export function App({ const handleAiDiscard = useCallback( (messageId: string): void => { + const message = chatMessages.find((item) => item.id === messageId); + const actionId = + message !== undefined && isAssistantGenerateMessage(message) + ? message.actionId + : undefined; + const client = clientRef.current; + if (actionId !== undefined && client?.conversationDiscard !== undefined) { + void client.conversationDiscard( + conversationSessionIdRef.current, + actionId, + workspaceRef.current.revision, + ); + } setChatMessages((messages) => messages.map((message) => message.role === 'assistant' && message.id === messageId @@ -884,7 +992,7 @@ export function App({ ), ); }, - [], + [chatMessages], ); const retryCompiler = (): void => { diff --git a/web/src/ai/ChatPanel.test.tsx b/web/src/ai/ChatPanel.test.tsx index a901de43..5e0da2f1 100644 --- a/web/src/ai/ChatPanel.test.tsx +++ b/web/src/ai/ChatPanel.test.tsx @@ -130,7 +130,104 @@ test('accepts generated source', async () => { ); await user.click(screen.getByRole('button', { name: 'Accept' })); - expect(onAccept).toHaveBeenCalledWith('entity Order {}'); + expect(onAccept).toHaveBeenCalledWith('entity Order {}', undefined); +}); + +test('previews and accepts compilation artifacts', async () => { + const user = userEvent.setup(); + const onAccept = vi.fn(); + const messages: ChatMessage[] = [ + { + id: '1', + role: 'assistant', + kind: 'generate', + diagnostics: [], + providerInfo: { provider: 'simulator', model: 'semantic-v1' }, + pending: false, + actionId: 'compile-1', + compilationFiles: [ + { + destination: 'dist/customer.schema.json', + media_type: 'application/json', + after_text: '{"type":"object"}', + after_hash: 'abc123', + }, + ], + }, + ]; + render( + , + ); + + expect(screen.getByText('dist/customer.schema.json')).toBeTruthy(); + expect(screen.getByText('{"type":"object"}')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Accept' })); + expect(onAccept).toHaveBeenCalledWith('', 'compile-1'); +}); + +test('renders every source file in a multi-file preview', async () => { + const user = userEvent.setup(); + const onAccept = vi.fn(); + const messages: ChatMessage[] = [ + { + id: '1', + role: 'assistant', + kind: 'generate', + diagnostics: [], + providerInfo: { provider: 'simulator', model: 'semantic-v1' }, + pending: false, + actionId: 'change-1', + previewFiles: [ + { + path: 'a.mdl', + existed_before: true, + before_text: 'domain a {}', + after_text: 'domain a { owner: "a" }', + }, + { + path: 'b.mdl', + existed_before: true, + before_text: 'domain b {}', + after_text: 'domain b { owner: "b" }', + }, + ], + }, + ]; + render( + , + ); + + expect(screen.getByText('a.mdl')).toBeTruthy(); + expect(screen.getByText('b.mdl')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Accept' })); + expect(onAccept).toHaveBeenCalledWith( + 'domain a { owner: "a" }', + 'change-1', + ); }); test('discards a message', async () => { diff --git a/web/src/ai/ChatPanel.tsx b/web/src/ai/ChatPanel.tsx index 1a5fcff4..a2770a97 100644 --- a/web/src/ai/ChatPanel.tsx +++ b/web/src/ai/ChatPanel.tsx @@ -22,7 +22,7 @@ export interface ChatPanelProps { onSend(text: string): void; onExplain(): void; onSuggestProjection(): void; - onAccept(source: string): void; + onAccept(source: string, actionId?: string): void; onDiscard(messageId: string): void; onDownloadModel(): void; onUseHeuristic(): void; @@ -125,7 +125,7 @@ export function ChatPanel({ ) : null} {aiState.status === 'unsupported' || aiState.status === 'error' ? ( ) : null} @@ -179,7 +179,7 @@ export function ChatPanel({ interface ChatMessageItemProps { message: ChatMessage; activeFileContent: string; - onAccept(source: string): void; + onAccept(source: string, actionId?: string): void; onDiscard(messageId: string): void; } @@ -279,14 +279,18 @@ function AssistantGenerateMessageItem({ }: { message: AssistantGenerateChatMessage; activeFileContent: string; - onAccept(source: string): void; + onAccept(source: string, actionId?: string): void; onDiscard(messageId: string): void; }) { const [showDiff, setShowDiff] = useState(false); + const hasSourcePreviews = (message.previewFiles?.length ?? 0) > 0; + const hasCompilation = (message.compilationFiles?.length ?? 0) > 0; return (
-

AI generated source

+

+ {hasCompilation ? 'Compilation preview' : 'AI generated source'} +

{message.providerInfo.provider} / {message.providerInfo.model}

@@ -298,8 +302,26 @@ function AssistantGenerateMessageItem({

Discarded

) : ( <> - {message.source === undefined ? ( -

No source generated

+ {hasSourcePreviews ? ( +
+ {message.previewFiles?.map((file) => ( +
+

{file.path}

+ {showDiff ? ( + + ) : ( + + )} +
+ ))} +
+ ) : message.source === undefined ? ( + !hasCompilation ? ( +

No source generated

+ ) : null ) : showDiff ? (
@@ -307,9 +329,20 @@ function AssistantGenerateMessageItem({ ) : ( )} + {hasCompilation ? ( +
+ {message.compilationFiles?.map((file) => ( +
+

{file.destination}

+
{file.after_text ?? `Binary artifact (${file.after_hash})`}
+
+ ))} +
+ ) : null}
- {message.source !== undefined && activeFileContent !== message.source ? ( + {hasSourcePreviews || + (message.source !== undefined && activeFileContent !== message.source) ? ( diff --git a/web/src/ai/ai.worker.ts b/web/src/ai/ai.worker.ts index e965875c..a2cfe1af 100644 --- a/web/src/ai/ai.worker.ts +++ b/web/src/ai/ai.worker.ts @@ -65,16 +65,7 @@ async function handleComplete(id: string, request: LlmRequest): Promise { } try { - const reply = await engine.chat.completions.create({ - messages: [ - { role: 'system', content: request.system }, - { role: 'user', content: request.user }, - ], - temperature: request.temperature, - response_format: request.responseFormat === 'json' - ? { type: 'json_object' } - : undefined, - }); + const reply = await engine.chat.completions.create(createWebLlmCompletionRequest(request)); const content = reply.choices[0]?.message?.content ?? ''; const usage = reply.usage; @@ -96,6 +87,21 @@ async function handleComplete(id: string, request: LlmRequest): Promise { } } +export function createWebLlmCompletionRequest(request: LlmRequest) { + return { + messages: [ + { role: 'system' as const, content: request.system }, + { role: 'user' as const, content: request.user }, + ], + temperature: request.temperature, + response_format: request.responseFormat === 'json' + ? request.schema === undefined + ? { type: 'json_object' as const } + : { type: 'json_object' as const, schema: JSON.stringify(request.schema) } + : undefined, + }; +} + async function handleDispose(): Promise { if (engine !== null) { await engine.unload(); diff --git a/web/src/ai/chat-types.ts b/web/src/ai/chat-types.ts index 9258a9cd..a540749e 100644 --- a/web/src/ai/chat-types.ts +++ b/web/src/ai/chat-types.ts @@ -1,4 +1,8 @@ -import type { BrowserDiagnostic } from '../protocol'; +import type { + BrowserConversationCompilationFile, + BrowserConversationPreviewFile, + BrowserDiagnostic, +} from '../protocol'; export interface ChatProviderInfo { provider: string; @@ -19,6 +23,9 @@ export interface AssistantGenerateChatMessage { diagnostics: BrowserDiagnostic[]; providerInfo: ChatProviderInfo; pending: boolean; + actionId?: string; + previewFiles?: BrowserConversationPreviewFile[]; + compilationFiles?: BrowserConversationCompilationFile[]; outcome?: 'accepted' | 'discarded'; } diff --git a/web/src/ai/heuristic-provider.test.ts b/web/src/ai/heuristic-provider.test.ts deleted file mode 100644 index 28dc5c02..00000000 --- a/web/src/ai/heuristic-provider.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { HeuristicProvider } from './heuristic-provider'; -import type { LlmRequest } from './types'; - -describe('HeuristicProvider', () => { - test('has heuristic id and model', () => { - const provider = new HeuristicProvider(); - expect(provider.id).toBe('heuristic'); - expect(provider.model).toBe('heuristic'); - }); - - test('initialize resolves without error', async () => { - const provider = new HeuristicProvider(); - await expect(provider.initialize()).resolves.toBeUndefined(); - }); - - test('complete returns user text for text format', async () => { - const provider = new HeuristicProvider(); - const request: LlmRequest = { - system: 'You are a helper.', - user: 'domain example\n entity Foo @1', - temperature: 0.2, - responseFormat: 'text', - }; - const response = await provider.complete(request); - expect(response.content).toBe(request.user); - expect(response.provider).toBe('heuristic'); - expect(response.model).toBe('heuristic'); - }); - - test('complete returns JSON-wrapped user text for json format', async () => { - const provider = new HeuristicProvider(); - const request: LlmRequest = { - system: 'You are a helper.', - user: 'explain this model', - temperature: 0.2, - responseFormat: 'json', - }; - const response = await provider.complete(request); - expect(JSON.parse(response.content)).toEqual({ result: request.user }); - }); - - test('dispose resolves without error', async () => { - const provider = new HeuristicProvider(); - await expect(provider.dispose()).resolves.toBeUndefined(); - }); -}); diff --git a/web/src/ai/heuristic-provider.ts b/web/src/ai/heuristic-provider.ts deleted file mode 100644 index f7ba42c8..00000000 --- a/web/src/ai/heuristic-provider.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { LlmProvider, LlmRequest, LlmResponse } from './types'; - -export class HeuristicProvider implements LlmProvider { - readonly id = 'heuristic'; - readonly model = 'heuristic'; - - async initialize(): Promise {} - - async complete(request: LlmRequest): Promise { - return { - content: request.responseFormat === 'json' - ? JSON.stringify({ result: request.user }) - : request.user, - provider: this.id, - model: this.model, - }; - } - - async dispose(): Promise {} -} diff --git a/web/src/ai/simulator-provider.test.ts b/web/src/ai/simulator-provider.test.ts new file mode 100644 index 00000000..efbca1c5 --- /dev/null +++ b/web/src/ai/simulator-provider.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'vitest'; + +import { SimulatorProvider } from './simulator-provider'; +import type { LlmRequest } from './types'; + +function conversationRequest(message: string, repair = false): LlmRequest { + return { + system: 'Return JSON matching the supplied conversation plan schema.', + user: [ + 'Workspace summary:', + 'domain customer', + '', + `User request:\n${message}`, + repair ? '\nPrevious response validation error:\ninvalid JSON' : '', + ].join('\n'), + temperature: repair ? 0.05 : 0.1, + responseFormat: 'json', + schema: { type: 'object' }, + }; +} + +describe('SimulatorProvider', () => { + test('returns a typed create-model plan for an invoice request', async () => { + const provider = new SimulatorProvider(); + + const response = await provider.complete( + conversationRequest('Create an invoice with an invoice id'), + ); + + expect(JSON.parse(response.content)).toMatchObject({ + kind: 'change_set', + operations: [{ kind: 'create_model', name: 'Invoice' }], + }); + expect(response.provider).toBe('simulator'); + }); + + test('returns malformed output once then a valid repair', async () => { + const provider = new SimulatorProvider({ malformedFirst: true }); + + expect( + (await provider.complete(conversationRequest('Create an invoice'))).content, + ).toBe('{malformed'); + expect( + JSON.parse( + ( + await provider.complete( + conversationRequest('Create an invoice', true), + ) + ).content, + ).kind, + ).toBe('change_set'); + }); + + test('returns projection and clarification plans by semantic intent', async () => { + const provider = new SimulatorProvider(); + + const projection = JSON.parse( + ( + await provider.complete( + conversationRequest('Suggest a projection for billing'), + ) + ).content, + ); + const clarification = JSON.parse( + ( + await provider.complete(conversationRequest('Create a projection')) + ).content, + ); + + expect(projection.operations[0].kind).toBe('create_projection'); + expect(clarification.kind).toBe('clarification'); + }); +}); diff --git a/web/src/ai/simulator-provider.ts b/web/src/ai/simulator-provider.ts new file mode 100644 index 00000000..5872f20f --- /dev/null +++ b/web/src/ai/simulator-provider.ts @@ -0,0 +1,137 @@ +import type { LlmProvider, LlmRequest, LlmResponse } from './types'; + +export interface SimulatorProviderOptions { + malformedFirst?: boolean; + failure?: string; +} + +export class SimulatorProvider implements LlmProvider { + readonly id = 'simulator'; + readonly model = 'semantic-v1'; + private calls = 0; + + constructor(private readonly options: SimulatorProviderOptions = {}) {} + + async initialize(): Promise {} + + async complete(request: LlmRequest): Promise { + this.calls += 1; + if (this.options.failure !== undefined) { + throw new Error(this.options.failure); + } + const isRepair = request.user.includes( + 'Previous response validation error', + ); + const content = this.options.malformedFirst === true + && this.calls === 1 + && !isRepair + ? '{malformed' + : JSON.stringify(planFor(userRequest(request.user))); + return { + content, + provider: this.id, + model: this.model, + }; + } + + async dispose(): Promise {} +} + +function userRequest(prompt: string): string { + const match = /User request:\n([\s\S]*?)(?:\n\nPrevious response validation error:|$)/u + .exec(prompt); + return (match?.[1] ?? prompt).trim(); +} + +function planFor(message: string): Record { + const normalized = message.toLowerCase(); + if (normalized.includes('describe the workspace')) { + return { + kind: 'query', + query_kind: 'summary', + refs: [], + question: message, + }; + } + if (normalized.includes('create an invoice')) { + return { + kind: 'change_set', + summary: 'Create billing.Invoice@1', + operations: [{ + kind: 'create_model', + domain: 'billing', + name: 'Invoice', + model_kind: 'entity', + fields: [{ + name: 'invoiceId', + type: { kind: 'uuid' }, + annotations: [{ kind: 'key' }], + }], + }], + }; + } + if (normalized.includes('suggest a projection')) { + return { + kind: 'change_set', + summary: 'Create billing.CustomerProjection@1', + operations: [{ + kind: 'create_projection', + domain: 'billing', + name: 'CustomerProjection', + source: { + model: 'customer.Customer', + version: 1, + alias: 'customer', + }, + fields: [{ + name: 'customerId', + mapping: { + kind: 'direct', + source_alias: 'customer', + source_field: 'customerId', + }, + }], + }], + }; + } + if (normalized.includes('create a projection')) { + return { + kind: 'clarification', + question: 'Which source model and consumer domain should I use?', + reason: 'A projection requires a grounded source and consumer.', + }; + } + if (normalized.includes('add email')) { + return { + kind: 'change_set', + summary: 'Add email to customer.Customer@2', + operations: [ + { + kind: 'append_model_version', + source: 'customer.Customer@1', + version: 2, + }, + { + kind: 'add_field', + target: 'customer.Customer@2', + field: { name: 'email', type: { kind: 'string' } }, + }, + ], + }; + } + if (normalized.includes('/compile json-schema')) { + return { + kind: 'compile', + target: 'json-schema', + domains: [], + output: null, + descriptor_set: false, + summary: 'Compile the workspace to JSON Schema.', + }; + } + return { + kind: 'unsupported', + request: message, + reason: 'The semantic simulator has no fixture for this request.', + }; +} diff --git a/web/src/ai/types.ts b/web/src/ai/types.ts index ee043a1d..a80275df 100644 --- a/web/src/ai/types.ts +++ b/web/src/ai/types.ts @@ -24,34 +24,6 @@ export interface LlmProvider { dispose(): Promise; } -export type AiGenerateAction = - | 'generate_entity' - | 'suggest_projection'; - -export type AiExplainAction = 'explain'; - -export interface AiGenerateParameters { - description?: string; - domainName?: string; - modelName?: string; - sourceRef?: string; - consumerDomain?: string; -} - -export interface AiExplainParameters { - ref?: string; - diagnosticIndex?: number; -} - -export interface AiGenerateResult { - source: string; - diagnostics: import('../protocol').BrowserDiagnostic[]; -} - -export interface AiExplainResult { - explanation: string; -} - export type ProviderStatus = | 'idle' | 'detecting' diff --git a/web/src/client.test.ts b/web/src/client.test.ts index 1d7141aa..edb76117 100644 --- a/web/src/client.test.ts +++ b/web/src/client.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import { BrowserCompilerClient, @@ -128,6 +128,73 @@ describe('BrowserCompilerClient', () => { ]); }); + test('fails only the active turn when provider completion fails', async () => { + const worker = new FakeWorker(); + const client = new BrowserCompilerClient(worker); + await initialize(client, worker); + const providerError = new Error('provider unavailable'); + const provider = { + id: 'failing', + model: 'test', + initialize: async () => {}, + complete: async () => { + throw providerError; + }, + dispose: async () => {}, + }; + + const turn = client.conversationTurn( + { + sessionId: 'session-1', + workspaceRevision: 1, + message: 'Create customer.Customer', + activeDocumentUri: null, + position: null, + }, + provider, + ); + await Promise.resolve(); + worker.respond( + success(worker.posted[1]!, { + status: 'pending_llm', + request_id: 'request-1', + attempt: 0, + llm_request: { + system: 'Return a plan.', + user: 'Create customer.Customer', + temperature: 0.2, + response_format: 'json', + schema: { type: 'object' }, + }, + }), + ); + await vi.waitFor(() => { + expect(worker.posted[2]?.method).toBe('conversation.fail'); + }); + expect(worker.posted[2]?.payload).toEqual({ + sessionId: 'session-1', + requestId: 'request-1', + workspaceRevision: 1, + error: 'provider unavailable', + }); + worker.respond( + success(worker.posted[2]!, { + reply: { + kind: 'unsupported', + text: 'Provider unavailable', + change_set_id: null, + operation_kind: null, + focused_ref: null, + preview_files: [], + compilation_files: [], + }, + workspace_revision: 1, + sources: [], + }), + ); + await expect(turn).rejects.toBe(providerError); + }); + test('response IDs resolve only matching promises', async () => { const worker = new FakeWorker(); const client = new BrowserCompilerClient(worker); diff --git a/web/src/client.ts b/web/src/client.ts index 322cc2d1..290eaf39 100644 --- a/web/src/client.ts +++ b/web/src/client.ts @@ -1,10 +1,8 @@ import { BROWSER_COMPILER_PROTOCOL_VERSION, - type BrowserAiExplainResult, - type BrowserAiGenerateResult, - type BrowserAiPendingResult, - type BrowserAiResult, type BrowserCompileResult, + type BrowserConversationReply, + type BrowserConversationResult, type BrowserCompatibilityResult, type BrowserCompletionResult, type BrowserCompilerErrorCode, @@ -24,11 +22,10 @@ import { type BrowserResultGuard, type BrowserSource, type BrowserWorkspaceResult, - isBrowserAiExplainResult, - isBrowserAiGenerateResult, - isBrowserAiPendingResult, - isBrowserAiResult, isBrowserCompileResult, + isBrowserConversationPendingResult, + isBrowserConversationReply, + isBrowserConversationResult, isBrowserCompatibilityResult, isBrowserCompletionResult, isBrowserCompilerResponse, @@ -43,7 +40,7 @@ import { isBrowserRenameResult, isBrowserWorkspaceResult, } from './protocol'; -import type { AiExplainParameters, AiGenerateAction, AiGenerateParameters, LlmProvider } from './ai/types'; +import type { LlmProvider } from './ai/types'; export interface WorkerLike { postMessage(message: BrowserCompilerRequest): void; @@ -95,6 +92,14 @@ export type CompileTarget = | 'markdown' | 'python'; +export interface ConversationTurnInput { + sessionId: string; + workspaceRevision: number; + message: string; + activeDocumentUri: string | null; + position: { line: number; character: number } | null; +} + export class BrowserCompilerClient { private readonly pending = new Map(); private initializationPromise: Promise | undefined; @@ -297,77 +302,96 @@ export class BrowserCompilerClient { ); } - async aiGenerate( - workspaceRevision: number, - action: AiGenerateAction, - parameters: AiGenerateParameters, + async conversationTurn( + input: ConversationTurnInput, provider: LlmProvider, - ): Promise { - const pendingResult = await this.initializedRequest( - 'ai.generate', - { workspaceRevision, action, parameters }, - isBrowserAiResult, + signal?: AbortSignal, + ): Promise { + let result = await this.initializedRequest( + 'conversation.turn', + { + sessionId: input.sessionId, + workspaceRevision: input.workspaceRevision, + message: input.message, + activeDocumentUri: input.activeDocumentUri, + line: input.position?.line ?? null, + character: input.position?.character ?? null, + }, + isBrowserConversationResult, ); - if (!isBrowserAiPendingResult(pendingResult)) { - if (isBrowserAiGenerateResult(pendingResult)) { - return pendingResult; + try { + while (isBrowserConversationPendingResult(result)) { + throwIfConversationAborted(signal); + const response = await provider.complete({ + system: result.llm_request.system, + user: result.llm_request.user, + temperature: result.llm_request.temperature, + responseFormat: result.llm_request.response_format === 'json' ? 'json' : 'text', + schema: result.llm_request.schema ?? undefined, + }); + throwIfConversationAborted(signal); + result = await this.initializedRequest( + 'conversation.resume', + { + sessionId: input.sessionId, + requestId: result.request_id, + workspaceRevision: input.workspaceRevision, + llmResponseContent: response.content, + }, + isBrowserConversationResult, + ); } - throw new BrowserCompilerError( - 'COMPILER_FAILED', - 'Unexpected AI result type', - ); + } catch (error: unknown) { + if (isBrowserConversationPendingResult(result)) { + try { + await this.initializedRequest( + 'conversation.fail', + { + sessionId: input.sessionId, + requestId: result.request_id, + workspaceRevision: input.workspaceRevision, + error: error instanceof Error ? error.message : 'Provider completion failed', + }, + isBrowserConversationReply, + ); + } catch { + // Preserve the provider/cancellation failure that caused cleanup. + } + } + throw error; } - const llmResponse = await provider.complete({ - system: pendingResult.llm_request.system, - user: pendingResult.llm_request.user, - temperature: pendingResult.llm_request.temperature, - responseFormat: pendingResult.llm_request.response_format === 'json' ? 'json' : 'text', - }); - return this.initializedRequest( - 'ai.generate', - { - workspaceRevision, - action, - parameters, - llmResponseContent: llmResponse.content, - }, - isBrowserAiGenerateResult, + return result; + } + + conversationApply( + sessionId: string, + actionId: string, + workspaceRevision: number, + ): Promise { + return this.initializedRequest( + 'conversation.apply', + { sessionId, actionId, workspaceRevision }, + isBrowserConversationReply, ); } - async aiExplain( + conversationDiscard( + sessionId: string, + actionId: string, workspaceRevision: number, - parameters: AiExplainParameters, - provider: LlmProvider, - ): Promise { - const pendingResult = await this.initializedRequest( - 'ai.explain', - { workspaceRevision, parameters }, - isBrowserAiResult, + ): Promise { + return this.initializedRequest( + 'conversation.discard', + { sessionId, actionId, workspaceRevision }, + isBrowserConversationReply, ); - if (!isBrowserAiPendingResult(pendingResult)) { - if (isBrowserAiExplainResult(pendingResult)) { - return pendingResult; - } - throw new BrowserCompilerError( - 'COMPILER_FAILED', - 'Unexpected AI result type', - ); - } - const llmResponse = await provider.complete({ - system: pendingResult.llm_request.system, - user: pendingResult.llm_request.user, - temperature: pendingResult.llm_request.temperature, - responseFormat: pendingResult.llm_request.response_format === 'json' ? 'json' : 'text', - }); - return this.initializedRequest( - 'ai.explain', - { - workspaceRevision, - parameters, - llmResponseContent: llmResponse.content, - }, - isBrowserAiExplainResult, + } + + async conversationReset(sessionId: string): Promise { + await this.initializedRequest( + 'conversation.reset', + { sessionId }, + (result): result is null => result === null, ); } @@ -451,10 +475,14 @@ export type BrowserCompilerClientLike = Pick< | 'lineage' | 'compatibility' | 'governance' - | 'aiGenerate' - | 'aiExplain' | 'dispose' ->; +> & Partial>; function languagePositionPayload( position: BrowserLanguagePosition, @@ -466,3 +494,9 @@ function languagePositionPayload( character: position.character, }; } + +function throwIfConversationAborted(signal?: AbortSignal): void { + if (signal?.aborted === true) { + throw new DOMException('Conversation cancelled', 'AbortError'); + } +} diff --git a/web/src/editor/SourceEditor.tsx b/web/src/editor/SourceEditor.tsx index 8e871036..4dd4a8c6 100644 --- a/web/src/editor/SourceEditor.tsx +++ b/web/src/editor/SourceEditor.tsx @@ -230,6 +230,19 @@ export const SourceEditor = forwardRef( version: file?.version ?? 1, }; }, + getPosition() { + const sourceEditor = editorRef.current; + const position = sourceEditor?.getPosition(); + const model = sourceEditor?.getModel(); + if (position === null || position === undefined || model === null || model === undefined) { + return null; + } + return { + uri: model.uri.toString(), + line: position.lineNumber - 1, + character: position.column - 1, + }; + }, applyFormattedText(pathOrText: string, formattedText?: string) { const path = formattedText === undefined ? activePathRef.current : pathOrText; diff --git a/web/src/editor/types.ts b/web/src/editor/types.ts index a3de84f6..0937a8a1 100644 --- a/web/src/editor/types.ts +++ b/web/src/editor/types.ts @@ -2,6 +2,7 @@ import type { BrowserSource } from '../protocol'; export interface SourceEditorHandle { getSource(): BrowserSource; + getPosition(): { uri: string; line: number; character: number } | null; applyFormattedText(path: string, text: string): void; applyFormattedText(text: string): void; replaceText(text: string): void; diff --git a/web/src/language/BrowserLanguageServiceController.test.ts b/web/src/language/BrowserLanguageServiceController.test.ts index 57c121f7..e449d0f4 100644 --- a/web/src/language/BrowserLanguageServiceController.test.ts +++ b/web/src/language/BrowserLanguageServiceController.test.ts @@ -143,8 +143,6 @@ class FakeClient implements BrowserCompilerClientLike { readonly formatSource = vi.fn(); readonly compile = vi.fn(); readonly compileJsonSchema = vi.fn(); - readonly aiGenerate = vi.fn(); - readonly aiExplain = vi.fn(); readonly dispose = vi.fn(); } diff --git a/web/src/protocol.test.ts b/web/src/protocol.test.ts index 27a66f15..6e557385 100644 --- a/web/src/protocol.test.ts +++ b/web/src/protocol.test.ts @@ -2,10 +2,6 @@ import { describe, expect, test } from 'vitest'; import { BROWSER_COMPILER_PROTOCOL_VERSION, - isBrowserAiExplainResult, - isBrowserAiGenerateResult, - isBrowserAiPendingResult, - isBrowserAiResult, isBrowserCompatibilityResult, isBrowserCompletionResult, isBrowserCompilerRequest, @@ -56,7 +52,6 @@ describe('isBrowserCompilerRequest', () => { expect(isBrowserCompilerRequest(valid)).toBe(true); }); }); - describe('isBrowserCompilerResponse', () => { const success = { protocolVersion: 2, @@ -110,7 +105,6 @@ describe('isBrowserCompilerResponse', () => { ).toBe(true); }); }); - describe('browser protocol v2 result guards', () => { const range = { start: { line: 1, character: 2 }, @@ -460,144 +454,3 @@ describe('isBrowserGovernanceResult', () => { expect(isBrowserGovernanceResult(null)).toBe(false); }); }); - -describe('isBrowserAiPendingResult', () => { - const valid = { - status: 'pending_llm', - llm_request: { - system: 'You are a helper.', - user: 'Generate an entity.', - temperature: 0.2, - response_format: 'text', - }, - }; - - test('accepts valid pending result', () => { - expect(isBrowserAiPendingResult(valid)).toBe(true); - }); - - test('rejects wrong status', () => { - expect(isBrowserAiPendingResult({ ...valid, status: 'ready' })).toBe(false); - }); - - test('rejects missing llm_request', () => { - expect(isBrowserAiPendingResult({ status: 'pending_llm' })).toBe(false); - }); - - test('rejects non-object', () => { - expect(isBrowserAiPendingResult(null)).toBe(false); - }); -}); - -describe('isBrowserAiGenerateResult', () => { - const valid = { - source: 'domain example\n entity Foo @1\n fooId uuid @key', - diagnostics: [], - }; - - test('accepts valid generate result', () => { - expect(isBrowserAiGenerateResult(valid)).toBe(true); - }); - - test('accepts result with diagnostics', () => { - expect( - isBrowserAiGenerateResult({ - source: 'invalid', - diagnostics: [ - { - code: 'PARSE', - severity: 'error', - message: 'Unexpected token', - uri: 'ai-generated.mdl', - line: 1, - column: 0, - end_line: null, - end_column: null, - }, - ], - }), - ).toBe(true); - }); - - test('rejects missing source', () => { - expect(isBrowserAiGenerateResult({ diagnostics: [] })).toBe(false); - }); - - test('rejects non-object', () => { - expect(isBrowserAiGenerateResult(null)).toBe(false); - }); -}); - -describe('isBrowserAiExplainResult', () => { - test('accepts valid explain result', () => { - expect( - isBrowserAiExplainResult({ explanation: 'This model represents...' }), - ).toBe(true); - }); - - test('rejects missing explanation', () => { - expect(isBrowserAiExplainResult({})).toBe(false); - }); - - test('rejects non-string explanation', () => { - expect(isBrowserAiExplainResult({ explanation: 42 })).toBe(false); - }); - - test('rejects non-object', () => { - expect(isBrowserAiExplainResult(null)).toBe(false); - }); -}); - -describe('isBrowserAiResult', () => { - test('accepts pending result', () => { - expect( - isBrowserAiResult({ - status: 'pending_llm', - llm_request: { - system: 's', - user: 'u', - temperature: 0.2, - response_format: 'text', - }, - }), - ).toBe(true); - }); - - test('accepts generate result', () => { - expect( - isBrowserAiResult({ source: 'domain d\n entity E @1', diagnostics: [] }), - ).toBe(true); - }); - - test('accepts explain result', () => { - expect(isBrowserAiResult({ explanation: 'text' })).toBe(true); - }); - - test('rejects unknown shape', () => { - expect(isBrowserAiResult({ unknown: true })).toBe(false); - }); -}); - -describe('isBrowserCompilerRequest with AI methods', () => { - test('accepts ai.generate method', () => { - expect( - isBrowserCompilerRequest({ - protocolVersion: BROWSER_COMPILER_PROTOCOL_VERSION, - id: 'req-1', - method: 'ai.generate', - payload: {}, - }), - ).toBe(true); - }); - - test('accepts ai.explain method', () => { - expect( - isBrowserCompilerRequest({ - protocolVersion: BROWSER_COMPILER_PROTOCOL_VERSION, - id: 'req-2', - method: 'ai.explain', - payload: {}, - }), - ).toBe(true); - }); -}); diff --git a/web/src/protocol.ts b/web/src/protocol.ts index a4497638..26457f13 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -16,8 +16,12 @@ export type BrowserCompilerMethod = | 'workspace.lineage' | 'workspace.compatibility' | 'workspace.governance' - | 'ai.generate' - | 'ai.explain'; + | 'conversation.turn' + | 'conversation.resume' + | 'conversation.fail' + | 'conversation.apply' + | 'conversation.discard' + | 'conversation.reset'; export type BrowserCompilerErrorCode = | 'INITIALIZATION_FAILED' @@ -293,26 +297,49 @@ export interface BrowserLlmRequest { user: string; temperature: number; response_format: string; + schema: Record | null; } -export interface BrowserAiPendingResult { +export interface BrowserConversationPendingResult { status: 'pending_llm'; + request_id: string; + attempt: number; llm_request: BrowserLlmRequest; } -export interface BrowserAiGenerateResult { - source: string; - diagnostics: BrowserDiagnostic[]; +export interface BrowserConversationPreviewFile { + path: string; + existed_before: boolean; + before_text: string; + after_text: string; +} + +export interface BrowserConversationCompilationFile { + destination: string; + media_type: string; + after_text: string | null; + after_hash: string; } -export interface BrowserAiExplainResult { - explanation: string; +export interface BrowserConversationReplyValue { + kind: 'answer' | 'clarification' | 'preview' | 'applied' | 'discarded' | 'unsupported' | 'error'; + text: string; + change_set_id: string | null; + operation_kind: 'source_change' | 'compile' | null; + focused_ref: string | null; + preview_files: BrowserConversationPreviewFile[]; + compilation_files: BrowserConversationCompilationFile[]; +} + +export interface BrowserConversationReply { + reply: BrowserConversationReplyValue; + workspace_revision: number; + sources: BrowserSource[]; } -export type BrowserAiResult = - | BrowserAiPendingResult - | BrowserAiGenerateResult - | BrowserAiExplainResult; +export type BrowserConversationResult = + | BrowserConversationPendingResult + | BrowserConversationReply; export type BrowserResultGuard = (value: unknown) => value is T; @@ -332,8 +359,12 @@ const methods = new Set([ 'workspace.lineage', 'workspace.compatibility', 'workspace.governance', - 'ai.generate', - 'ai.explain', + 'conversation.turn', + 'conversation.resume', + 'conversation.fail', + 'conversation.apply', + 'conversation.discard', + 'conversation.reset', ]); const errorCodes = new Set([ @@ -833,60 +864,81 @@ export function isBrowserGovernanceResult( function isBrowserLlmRequest( value: unknown, + requireSchema = false, ): value is BrowserLlmRequest { + const keys = requireSchema + ? ['system', 'user', 'temperature', 'response_format', 'schema'] + : ['system', 'user', 'temperature', 'response_format']; return ( isRecord(value) && - hasExactKeys(value, ['system', 'user', 'temperature', 'response_format']) && + hasExactKeys(value, keys) && typeof value.system === 'string' && typeof value.user === 'string' && typeof value.temperature === 'number' && - typeof value.response_format === 'string' + typeof value.response_format === 'string' && + (!requireSchema || value.schema === null || isRecord(value.schema)) ); } -export function isBrowserAiPendingResult( - value: unknown, -): value is BrowserAiPendingResult { +function isBrowserSource(value: unknown): value is BrowserSource { return ( isRecord(value) && - hasExactKeys(value, ['status', 'llm_request']) && - value.status === 'pending_llm' && - isBrowserLlmRequest(value.llm_request) + hasExactKeys(value, ['uri', 'text', 'version']) && + typeof value.uri === 'string' && + typeof value.text === 'string' && + isIntegerAtLeast(value.version, 1) ); } -export function isBrowserAiGenerateResult( +export function isBrowserConversationPendingResult( value: unknown, -): value is BrowserAiGenerateResult { +): value is BrowserConversationPendingResult { return ( isRecord(value) && - hasExactKeys(value, ['source', 'diagnostics']) && - typeof value.source === 'string' && - Array.isArray(value.diagnostics) && - value.diagnostics.every(isBrowserDiagnostic) + hasExactKeys(value, ['status', 'request_id', 'attempt', 'llm_request']) && + value.status === 'pending_llm' && + typeof value.request_id === 'string' && + isIntegerAtLeast(value.attempt, 0) && + isBrowserLlmRequest(value.llm_request, true) ); } -export function isBrowserAiExplainResult( +function isBrowserConversationReplyValue( value: unknown, -): value is BrowserAiExplainResult { +): value is BrowserConversationReplyValue { + if (!isRecord(value) || typeof value.kind !== 'string' || typeof value.text !== 'string') { + return false; + } + const kinds = new Set(['answer', 'clarification', 'preview', 'applied', 'discarded', 'unsupported', 'error']); return ( - isRecord(value) && - hasExactKeys(value, ['explanation']) && - typeof value.explanation === 'string' + kinds.has(value.kind) && + isNullableString(value.change_set_id) && + (value.operation_kind === null || value.operation_kind === 'source_change' || value.operation_kind === 'compile') && + isNullableString(value.focused_ref) && + Array.isArray(value.preview_files) && + Array.isArray(value.compilation_files) ); } -export function isBrowserAiResult( +export function isBrowserConversationReply( value: unknown, -): value is BrowserAiResult { +): value is BrowserConversationReply { return ( - isBrowserAiPendingResult(value) || - isBrowserAiGenerateResult(value) || - isBrowserAiExplainResult(value) + isRecord(value) && + hasExactKeys(value, ['reply', 'workspace_revision', 'sources']) && + isBrowserConversationReplyValue(value.reply) && + isIntegerAtLeast(value.workspace_revision, 1) && + Array.isArray(value.sources) && + value.sources.every(isBrowserSource) ); } +export function isBrowserConversationResult( + value: unknown, +): value is BrowserConversationResult { + return isBrowserConversationPendingResult(value) || isBrowserConversationReply(value); +} + export function isBrowserCompilerRequest( value: unknown, ): value is BrowserCompilerRequest { diff --git a/web/tests/ai-actions.spec.ts b/web/tests/ai-actions.spec.ts index 51542bbd..4f184ef0 100644 --- a/web/tests/ai-actions.spec.ts +++ b/web/tests/ai-actions.spec.ts @@ -2,16 +2,16 @@ import AxeBuilder from '@axe-core/playwright'; import { expect, test, type Page } from '@playwright/test'; import { replaceSource, sourceOutput, waitForReady } from './helpers'; -async function gotoWithHeuristic(page: Page): Promise { - await page.goto('?test=1&ai=heuristic'); +async function gotoWithSimulator(page: Page): Promise { + await page.goto('?test=1&ai=simulator'); await waitForReady(page); await expect( page.getByPlaceholder('e.g. Add a creditScore field to Customer'), ).toBeVisible({ timeout: 5_000 }); } -test('activates heuristic AI and shows action buttons', async ({ page }) => { - await gotoWithHeuristic(page); +test('activates the semantic simulator and shows action buttons', async ({ page }) => { + await gotoWithSimulator(page); await expect( page.getByRole('button', { name: 'Explain workspace' }), @@ -27,11 +27,11 @@ test('activates heuristic AI and shows action buttons', async ({ page }) => { test('generate entity sends a chat message and shows preview', async ({ page, }) => { - await gotoWithHeuristic(page); + await gotoWithSimulator(page); await page .getByPlaceholder('e.g. Add a creditScore field to Customer') - .fill('an invoice'); + .fill('Create an invoice'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByText('AI generated source')).toBeVisible({ @@ -43,11 +43,11 @@ test('generate entity sends a chat message and shows preview', async ({ await expect( page.getByRole('button', { name: 'Discard' }), ).toBeVisible(); - await expect(page.getByText('heuristic / heuristic')).toBeVisible(); + await expect(page.getByText('simulator / semantic-v1')).toBeVisible(); }); test('explain shows AI explanation in chat', async ({ page }) => { - await gotoWithHeuristic(page); + await gotoWithSimulator(page); await page.getByRole('button', { name: 'Explain workspace' }).click(); await expect(page.getByText('AI explanation')).toBeVisible({ @@ -62,11 +62,11 @@ test('explain shows AI explanation in chat', async ({ page }) => { }); test('accept applies generated source to the editor', async ({ page }) => { - await gotoWithHeuristic(page); + await gotoWithSimulator(page); await page .getByPlaceholder('e.g. Add a creditScore field to Customer') - .fill('an invoice'); + .fill('Create an invoice'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByText('AI generated source')).toBeVisible({ timeout: 10_000, @@ -77,7 +77,7 @@ test('accept applies generated source to the editor', async ({ page }) => { }); test('discard closes preview without modifying source', async ({ page }) => { - await gotoWithHeuristic(page); + await gotoWithSimulator(page); const source = 'domain customer { owner: "team" entity Customer @ 1 (additive) { @key customerId: uuid } }'; @@ -97,14 +97,14 @@ test('has no accessibility violations across the assistant panel', async ({ page, }) => { test.setTimeout(90_000); - await gotoWithHeuristic(page); + await gotoWithSimulator(page); const panelResults = await new AxeBuilder({ page }).analyze(); expect(panelResults.violations).toEqual([]); await page .getByPlaceholder('e.g. Add a creditScore field to Customer') - .fill('an invoice'); + .fill('Create an invoice'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByText('AI generated source')).toBeVisible({ timeout: 10_000, @@ -125,7 +125,7 @@ test('no CSS animations are active with prefers-reduced-motion', async ({ }); const page = await context.newPage(); try { - await page.goto('?test=1&ai=heuristic'); + await page.goto('?test=1&ai=simulator'); await waitForReady(page); const animations = await page.evaluate(() => {