diff --git a/src/opendox/doxbench_install.py b/src/opendox/doxbench_install.py index fef18092..7cb15c1e 100644 --- a/src/opendox/doxbench_install.py +++ b/src/opendox/doxbench_install.py @@ -48,12 +48,19 @@ CHOICE between two declarations, which is exactly the kind of install-time fact this module exists to make readable in one place. -THE UNCONFIGURED POSTURE IS UNCHANGED, BYTE FOR BYTE. A checkout with no -bindings document, or one declaring no bindings, resolves the SAME -`model_port_factory(session_root)` the entrypoints have always resolved, so an -install that never heard of a broker behaves precisely as it did before this -change — and a plane with no factory at all still refuses -`model_capability_unavailable` exactly as it always has. +THE UNCONFIGURED POSTURE IS A STATE (#1144's 16.4; plan 034's T081). A checkout +with no approved binding resolves the SAME `model_port_factory(session_root)` +the entrypoints have always resolved WHERE THE HARNESS IS INSTALLED, so the +harness route stays for an install that has it. Where it is not (`omp`, +`doxbench_bridge.HARNESS_COMMAND`, is not on the PATH: `harness_installed()`), +there is no model, and the declaration says so: it resolves +`doxbench_model.NO_MODEL_CONFIGURED`, whose catalog offers no available entry, +and which `serve_workbench`'s accessor answers as no port at all. So the served +catalog is the editor-only posture before any turn, and a turn is refused +`model_capability_unavailable` before any process is spawned or any endpoint is +contacted. Until T081, the harness declaration answered here whether or not +`omp` existed, and its catalog offered `omp-local` as available: an install +with no model read as one with a model until a turn failed. ONE INSTANCE PER PROCESS, and that is a requirement rather than an optimisation. `_workbench_model_port` is called PER REQUEST, and `OmpHarnessBridge` is @@ -74,11 +81,13 @@ from __future__ import annotations +import shutil import sys import threading from pathlib import Path from opendox import doxbench_bridge as bridge_mod +from opendox import doxbench_model from opendox.doxbench_model import ModelCatalog, ModelCatalogEntry # -------------------------------------------------------------------------- @@ -188,6 +197,22 @@ def resolve() -> bridge_mod.OmpHarnessBridge: return resolve +def harness_installed() -> bool: + """Is the harness installed: is `doxbench_bridge.HARNESS_COMMAND` on the + PATH this process resolves commands from? + + The same question #1144's F16.1 asks as its precondition (`command -v omp`), + asked without starting anything: it reads the PATH and spawns no process.""" + return shutil.which(bridge_mod.HARNESS_COMMAND) is not None + + +def no_model_port_factory() -> doxbench_model.NoModelConfigured: + """The ZERO-ARGUMENT factory for an install with no model: it answers + `doxbench_model.NO_MODEL_CONFIGURED`, the one no-model port, and builds, + spawns and contacts nothing.""" + return doxbench_model.NO_MODEL_CONFIGURED + + # -------------------------------------------------------------------------- # the BROKERED declaration (add-model-provider-broker tasks 2.2/2.5) # -------------------------------------------------------------------------- @@ -275,7 +300,8 @@ def resolve(): def declared_model_port_factory(session_root: Path | str, *, checkout_root: Path | str, bindings_path: Path | str | None = None, - spawn=None): + spawn=None, + harness_present=None): """THE declaration both entrypoints make (task 2.5). ONE rule, in one place, so `cli.cmd_generate_and_open` and `serve.serve()` @@ -284,13 +310,17 @@ def declared_model_port_factory(session_root: Path | str, *, * a checkout declaring a model-provider BINDING resolves the brokered port for the FIRST declared binding, and every provider endpoint and every minted token it needs lives inside `doxbench_provider`; - * a checkout declaring NONE resolves exactly what these entrypoints have - always resolved — the harness bridge — so the unconfigured posture is - unchanged byte for byte; + * a checkout declaring NONE resolves the harness bridge where the harness + is installed, exactly as these entrypoints always have, and + `doxbench_model.NO_MODEL_CONFIGURED` where it is not (#1144's 16.4; + the module docstring). `harness_present` is that question, a + zero-argument callable, `harness_installed` by default, so a caller + that exercises a harness turn through `spawn` can say the harness is + there; * a bindings document that will not READ (malformed YAML, a wrong kind, a - record naming an unknown key) resolves the harness declaration too, and - says so on stderr. Refusing to serve at all would make one bad line in - an operator's settings file take the whole console down, and silently + record naming an unknown key) is read as declaring none, and says so + on stderr. Refusing to serve at all would make one bad line in an + operator's settings file take the whole console down, and silently serving a DIFFERENT provider than the one declared would be worse than either. @@ -319,7 +349,7 @@ def declared_model_port_factory(session_root: Path | str, *, except binding_mod.BindingRefused as error: sys.stderr.write( f"[model-provider] the bindings document could not be read " - f"({error}); serving the local harness declaration instead\n") + f"({error}); reading it as declaring no binding\n") declared = () pending = intake_mod.pending_binding_ids(checkout_root) approved = tuple(binding for binding in declared @@ -335,5 +365,7 @@ def declared_model_port_factory(session_root: Path | str, *, "human approval and contribute no available model; approve them " "from the console's model intake flow\n") if not approved: - return model_port_factory(Path(session_root), spawn=spawn) + if (harness_present or harness_installed)(): + return model_port_factory(Path(session_root), spawn=spawn) + return no_model_port_factory return brokered_model_port_factory(approved[0]) diff --git a/src/opendox/doxbench_model.py b/src/opendox/doxbench_model.py index 13d22621..8ec3973e 100644 --- a/src/opendox/doxbench_model.py +++ b/src/opendox/doxbench_model.py @@ -1062,6 +1062,66 @@ def catalog(self) -> ModelCatalog: ... def dispatch(self, prompt_envelope: object) -> object: ... +# --------------------------------------------------------------------------- +# "no model configured" (#1144's 16.4; plan 034's T081) +# --------------------------------------------------------------------------- + +#: How to configure a model, said where the rail says that none is. The chat +#: rail (`web/views/doxbench-chat.js`) restates it verbatim, and +#: `tests/test_chat_model_configuration.py` holds the two spellings together. +NO_MODEL_CONFIGURED_REMEDY = ( + "No model configured. To configure one, declare a model binding with " + "\"opendox model-binding add\" (\"--help\" lists its fields), or put the " + "local harness \"omp\" on PATH, then restart this console.") + + +class NoModelConfiguredError(RuntimeError): + """A turn was handed to the port an install declares when it has no model. + Nothing was spawned and nothing was contacted.""" + + +class NoModelConfigured: + """THE PORT AN INSTALL DECLARES WHEN IT HAS NO MODEL (#1144's 16.4). + + 16.4 measured the gap: with no binding, the entrypoints resolved the + harness declaration, whose catalog offers `omp-local` as AVAILABLE with no + `omp` on the PATH, so an install with no model read as one with a model + until a turn failed. `doxbench_install.declared_model_port_factory` + answers THIS port instead when no approved binding is declared and the + harness is absent, and every reader sees the state before any turn: + + * `catalog()` is `EMPTY_CATALOG`, so the catalog offers no available + entry; + * `serve_workbench`'s model-port accessor answers this port as NO port, so + the served catalog is the editor-only posture and a turn or an abstract + is refused `model_capability_unavailable`, the fixed code a plane with + no model capability has always given; + * `dispatch()` refuses without spawning or contacting anything, for a + caller that reaches it directly. + + One instance, `NO_MODEL_CONFIGURED`, which the accessor recognises by + identity, so no adapter can claim the posture by imitation.""" + + __slots__ = () + + @property + def timeout_seconds(self) -> float: + return 1.0 + + def catalog(self) -> ModelCatalog: + return EMPTY_CATALOG + + def dispatch(self, prompt_envelope: object) -> object: + raise NoModelConfiguredError(NO_MODEL_CONFIGURED_REMEDY) + + def __repr__(self) -> str: + return "" + + +#: The one no-model port. +NO_MODEL_CONFIGURED = NoModelConfigured() + + def validated_timeout_seconds(value: object) -> float: """Pure validator: accepts a real, non-bool number strictly greater than zero and no greater than ``MAX_ADAPTER_TIMEOUT_SECONDS``; refuses every diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 037ed2fb..5142c10f 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -159,15 +159,27 @@ def _workbench_model_port(self): adapter is stateful -- the harness bridge the entrypoints declare holds per-document-thread sessions -- the factory returns ONE instance for the life of the process and this accessor hands back that same object - on every request. Nothing here may assume a per-request adapter.""" + on every request. Nothing here may assume a per-request adapter. + + "NO MODEL CONFIGURED" IS ABSENCE TOO (#1144's 16.4; plan 034's T081). + An install with no approved binding and no harness declares + `doxbench_model.NO_MODEL_CONFIGURED`, and this accessor answers that + one port, recognised by identity, as no port: so the catalog route + serves the editor-only posture and a turn or an abstract is refused + `model_capability_unavailable` before anything is spawned or + contacted.""" if not self.capabilities.get("actions", {}).get("session"): return None if self.model_port_factory is None: return None try: - return self.model_port_factory() + port = self.model_port_factory() except Exception: # noqa: BLE001 - absence is a capability verdict return None + from opendox import doxbench_model + if port is doxbench_model.NO_MODEL_CONFIGURED: + return None + return port # The largest corpus one tile's index is built from. A bound, not a # policy: a tile's staged set is a topic folder, and an index that grew @@ -1664,6 +1676,30 @@ def _handle_workbench_chat_turn(self) -> None: transcript_turns = fields["transcript_turns"] turn_buffers = fields["turn_buffers"] + # ---- the model verdict, AHEAD of step 5 (#1144's 16.4; plan 034's + # T081). A plane with NO model port refuses a well-formed turn here, + # with step 7's own code and envelope, before the scope is read: a + # plane-level verdict outranks any defect in the caller's request, the + # rule the validators refusal above keeps. It answers both an install + # with no model configured (`doxbench_model.NO_MODEL_CONFIGURED`, which + # the accessor answers as no port) and a plane with no factory at all, + # and it spawns nothing and contacts nothing. Measured at + # openDox-code#71 `e0298cf4`, once T085's validators answered + # standalone: without this, a standalone turn reached step 5's scope + # import and the connection dropped. + # + # THE PORT RESOLVED HERE IS THE ONE STEP 7 READS, so the declared + # factory runs ONCE per turn. The built-in factories memoize, but the + # accessor does not require an injected one to, and a second call + # would build a second adapter and discard the first (Copilot at + # openDox-code#74 8104fa6e, r4170882125). ---- + port = self._workbench_model_port() + if port is None: + self._refuse_turn(validators, + DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE, + turn_id, failure_kind=failure_kind) + return + from opendox import doxbench_hash from opendox import doxbench_model from openxdox import doxbench_scope @@ -1823,13 +1859,8 @@ def _session_text(rel, _root=source_root): failure_kind=failure_kind) return - # ---- step 7: model ---- - port = self._workbench_model_port() - if port is None: - self._refuse_turn(validators, - DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE, - turn_id, failure_kind=failure_kind) - return + # ---- step 7: model. `port` is the one resolved, and found present, + # ahead of step 5; it is not resolved a second time. ---- try: catalog = port.catalog() except Exception: # noqa: BLE001 - never let a provider-shaped exception reach the wire diff --git a/src/opendox/web/views/doxbench-chat.js b/src/opendox/web/views/doxbench-chat.js index 6a496f98..7b4d66b9 100644 --- a/src/opendox/web/views/doxbench-chat.js +++ b/src/opendox/web/views/doxbench-chat.js @@ -309,6 +309,50 @@ function unavailabilityNote(stateValue) { return CHAT_UNAVAILABLE_NOTE; } +// "NO MODEL CONFIGURED" IS A STATE, SHOWN BEFORE ANY TURN, WITH HOW TO +// CONFIGURE ONE (#1144's 16.4; plan 034's T081). The configured-none sentence +// above stays byte for byte: add-doxchat-model-intake §1 keeps "the rail's +// existing sentence" stating that no approved model is configured, as the +// send button's stated reason. What it never said is HOW (plan 034's research +// R15), and an install with no model and no intake flow, which is every +// standalone one, had no other place that said it. So this is a SEPARATE, +// VISIBLE line with the remedy, and it shows only when that is the state: +// the catalog has ANSWERED, it is EMPTY, no catalog failure is recorded (a +// stale token or an unreadable answer has its own remedy), and no intake +// affordance is rendered (where intake is offered, the selector's first option +// is the remedy's home, and a second statement of it would be the "second, +// weaker statement" the intake requirement refuses). EMPTY, not "nothing +// available": a configured model can be unavailable — after a broker refusal +// `BrokeredProviderPort.catalog()` keeps the binding, `available: false` — and +// telling that operator to declare a binding they already have would send them +// to the wrong repair. The server's no-model port answers the empty catalog +// (`doxbench_model.NO_MODEL_CONFIGURED`), so empty is exactly "no binding and +// no harness". The Python twin is `doxbench_model.NO_MODEL_CONFIGURED_REMEDY`, +// which tests/test_chat_model_configuration.py holds to this spelling. +export const NO_MODEL_CONFIGURED_REMEDY = + "No model configured. To configure one, declare a model binding with \"opendox model-binding add\" (\"--help\" lists its fields), or put the local harness \"omp\" on PATH, then restart this console."; + +export function noModelConfiguredRemedy(stateValue) { + if (stateValue.catalogFailure) return null; + if (stateValue.models === null) return null; + // EMPTY READS AS openDox's OWN NO-MODEL STATE, and that is a stated limit + // (RULED by the holder, 2026-10-03, on Copilot's r4170956940 at + // openDox-code#74). The remedy names openDox's OWN ways to configure a + // model. Every openDox entry point declares openDox's own model port + // (`doxbench_install.declared_model_port_factory`), whose empty catalog is + // exactly no binding and no harness. A programmatic embedder that injects + // its own port, and serves an empty catalog from it, supplies its own intake + // offer, and where intake is offered this line is hidden (the check below). + // The server cannot say more within the released contract: + // `xfactory-workbench-model-catalog` is closed (`additionalProperties: + // false`: `schema_version`, `kind`, `models`), and `/capabilities` and the + // intake surface are held EQUAL with and without a model by #1144's 16.5 + // (plan 034 T082), so neither may carry a model posture. + if ((stateValue.models || []).length !== 0) return null; + if (stateValue.intakeOffered === true) return null; + return NO_MODEL_CONFIGURED_REMEDY; +} + // T104 F10-2/4: the over-bound paste, refused VISIBLY. The pure model // refuses by returning the IDENTICAL state (refused, never truncated) and // render()'s unconditional value reassignment reverts the DOM — correct, but @@ -1098,6 +1142,11 @@ export function mountDoxBenchChatRail(host, options = {}) { // unavailabilityNote derives from the state's own facts. const unavailableNote = el("div", "doxchat-unavailable doxchat-sronly", CHAT_CATALOG_LOADING_NOTE); + // 16.4's visible line (`noModelConfiguredRemedy`): hidden until the catalog + // has answered empty. It is not itself a live region; render() ANNOUNCES its + // text through `announce` below, once per change. + const noModelNote = el("div", "doxchat-no-model"); + noModelNote.hidden = true; const transcriptList = el("ul", "doxchat-transcript"); transcriptList.setAttribute("aria-label", "chat transcript"); const failureNote = el("div", "doxchat-failure"); @@ -1154,8 +1203,9 @@ export function mountDoxBenchChatRail(host, options = {}) { sendBtn.setAttribute("aria-describedby", unavailableNote.id); host.append(loadedSelect, loadedNote, loadedEmpty, loadedFull, subjectInput, - unavailableNote, transcriptList, contextNote, retryNote, - cardsHost, announce, failureNote, composer, disclosure, sendrow); + unavailableNote, noModelNote, transcriptList, contextNote, + retryNote, cardsHost, announce, failureNote, composer, + disclosure, sendrow); // SELECTING IS IMMEDIATE, and it is not a state authority: the seam owns the // move, `state.active_buffer` remains the one answer, and this handler only @@ -1467,6 +1517,18 @@ export function mountDoxBenchChatRail(host, options = {}) { // would trade a statement of posture for a call to action, and the posture is // the fact the human needs. unavailableNote.textContent = selectable ? "" : unavailabilityNote(state); + // ANNOUNCED, ONCE PER CHANGE. The catalog settles asynchronously and with + // no focus change, so a line that only appears is a line a screen-reader + // user is never told about: its text goes to the polite `announce` region + // too. Same only-when-it-changes guard as the posture and retry notes + // below, for the same measured reason: render() runs on every keystroke, + // and re-writing a live region with the same sentence re-announces it. + const remedyText = noModelConfiguredRemedy(state) || ""; + if (noModelNote.textContent !== remedyText) { + noModelNote.hidden = !remedyText; + noModelNote.textContent = remedyText; + if (remedyText) announce.textContent = remedyText; + } selector.value = defaultSelectorValue(state); transcriptList.textContent = ""; for (const turn of transcriptWindow(state)) { diff --git a/tests/fixtures/web_boundary_census.yaml b/tests/fixtures/web_boundary_census.yaml index 85677478..7128b840 100644 --- a/tests/fixtures/web_boundary_census.yaml +++ b/tests/fixtures/web_boundary_census.yaml @@ -191,7 +191,7 @@ measured_at: "opensoft/openDox-code main a99eba03e31a0aee1cc15a061fdf718cc88a2c4 # shape and `test_the_declared_totals_are_re_derived_from_the_rows` can refuse a # drift between the two. Measured at slice S4 (see the S4 block above). totals: - A: {files: 26, loc: 18073} + A: {files: 26, loc: 18135} B: {files: 1, loc: 73} C: {files: 14, loc: 12587} "?": {files: 1, loc: 1577} @@ -278,7 +278,7 @@ files: - path: views/doxbench-chat.js class: A - loc: 1828 + loc: 1890 note: "doxBench chat rail; imports only the pure chat model" - path: views/doxbench-editor.js diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py new file mode 100644 index 00000000..fb1aca18 --- /dev/null +++ b/tests/test_chat_model_configuration.py @@ -0,0 +1,862 @@ +"""Chat's model configuration, with NO MODEL CONFIGURED: #1144's 16.4, which +plan 034's T081 realizes (requirement 17's third scenario, *"No model is +configured"*). + +16.4 measured the gap at `1e4a57fb`. With no binding, +`declared_model_port_factory(...)()` resolved the harness declaration, and its +catalog offered `omp-local` as AVAILABLE with no `omp` on the PATH. So an install +with no model read as one with a model until a turn failed. This file holds the +state 16.4 asks for: + +1. F16.1'S CATALOG BLOCK, AS WRITTEN. With no binding and no harness, the + catalog offers no available entry. +2. THE DECLARATION, STATE BY STATE. No approved binding and no harness gives + openDox's no-model port. A harness on the PATH gives the harness bridge, so + the harness route stays, and resolving it spawns nothing. The binding + states the port reads are each pinned: + - a hand- or CLI-declared binding, which the intake document says nothing + about, makes the port present; + - a `pending` intake declaration is suppressed; + - an `approved` one makes the port present; + - an unreadable bindings document is read as declaring none. +3. THE PORT AND THE ACCESSOR. The no-model port is a `WorkbenchModelPort`, + its catalog is empty, and its `dispatch` refuses without spawning or + contacting anything. `serve_workbench`'s accessor answers it, and only it, + as no port. +4. THE SERVED ROUTES, STANDALONE. A `generate-and-open` child with neither + sibling importable (`tests/standalone_child.py`) answers the catalog route + 200 with no available entry. This is T085's falsifier's second half, and + it needs T085's validators. The child also answers a schema-valid turn + `403 model_capability_unavailable`, and the same turn without the console + token `console_required`. + THE TURN ROUTE'S ORDER (the holder's ruling on the hoist, option (a)): a + console, body, kind, schema or parse defect answers first in every + posture; with no port, the no-model refusal answers before a scope, + identity or limits defect and the scope is never read; with a port, every + defect answers what it answered before the hoist. +5. THE CHAT RAIL. Mounted over an EMPTY catalog with no intake flow, the rail + shows "No model configured" and how to configure one, visibly and before + any turn, and announces it once through its polite live region, while the + send button keeps its existing sentence byte for byte. The line shows in + that state only: a catalog of configured models that are unavailable is + not "no model configured". Its spelling is held to the Python twin's. + +`omp` is `doxbench_bridge.HARNESS_COMMAND`. A case that means "no harness" +says so with `harness_present=lambda: False`, or with a PATH holding no `omp`, +and asserts it. + +Plan 034's T082 adds 16.5 here (every other surface, with no model), and +T078–T080 add the configured turn over a stand-in OpenAI-compatible server. +F16.1's last line runs this file whole once all of them have landed (T083). + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import copy +import dataclasses +import http.client +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import textwrap +import types +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +from opendox import doxbench_binding as binding_mod +from opendox import doxbench_bridge as bridge_mod +from opendox import doxbench_hash +from opendox import doxbench_install as inst +from opendox import doxbench_intake as intake_mod +from opendox import doxbench_model +from opendox import doxbench_turns +from opendox import serve_wire +from opendox import validator as own +from opendox.serve_workbench import WorkbenchRoutes +from session_fixtures import GATE_TEST_PRINCIPALS +from standalone_child import Child, fresh_repository + +ROOT = Path(__file__).resolve().parent.parent +PLAIN = ROOT / "tests" / "fixtures" / "plain-documents" +EXAMPLES = ROOT / "tests" / "fixtures" / "spec-examples" +CHAT_VIEW_JS = ROOT / "src" / "opendox" / "web" / "views" / "doxbench-chat.js" +CHAT_MODEL_JS = ROOT / "src" / "opendox" / "web" / "views" / "doxbench-chat-model.js" +NODE = shutil.which("node") + +#: The console's human, one of the suite's declared principals. +ACTOR = "tester" + +#: The rail's existing configured-none sentence, which add-doxchat-model-intake +#: §1 keeps byte for byte. +CONFIGURED_NONE = ("chat is unavailable — no approved model is configured; both " + "editors remain fully usable.") + + +def _no_omp_path(tmp_path: Path, path: str | None = None) -> str: + """This process's PATH (or `path`) with `omp` taken out and EVERY OTHER + COMMAND KEPT, in order. A directory holding `omp` is replaced by a mirror + of it under `tmp_path`: a symlink to each of its other entries. Dropping + the whole directory would drop whatever else it holds, `git` among them, + and a child that cannot find `git` fails before the case it exists for + (Copilot at openDox-code#74 9551f20d, r4170794383).""" + harness = bridge_mod.HARNESS_COMMAND + mirrors = Path(tempfile.mkdtemp(prefix="path-without-omp-", dir=tmp_path)) + kept = [] + for index, entry in enumerate( + (os.environ.get("PATH", "") if path is None else path).split(os.pathsep)): + if not entry: + continue + if shutil.which(harness, path=entry) is None: + kept.append(entry) + continue + mirror = mirrors / str(index) + mirror.mkdir() + # ABSOLUTE targets: a relative PATH entry names a directory relative + # to the current directory, and a relative link would resolve from + # the mirror instead (Copilot at openDox-code#74 4ef7575a, + # r4170839174). + for item in sorted(Path(os.path.abspath(entry)).iterdir()): + if item.name != harness: + (mirror / item.name).symlink_to(item) + kept.append(str(mirror)) + without = os.pathsep.join(kept) + assert shutil.which(harness, path=without) is None + return without + + +def test_a_path_without_omp_keeps_every_other_command(tmp_path) -> None: + """A directory that holds `omp` beside another command keeps the other + command, in its place in the order.""" + shared = tmp_path / "shared-bin" + shared.mkdir() + for name in (bridge_mod.HARNESS_COMMAND, "fixture-git"): + tool = shared / name + tool.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + tool.chmod(0o755) + before, after = tmp_path / "before", tmp_path / "after" + before.mkdir() + after.mkdir() + path = os.pathsep.join([str(before), str(shared), str(after)]) + without = _no_omp_path(tmp_path, path).split(os.pathsep) + assert len(without) == 3, "a directory holding omp was dropped whole" + assert without[0] == str(before) and without[2] == str(after), without + assert shutil.which(bridge_mod.HARNESS_COMMAND, path=os.pathsep.join(without)) is None + found = shutil.which("fixture-git", path=os.pathsep.join(without)) + assert found is not None and Path(found).parent == Path(without[1]), found + assert Path(found).resolve() == (shared / "fixture-git").resolve() + + +def test_a_relative_path_entry_without_omp_keeps_its_commands( + tmp_path, monkeypatch) -> None: + """A RELATIVE PATH entry holding `omp`: its other commands still resolve, + from anywhere, to the same files.""" + shared = tmp_path / "shared-bin" + shared.mkdir() + for name in (bridge_mod.HARNESS_COMMAND, "fixture-git"): + tool = shared / name + tool.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + tool.chmod(0o755) + monkeypatch.chdir(tmp_path) + assert shutil.which("fixture-git", path="shared-bin") is not None + without = _no_omp_path(tmp_path, "shared-bin") + monkeypatch.chdir(ROOT) # a child's own directory + assert shutil.which(bridge_mod.HARNESS_COMMAND, path=without) is None + found = shutil.which("fixture-git", path=without) + assert found is not None, without + assert Path(found).resolve() == (shared / "fixture-git").resolve() + + +def _fake_omp(tmp_path: Path) -> Path: + """A directory holding an executable named `omp`, which is never run.""" + bin_dir = tmp_path / "harness-bin" + bin_dir.mkdir() + omp = bin_dir / bridge_mod.HARNESS_COMMAND + omp.write_text("#!/bin/sh\necho 'a fake harness must never run' >&2\nexit 97\n", + encoding="utf-8") + omp.chmod(0o755) + return bin_dir + + +def _binding(**overrides) -> binding_mod.ModelProviderBinding: + fields = dict(id="a-provider", label="A provider", provider="a-provider", + credential_ref="opref-0000000000000000", auth_kind="api_key", + approved_by="tester", endpoint="https://provider.invalid/turn", + dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1, + broker_argv=("a-broker",)) + fields.update(overrides) + return binding_mod.ModelProviderBinding(**fields) + + +def _declare(checkout: Path, binding=None) -> binding_mod.ModelProviderBinding: + """A binding declared in the checkout's bindings document, as + `opendox model-binding add` declares one.""" + binding = binding or _binding() + binding_mod.BindingStore(binding_mod.bindings_path(checkout)).add(binding) + return binding + + +def _resolve(tmp_path: Path, checkout: Path, *, harness: bool, spawn=None): + return inst.declared_model_port_factory( + tmp_path / "sessions", checkout_root=checkout, spawn=spawn, + harness_present=lambda: harness)() + + +@pytest.fixture +def checkout(tmp_path) -> Path: + root = tmp_path / "checkout" + root.mkdir() + return root + + +class _NoSpawn: + """A `spawn` seam that records any call. Resolving a port must not call it.""" + + def __init__(self): + self.calls = [] + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + raise AssertionError("a child process was spawned") + + +# --------------------------------------------------------------------------- +# 1 — F16.1's catalog block, as written +# --------------------------------------------------------------------------- + +_F16_1_CATALOG_BLOCK = textwrap.dedent(''' + import pathlib, sys + from opendox import doxbench_install as inst + root = pathlib.Path(sys.argv[1]) / "no-model" + root.mkdir() + port = inst.declared_model_port_factory(root / "sessions", checkout_root=root)() + offered = [e.model_id for e in port.catalog().available_entries()] + assert not offered, f"no model is configured, yet the catalog offers {offered}" + print("no model configured: the catalog offers nothing") + ''') + + +def test_F16_1s_catalog_block_with_no_binding_and_no_harness(tmp_path) -> None: + """#1144's F16.1, its 16.4 block word for word, in a process whose PATH + holds no `omp` (F16.1's own precondition). At `047bb4fa` it failed with + `['omp-local']`.""" + done = subprocess.run( + [sys.executable, "-", str(tmp_path)], input=_F16_1_CATALOG_BLOCK, + capture_output=True, text=True, cwd=ROOT, timeout=120, + env=dict(os.environ, PATH=_no_omp_path(tmp_path))) + assert done.returncode == 0, done.stderr + assert done.stdout.strip() == "no model configured: the catalog offers nothing" + + +# --------------------------------------------------------------------------- +# 2 — the declaration, state by state +# --------------------------------------------------------------------------- + +def test_no_binding_and_no_harness_is_the_no_model_port(tmp_path, checkout) -> None: + spawn = _NoSpawn() + port = _resolve(tmp_path, checkout, harness=False, spawn=spawn) + assert port is doxbench_model.NO_MODEL_CONFIGURED + assert port.catalog().available_entries() == () + assert spawn.calls == [] + + +def test_the_harness_route_stays_where_the_harness_is_installed( + tmp_path, checkout, monkeypatch) -> None: + """With `omp` on the PATH, the default probe finds it, and the harness + bridge answers exactly as it always has. Resolving it spawns nothing.""" + monkeypatch.setenv("PATH", str(_fake_omp(tmp_path)) + os.pathsep + + _no_omp_path(tmp_path)) + assert inst.harness_installed() is True + spawn = _NoSpawn() + port = inst.declared_model_port_factory( + tmp_path / "sessions", checkout_root=checkout, spawn=spawn)() + assert isinstance(port, bridge_mod.OmpHarnessBridge) + assert [entry.model_id for entry in port.catalog().available_entries()] == \ + [inst.HARNESS_MODEL_ID] + assert spawn.calls == [] + + +def test_the_default_probe_reads_the_path(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("PATH", _no_omp_path(tmp_path)) + assert inst.harness_installed() is False + monkeypatch.setenv("PATH", str(_fake_omp(tmp_path))) + assert inst.harness_installed() is True + + +def test_a_hand_declared_binding_makes_the_port_present(tmp_path, checkout) -> None: + """A binding `opendox model-binding add` declares writes the bindings + document alone, and the intake document says nothing about it, so it is + never `pending`. That is how a standalone install configures a model + without the console's approval route.""" + binding = _declare(checkout) + from opendox import doxbench_provider as provider_mod + port = _resolve(tmp_path, checkout, harness=False) + assert isinstance(port, provider_mod.BrokeredProviderPort) + assert [e.model_id for e in port.catalog().available_entries()] == [binding.id] + + +def _intake(checkout: Path) -> intake_mod.DeclarationStore: + return intake_mod.DeclarationStore(intake_mod.declarations_path(checkout)) + + +def _pending(binding_id: str) -> intake_mod.ModelDeclaration: + return intake_mod.ModelDeclaration( + binding_id=binding_id, status=intake_mod.STATUS_PENDING, + install_posture=intake_mod.POSTURE_SINGLE_OPERATOR, proposed_by="tester", + proposed_at="2026-10-02T00:00:00Z") + + +def test_a_pending_declaration_is_suppressed(tmp_path, checkout, capsys) -> None: + binding = _declare(checkout) + _intake(checkout).propose(_pending(binding.id)) + assert _resolve(tmp_path, checkout, harness=False) is \ + doxbench_model.NO_MODEL_CONFIGURED + assert "pending human approval" in capsys.readouterr().err + + +def test_an_approved_declaration_makes_the_port_present(tmp_path, checkout) -> None: + binding = _declare(checkout) + store = _intake(checkout) + store.propose(_pending(binding.id)) + store.approve(binding.id, issued_by="tester", approved_by="tester", + expires_at="2027-01-01T00:00:00Z", audit_ref="audit-0001") + from opendox import doxbench_provider as provider_mod + assert isinstance(_resolve(tmp_path, checkout, harness=False), + provider_mod.BrokeredProviderPort) + + +def test_an_unreadable_bindings_document_is_read_as_declaring_none( + tmp_path, checkout, capsys) -> None: + path = binding_mod.bindings_path(checkout) + path.parent.mkdir(parents=True) + path.write_text("schema_version: 9\nkind: something-else\n", encoding="utf-8") + assert _resolve(tmp_path, checkout, harness=False) is \ + doxbench_model.NO_MODEL_CONFIGURED + assert "reading it as declaring no binding" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# 3 — the port, and the accessor that answers it as no port +# --------------------------------------------------------------------------- + +def test_the_no_model_port_is_a_port_that_offers_nothing_and_refuses(monkeypatch) -> None: + port = doxbench_model.NO_MODEL_CONFIGURED + assert isinstance(port, doxbench_model.WorkbenchModelPort) + assert port.catalog() is doxbench_model.EMPTY_CATALOG + doxbench_model.validated_timeout_seconds(port.timeout_seconds) + + def refused(*args, **kwargs): + raise AssertionError("dispatch reached a process or the network") + + import socket + monkeypatch.setattr(subprocess, "Popen", refused) + monkeypatch.setattr(socket, "create_connection", refused) + with pytest.raises(doxbench_model.NoModelConfiguredError) as raised: + port.dispatch(object()) + assert str(raised.value) == doxbench_model.NO_MODEL_CONFIGURED_REMEDY + + +class _Plane: + """The two facts `_workbench_model_port` reads, and nothing else.""" + + capabilities = {"actions": {"session": True}} + + def __init__(self, factory): + self.model_port_factory = factory + + +def test_the_accessor_answers_the_no_model_port_as_no_port() -> None: + accessor = WorkbenchRoutes._workbench_model_port + assert accessor(_Plane(inst.no_model_port_factory)) is None + other = doxbench_model.NoModelConfigured() + assert accessor(_Plane(lambda: other)) is other, \ + "only THE no-model port is absence; anything else is a port" + real = object() + assert accessor(_Plane(lambda: real)) is real + + +# --------------------------------------------------------------------------- +# 4 — the served routes, standalone +# --------------------------------------------------------------------------- + +_URL = re.compile(r"(http://([0-9.]+):([0-9]+))/index\.html$") + + +def _request(base, method, path, *, body=None, headers=None): + connection = http.client.HTTPConnection(*base, timeout=30) + try: + connection.request(method, path, body=body, headers=headers or {}) + response = connection.getresponse() + return response.status, json.loads(response.read() or b"null") + finally: + connection.close() + + +@pytest.fixture +def standalone(tmp_path, monkeypatch): + """A `generate-and-open` child over T050's fixture, with neither sibling + importable, no binding, and a PATH holding no `omp`. Answers + `(base, console token, child)`.""" + assert ACTOR in GATE_TEST_PRINCIPALS + repo = fresh_repository(PLAIN, tmp_path) + monkeypatch.setenv("PATH", _no_omp_path(tmp_path)) # the child inherits it + # `--local`: the single-user install. Since plan 034 T070 an unflagged + # `generate-and-open` is HOSTED, and with no issuer it refuses (13.5) + # before it serves anything. + child = Child(tmp_path, "opendox.cli", "generate-and-open", "--local", + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--port", "0", "--run-dir", str(tmp_path / "run"), + "--actor", ACTOR) + try: + match = child.wait_for_line(_URL) + base = (match.group(2), int(match.group(3))) + status, capabilities = _request(base, "GET", "/capabilities") + assert status == 200 and capabilities["actions"]["session"] is True + yield base, capabilities["console_token"], child + assert child.interrupt() == 0, child.stderr_text() + assert child.refused() == [], child.refused() + assert "Traceback" not in child.stderr_text(), child.stderr_text() + finally: + child.kill() + + +def test_the_served_catalog_offers_no_available_entry(standalone) -> None: + """T085's falsifier, whole: the served catalog route answers standalone, + with no available entry, in an envelope openDox's own validator accepts.""" + base, token, _child = standalone + status, envelope = _request(base, "GET", "/workbench/model-catalog", + headers={"X-XF-Console-Token": token}) + assert status == 200, envelope + assert envelope["kind"] == serve_wire.DOXBENCH_MODEL_CATALOG_KIND + assert own.validate(envelope) == [] + assert [m for m in envelope["models"] if m["available"]] == [] + + +def _example_turn() -> dict: + return yaml.safe_load( + (EXAMPLES / "workbench-chat-turn-v2-loaded-set.example.yaml").read_text( + encoding="utf-8")) + + +def test_a_turn_is_refused_model_capability_unavailable(standalone) -> None: + """A schema-valid turn, which no rail sends with no model selected but any + client can, is refused with the fixed code and the contract's failure + envelope, and the child spawns no harness (none is on its PATH, and the + no-model port spawns nothing). The CONSOLE verdict still comes first: the + same turn without the console token is refused `console_required`, so an + unauthenticated caller learns nothing about the model posture.""" + base, token, _child = standalone + request = _example_turn() + status, body = _request( + base, "POST", "/actions/workbench/chat-turn", + body=json.dumps(request).encode("utf-8"), + headers={"Content-Type": "application/json"}) + console = serve_wire.DOXBENCH_ERR_CONSOLE_REQUIRED + assert (status, body["error"]) == (serve_wire.doxbench_error_status(console), + console), body + status, body = _request( + base, "POST", "/actions/workbench/chat-turn", + body=json.dumps(request).encode("utf-8"), + headers={"Content-Type": "application/json", "X-XF-Console-Token": token}) + code = serve_wire.DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE + assert status == serve_wire.doxbench_error_status(code) == 403, body + assert body["error"] == code, body + assert body["kind"] == serve_wire.DOXBENCH_CHAT_TURN_V2_FAILURE_KIND + assert body["client_turn_id"] == request["client_turn_id"] + assert own.validate(body) == [], own.report(own.validate(body)) + + +# --------------------------------------------------------------------------- +# 4b — the turn route's ORDER, with and without a port (the holder's ruling +# on 0a12dc58: option (a), with an ordering test) +# --------------------------------------------------------------------------- + +class _OfferingNothing: + """A CONFIGURED port whose catalog offers nothing: reaching its catalog is + step 7 (`model_unavailable`), and reaching `dispatch` would be a defect.""" + + timeout_seconds = 30.0 + + def catalog(self): + return doxbench_model.EMPTY_CATALOG + + def dispatch(self, prompt_envelope): + raise AssertionError("a turn with a defect was dispatched") + + +class _Registry: + """The server's scope truth for one key: found, or not. Every read is + recorded, so a case can assert the scope was never read.""" + + def __init__(self, found: bool, root: Path): + self.found, self.root, self.reads = found, root, [] + + def resolve(self, repository, ref): + self.reads.append((repository, ref)) + if not self.found: + return None + return SimpleNamespace(source_root=str(self.root), repository=repository, + ref=ref, session_base=None, + read_bytes=lambda: b"{}") + + +class _TurnRoute(WorkbenchRoutes): + """`_handle_workbench_chat_turn` itself, over openDox's real validators + (T085) and its real identity checks, with only the HTTP plumbing replaced: + the console verdict, the bounded body read, and the reply.""" + + loopback = True + capabilities = {"actions": {"session": True}} + actor = ACTOR + + def __init__(self, payload, *, port_factory, root, console=None, + body_bound=None, parsed=True, scope_found=True): + self.payload, self.console, self.body_bound = payload, console, body_bound + self.parsed = parsed + self.model_port_factory = port_factory + self.schema_validator_factory = own.doxbench_validators + self.source = SimpleNamespace(registry=_Registry(scope_found, root)) + self.sent = [] + + def _not_the_human_console(self): + return self.console + + def _read_bounded_json_body(self, max_bytes, dimension): + if self.body_bound is not None: + return None, self.body_bound + return self.payload, None + + def _parse_workbench_chat_turn_v2_body(self, payload): + # "parse": a validator more permissive than the release would let a + # body through that the parser still refuses + if not self.parsed: + return None + return WorkbenchRoutes._parse_workbench_chat_turn_v2_body(payload) + + def _send_json(self, status, obj): + self.sent.append((status, obj)) + + +@pytest.fixture +def scope_stand_in(monkeypatch): + """openxdox's scope module, which openDox's suite does not install (T084 + routes step 5 without it): a key type, the confinement error, and a scope + that resolves. Revalidation against that projection is a no-op here, so + step 5's verdict is exactly the registry's: found, or not.""" + scope = types.ModuleType("openxdox.doxbench_scope") + + @dataclasses.dataclass(frozen=True) + class ScopeKey: + repository: str + ref: str + tile_kind: str + tile_id: str + + class ScopeConfinementError(ValueError): + pass + + scope.ScopeKey = ScopeKey + scope.ScopeConfinementError = ScopeConfinementError + scope.session_created_paths_for_scope = lambda *args, **kwargs: () + scope.resolve_scope = lambda *args, **kwargs: SimpleNamespace() + package = types.ModuleType("openxdox") + package.doxbench_scope = scope + monkeypatch.setitem(sys.modules, "openxdox", package) + monkeypatch.setitem(sys.modules, "openxdox.doxbench_scope", scope) + monkeypatch.setattr(doxbench_turns, "revalidate_scope", lambda **kwargs: None) + + +def _defective(defect: str) -> tuple[dict | None, dict]: + """The example turn with one defect, and the route arguments it needs.""" + turn = copy.deepcopy(_example_turn()) + if defect == "console": + return turn, {"console": "no console token was presented"} + if defect == "not an object": + return None, {} + if defect == "body over its bound": + return turn, {"body_bound": {"dimension": "request_body_bytes", + "measured": 1_048_577, "maximum": 1_048_576}} + if defect == "kind": + turn["kind"] = "workbench-chat-turn" # the retired v1 kind + elif defect == "schema": + del turn["message"] + elif defect == "parse": + return turn, {"parsed": False} + elif defect == "scope": + return turn, {"scope_found": False} + elif defect == "identity": + turn["buffers"][0]["content"] += "edited after hashing\n" + elif defect == "limits": + # within the schema's 1 MiB `maxLength`, past step 6's 400 000 bytes + big = "a" * (doxbench_hash.MAX_BUFFER_BYTES + 1) + digest = doxbench_hash.sha256_hex(big, max_bytes=None) + turn["buffers"][1].update(content=big, content_hash=digest, base_hash=digest) + else: + assert defect == "none", defect + return turn, {} + + +#: Each defect's verdict WITH a configured port: today's order, unchanged. +_WITH_A_PORT = { + "console": serve_wire.DOXBENCH_ERR_CONSOLE_REQUIRED, + "body over its bound": serve_wire.DOXBENCH_ERR_REQUEST_LIMIT_EXCEEDED, + "not an object": "invalid_body", + "kind": serve_wire.DOXBENCH_ERR_UNRECOGNIZED_TURN_KIND, + "schema": serve_wire.DOXBENCH_ERR_INVALID_TURN_REQUEST, + "parse": serve_wire.DOXBENCH_ERR_INVALID_TURN_REQUEST, + "scope": serve_wire.DOXBENCH_ERR_TURN_SCOPE_REFUSED, + "identity": serve_wire.DOXBENCH_ERR_CONTENT_IDENTITY_MISMATCH, + "limits": serve_wire.DOXBENCH_ERR_REQUEST_LIMIT_EXCEEDED, + "none": serve_wire.DOXBENCH_ERR_MODEL_UNAVAILABLE, # step 7, after step 6 +} + +#: The defects that still answer FIRST with no port: the console, the body, +#: the kind, the schema and the parse. Every later one yields to the +#: no-model verdict. +_BEFORE_THE_MODEL_VERDICT = ("console", "body over its bound", "not an object", + "kind", "schema", "parse") + +_NO_PORT = {"no model configured": inst.no_model_port_factory, "no factory": None} + + +@pytest.mark.parametrize("defect", sorted(_WITH_A_PORT)) +@pytest.mark.parametrize("posture", ["a port", *_NO_PORT]) +def test_the_turn_routes_order_with_and_without_a_port( + defect, posture, scope_stand_in, tmp_path) -> None: + """A console, body, kind, schema or parse defect answers first in every + posture. With no port (no model configured, or no factory), the no-model + refusal answers before a scope, identity or limits defect, and the scope + is never read. With a port, every defect answers what it answered before + the hoist, and a well-formed turn reaches step 7. + + The declared model factory runs AT MOST ONCE per turn: once where the + turn reaches the model verdict, never where an earlier defect answers + (Copilot at openDox-code#74 8104fa6e, r4170882125).""" + payload, arguments = _defective(defect) + port = _OfferingNothing() + declared = (lambda: port) if posture == "a port" else _NO_PORT[posture] + resolved = [] + + def counted(): + resolved.append(posture) + return declared() + + route = _TurnRoute(payload, root=tmp_path, + port_factory=None if declared is None else counted, + **arguments) + route._handle_workbench_chat_turn() + reaches_the_verdict = defect not in _BEFORE_THE_MODEL_VERDICT + assert len(resolved) == (1 if reaches_the_verdict and declared is not None + else 0), (posture, defect, resolved) + assert len(route.sent) == 1, route.sent + status, body = route.sent[0] + expected = (_WITH_A_PORT[defect] + if posture == "a port" or defect in _BEFORE_THE_MODEL_VERDICT + else serve_wire.DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE) + assert body["error"] == expected, (posture, defect, body) + if expected != "invalid_body": + assert status == serve_wire.doxbench_error_status(expected) + if posture != "a port": + assert route.source.registry.reads == [], "the scope was read" + + +# --------------------------------------------------------------------------- +# 5 — the chat rail: "No model configured", and how to configure one +# --------------------------------------------------------------------------- + +def test_the_rails_remedy_is_spelled_as_the_python_twin() -> None: + source = CHAT_VIEW_JS.read_text(encoding="utf-8") + match = re.search(r'export const NO_MODEL_CONFIGURED_REMEDY =\s*("(?:[^"\\]|\\.)*");', + source) + assert match, "the rail declares NO_MODEL_CONFIGURED_REMEDY as one literal" + assert json.loads(match.group(1)) == doxbench_model.NO_MODEL_CONFIGURED_REMEDY + remedy = doxbench_model.NO_MODEL_CONFIGURED_REMEDY + assert remedy.startswith("No model configured.") + assert '"opendox model-binding add"' in remedy and '"omp"' in remedy + + +_RAIL_HARNESS = r""" +class Node { + constructor(tag) { + this.tagName = String(tag).toUpperCase(); + this.children = []; this.attributes = {}; this.listeners = {}; + this.className = ''; this._text = ''; this.hidden = false; + this.disabled = false; this.value = ''; this.writes = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(''); + } + set textContent(value) { + this.children = []; this._text = String(value); this.writes.push(this._text); + } + appendChild(child) { child.parentNode = this; this.children.push(child); return child; } + append(...kids) { for (const k of kids) this.appendChild(k); } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attributes, name) + ? this.attributes[name] : null; + } + addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); } + focus() {} + walk() { return this.children.reduce((a, c) => a.concat(c.walk()), [this]); } +} +const doc = { createElement: (tag) => new Node(tag), activeElement: null }; +const byClass = (root, cls) => root.walk().filter( + (n) => String(n.className).split(' ').includes(cls)); + +import { mountDoxBenchChatRail, NO_MODEL_CONFIGURED_REMEDY, + noModelConfiguredRemedy } from "./doxbench-chat.mjs"; + +const KEY = { repository: "fixture", ref: "main", tile_kind: "staged", + tile_id: "a-topic" }; +const ENTRY = { model_id: "model-a", label: "A model", provider_class: "local", + available: true, input_limit_bytes: 800000, output_limit_bytes: 900000, + data_handling: "on this host" }; +const OFF = { ...ENTRY, model_id: "model-off", available: false }; +const bufferOf = (kind, path) => ({ kind, path, base_ref: "main", + base_revision: "r1", base_hash: { algorithm: "sha256", hex: "c".repeat(64) }, + current_hash: { algorithm: "sha256", hex: "d".repeat(64) }, + hash_pending: false, content: "# " + kind, dirty: false }); +const editorState = () => ({ active_buffer: "document", buffers: { + outline: bufferOf("outline", "docs/outline.md"), + document: bufferOf("document", "docs/detail.md") } }); + +async function mount(catalog, { intake = null, intakeFirst = false } = {}) { + const host = new Node("div"); host.ownerDocument = doc; + let turns = 0; + let release; + const gate = new Promise((res) => { release = res; }); + const rail = mountDoxBenchChatRail(host, { + scopeKey: KEY, + transports: { catalog: async () => { await gate; return catalog(); }, + chatTurn: async () => { turns += 1; return null; } }, + editorState }); + const line = () => byClass(host, "doxchat-no-model")[0] || null; + const shown = () => (line() && !line().hidden) ? line().textContent : null; + const announce = byClass(host, "doxchat-announce")[0]; + // how many times the polite region was written the remedy + const announced = () => announce.writes.filter( + (text) => text === NO_MODEL_CONFIGURED_REMEDY).length; + const loading = shown(); + if (intake !== null && intakeFirst) rail.intakeOffer(intake); + release(); + await rail.ready; + if (intake !== null && !intakeFirst) rail.intakeOffer(intake); + const onArrival = announced(); + // render() runs on every keystroke: type twice and count again + const composer = byClass(host, "doxchat-composer")[0]; + for (const value of ["w", "wh"]) { + composer.value = value; + for (const fn of composer.listeners.input || []) fn({ target: composer }); + } + const send = byClass(host, "doxchat-send")[0]; + return { loading, shown: shown(), exists: Boolean(line()), turns, + announcePolite: announce.getAttribute("aria-live"), + announcedOnArrival: onArrival, announcedAfterTyping: announced(), + sendDisabled: send.disabled === true, sendTitle: send.title, + srNote: (byClass(host, "doxchat-unavailable")[0] || {}).textContent }; +} + +const empty = () => ({ schema_version: 1, kind: "workbench-model-catalog", models: [] }); +const out = { + remedy: NO_MODEL_CONFIGURED_REMEDY, + empty: await mount(empty), + onlyUnavailable: await mount(() => ({ schema_version: 1, + kind: "workbench-model-catalog", models: [OFF] })), + available: await mount(() => ({ schema_version: 1, + kind: "workbench-model-catalog", models: [ENTRY] })), + unreadable: await mount(() => null), + intakeOffered: await mount(empty, { intake: true }), + intakeFirst: await mount(empty, { intake: true, intakeFirst: true }), + // the pure verdict over states a mount does not reach in one shot: a + // failure recorded beside an adopted empty catalog keeps its own remedy + pure: { + emptyAdopted: noModelConfiguredRemedy({ models: [], catalogFailure: null }), + emptyThenUnreadable: noModelConfiguredRemedy( + { models: [], catalogFailure: "unreadable" }), + emptyThenStaleToken: noModelConfiguredRemedy( + { models: [], catalogFailure: "console_required" }), + // a configured model the broker refused: kept, `available: false` + onlyUnavailable: noModelConfiguredRemedy({ models: [OFF], catalogFailure: null }), + }, +}; +process.stdout.write(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def rail(tmp_path_factory) -> dict: + if NODE is None: + pytest.skip("node not available for the chat rail's no-model probe") + work = tmp_path_factory.mktemp("no-model-rail") + source = CHAT_VIEW_JS.read_text(encoding="utf-8").replace( + "./doxbench-chat-model.js", "./doxbench-chat-model.mjs") + (work / "doxbench-chat.mjs").write_text(source, encoding="utf-8") + shutil.copy(CHAT_MODEL_JS, work / "doxbench-chat-model.mjs") + harness = work / "harness.mjs" + harness.write_text(_RAIL_HARNESS, encoding="utf-8") + done = subprocess.run([NODE, str(harness)], capture_output=True, text=True, + timeout=60) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_the_rail_shows_no_model_configured_and_how_before_any_turn(rail) -> None: + empty = rail["empty"] + assert empty["shown"] == doxbench_model.NO_MODEL_CONFIGURED_REMEDY + assert empty["turns"] == 0, "shown before any turn, and no turn was sent" + assert empty["sendDisabled"] is True + # the send button's own reason is the existing sentence, byte for byte + assert empty["srNote"] == CONFIGURED_NONE + assert empty["sendTitle"] == CONFIGURED_NONE + + +def test_a_configured_model_that_is_unavailable_is_not_no_model(rail) -> None: + """After a broker refusal `BrokeredProviderPort.catalog()` keeps the + binding with `available: false` (doxbench_provider.py). That operator HAS + a model configured, and telling them to declare a binding would send them + to the wrong repair: the line is for an EMPTY catalog only. The send + button's configured-none sentence still states the posture.""" + only_unavailable = rail["onlyUnavailable"] + assert only_unavailable["shown"] is None + assert only_unavailable["announcedAfterTyping"] == 0 + assert only_unavailable["sendDisabled"] is True + assert rail["pure"]["onlyUnavailable"] is None + + +def test_the_remedy_is_announced_once_when_it_arrives(rail) -> None: + """The catalog settles asynchronously with no focus change, so the line's + text also goes to the rail's polite live region -- once: render() runs on + every keystroke, and a re-write of the same sentence would re-announce it.""" + empty = rail["empty"] + assert empty["announcePolite"] == "polite" + assert empty["announcedOnArrival"] == 1 + assert empty["announcedAfterTyping"] == 1, "typing re-announced the remedy" + for case in ("available", "onlyUnavailable", "unreadable", "intakeFirst"): + assert rail[case]["announcedAfterTyping"] == 0, case + + +def test_the_line_shows_in_that_state_only(rail) -> None: + """Not while the catalog is loading (nothing is known yet), not with a + model available, not when the catalog could not be read (that has its + own remedy), and not where the intake flow is offered (its option is the + remedy's home).""" + for case in ("empty", "onlyUnavailable", "available", "unreadable", + "intakeOffered", "intakeFirst"): + assert rail[case]["exists"] is True, case + assert rail[case]["loading"] is None, case + assert rail["available"]["shown"] is None + assert rail["unreadable"]["shown"] is None + assert rail["intakeOffered"]["shown"] is None + assert rail["intakeFirst"]["shown"] is None + assert rail["pure"] == {"emptyAdopted": doxbench_model.NO_MODEL_CONFIGURED_REMEDY, + "emptyThenUnreadable": None, "emptyThenStaleToken": None, + "onlyUnavailable": None} diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index b22c0001..2dfe66c3 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -390,8 +390,12 @@ def poisoned(name, *args, **kwargs): monkeypatch.setattr(builtins, "__import__", poisoned) monkeypatch.delitem(sys.modules, "yaml", raising=False) + # The harness is PRESENT here (plan 034 T081): with it absent, a document + # read as declaring nothing resolves the no-model port instead, which + # tests/test_chat_model_configuration.py holds. resolve = install_mod.declared_model_port_factory( - tmp_path / "sessions", checkout_root=checkout) + tmp_path / "sessions", checkout_root=checkout, + harness_present=lambda: True) from opendox import doxbench_bridge as bridge_mod assert isinstance(resolve(), bridge_mod.OmpHarnessBridge) assert "bindings document could not be read" in capsys.readouterr().err @@ -1171,11 +1175,14 @@ def test_the_port_satisfies_the_seam_without_growing_a_fourth_verb(tmp_path): def test_a_checkout_with_no_bindings_resolves_exactly_the_harness_declaration( tmp_path): """TASK 3.3. Not "a port of the same kind" — the SAME construction the - entrypoints have always made.""" + entrypoints have always made, WHERE THE HARNESS IS INSTALLED (plan 034 + T081, #1144's 16.4). With it absent, there is no model, and + tests/test_chat_model_configuration.py holds that state.""" checkout = tmp_path / "checkout" checkout.mkdir() resolve = install_mod.declared_model_port_factory( - tmp_path / "sessions", checkout_root=checkout) + tmp_path / "sessions", checkout_root=checkout, + harness_present=lambda: True) port = resolve() from opendox import doxbench_bridge as bridge_mod assert isinstance(port, bridge_mod.OmpHarnessBridge) @@ -1203,8 +1210,10 @@ def test_an_unreadable_bindings_document_falls_back_and_says_so(tmp_path, path.parent.mkdir(parents=True) path.write_text("schema_version: 9\nkind: something-else\n", encoding="utf-8") + # The harness is PRESENT here (plan 034 T081), as in the case above. resolve = install_mod.declared_model_port_factory( - tmp_path / "sessions", checkout_root=checkout) + tmp_path / "sessions", checkout_root=checkout, + harness_present=lambda: True) from opendox import doxbench_bridge as bridge_mod assert isinstance(resolve(), bridge_mod.OmpHarnessBridge) assert "bindings document could not be read" in capsys.readouterr().err