diff --git a/src/brigade/model_inventory.py b/src/brigade/model_inventory.py index 3617af65..e3f7dc79 100644 --- a/src/brigade/model_inventory.py +++ b/src/brigade/model_inventory.py @@ -3,6 +3,8 @@ from __future__ import annotations import re +import subprocess +import tempfile from dataclasses import dataclass from typing import Literal @@ -15,6 +17,8 @@ "grok": ["grok", "models"], "ollama": ["ollama", "list"], } +_INVENTORY_TIMEOUT_SECONDS = 15.0 +_CURSOR_COMPLETION_PREFIX = "Tip: use --model " _EFFORT_SUFFIX = re.compile(r"-(?:none|low|medium|high|xhigh|extra-high|max)$") _OLLAMA_OPERATIONAL_ERRORS = ( "authentication", @@ -107,12 +111,17 @@ def _inventory(self, cli_ref: str) -> _HarnessInventory: cached = self._inventories.get(cli_ref) if cached is not None: return cached - result = proc.run(_LIST_COMMANDS[cli_ref], timeout=15.0) + result = ( + _run_cursor_inventory() + if cli_ref == "cursor" + else proc.run(_LIST_COMMANDS[cli_ref], timeout=_INVENTORY_TIMEOUT_SECONDS) + ) if result.code != 0: diagnostic = result.stderr.strip() or result.stdout.strip() or f"exit {result.code}" inventory = _HarnessInventory(error=diagnostic[:160]) else: - recognized, models = _parse_model_list(cli_ref, f"{result.stdout}\n{result.stderr}") + output = result.stdout if cli_ref == "cursor" else f"{result.stdout}\n{result.stderr}" + recognized, models = _parse_model_list(cli_ref, output) if not recognized: inventory = _HarnessInventory(error="command returned an unrecognized inventory shape") elif not models and cli_ref != "ollama": @@ -161,17 +170,61 @@ def _inspect_ollama(self, requested: str) -> ModelInventoryResult: return ModelInventoryResult(state, requested, (), diagnostic[:200]) +def _run_cursor_inventory() -> proc.Result: + """Capture Cursor inventory through a file because its pipe output truncates at 8 KiB.""" + with tempfile.TemporaryFile() as stdout: + try: + completed = subprocess.run( + _LIST_COMMANDS["cursor"], + stdout=stdout, + stderr=subprocess.PIPE, + stdin=subprocess.DEVNULL, + timeout=_INVENTORY_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired as exc: + stdout.seek(0) + output = stdout.read().decode("utf-8", errors="replace") + diagnostic = _decode_subprocess_output(exc.stderr) + if diagnostic and not diagnostic.endswith("\n"): + diagnostic += "\n" + return proc.Result( + 124, + output, + f"{diagnostic}timeout after {_INVENTORY_TIMEOUT_SECONDS}s", + ) + except OSError as exc: + return proc.Result(127 if isinstance(exc, FileNotFoundError) else 126, "", str(exc)) + stdout.seek(0) + output = stdout.read().decode("utf-8", errors="replace") + return proc.Result(completed.returncode, output, _decode_subprocess_output(completed.stderr)) + + +def _decode_subprocess_output(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + return value.decode("utf-8", errors="replace") + + def _parse_model_list(cli_ref: str, output: str) -> tuple[bool, tuple[str, ...]]: lines = output.splitlines() header_index = _inventory_header_index(cli_ref, lines) if header_index is None: return False, () models: set[str] = set() + complete = cli_ref != "cursor" for line in lines[header_index + 1 :]: if cli_ref == "cursor": - if line.startswith("Tip:"): + if not line.strip(): + continue + if line.startswith(_CURSOR_COMPLETION_PREFIX): + complete = True break match = re.match(r"^([a-z0-9][a-z0-9._:/\[\],=-]*)\s+-\s+.+$", line) + if match is None: + return False, () elif cli_ref == "grok": match = re.match(r"^\s*\*\s+([^\s(]+)", line) else: @@ -184,7 +237,7 @@ def _parse_model_list(cli_ref: str, output: str) -> tuple[bool, tuple[str, ...]] continue if match is not None: models.add(match.group(1)) - return True, tuple(sorted(models)) + return complete, tuple(sorted(models)) def _inventory_header_index(cli_ref: str, lines: list[str]) -> int | None: diff --git a/tests/test_model_inventory.py b/tests/test_model_inventory.py index 8c954eea..4eaf5353 100644 --- a/tests/test_model_inventory.py +++ b/tests/test_model_inventory.py @@ -1,9 +1,12 @@ +import subprocess + from brigade import agents, model_inventory def _cursor_listing(*ids: str) -> str: lines = ["Available models", ""] lines.extend(f"{model_id} - Label for {model_id}" for model_id in ids) + lines.extend(["", "Tip: use --model to switch."]) return "\n".join(lines) + "\n" @@ -19,12 +22,12 @@ def _ollama_listing(*ids: str) -> str: return "\n".join(lines) + "\n" +def _patch_cursor_inventory(monkeypatch, result) -> None: + monkeypatch.setattr(model_inventory, "_run_cursor_inventory", lambda: result, raising=False) + + def test_cursor_inventory_exact_match(monkeypatch): - monkeypatch.setattr( - model_inventory.proc, - "run", - lambda argv, **kwargs: agents.proc.Result(0, _cursor_listing("composer-2.5"), ""), - ) + _patch_cursor_inventory(monkeypatch, agents.proc.Result(0, _cursor_listing("composer-2.5"), "")) result = model_inventory.ModelInventoryInspector().inspect("cursor", "composer-2.5") @@ -34,10 +37,9 @@ def test_cursor_inventory_exact_match(monkeypatch): def test_cursor_inventory_narrow_fuzzy_match(monkeypatch): - monkeypatch.setattr( - model_inventory.proc, - "run", - lambda argv, **kwargs: agents.proc.Result( + _patch_cursor_inventory( + monkeypatch, + agents.proc.Result( 0, _cursor_listing("cursor-grok-4.5-low", "cursor-grok-4.5-high", "cursor-grok-4.6-high"), "", @@ -52,10 +54,9 @@ def test_cursor_inventory_narrow_fuzzy_match(monkeypatch): def test_cursor_inventory_different_version_is_missing(monkeypatch): - monkeypatch.setattr( - model_inventory.proc, - "run", - lambda argv, **kwargs: agents.proc.Result(0, _cursor_listing("cursor-grok-4.6-high"), ""), + _patch_cursor_inventory( + monkeypatch, + agents.proc.Result(0, _cursor_listing("cursor-grok-4.6-high"), ""), ) result = model_inventory.ModelInventoryInspector().inspect("cursor", "grok-4.5-xhigh") @@ -66,10 +67,9 @@ def test_cursor_inventory_different_version_is_missing(monkeypatch): def test_cursor_inventory_requires_versioned_family_for_fuzzy_match(monkeypatch): - monkeypatch.setattr( - model_inventory.proc, - "run", - lambda argv, **kwargs: agents.proc.Result(0, _cursor_listing("cursor-auto-low"), ""), + _patch_cursor_inventory( + monkeypatch, + agents.proc.Result(0, _cursor_listing("cursor-auto-low"), ""), ) result = model_inventory.ModelInventoryInspector().inspect("cursor", "auto-high") @@ -80,11 +80,7 @@ def test_cursor_inventory_requires_versioned_family_for_fuzzy_match(monkeypatch) def test_cursor_inventory_command_failure_is_unavailable(monkeypatch): - monkeypatch.setattr( - model_inventory.proc, - "run", - lambda argv, **kwargs: agents.proc.Result(1, "", "not logged in"), - ) + _patch_cursor_inventory(monkeypatch, agents.proc.Result(1, "", "not logged in")) result = model_inventory.ModelInventoryInspector().inspect("cursor", "composer-2.5") @@ -94,11 +90,7 @@ def test_cursor_inventory_command_failure_is_unavailable(monkeypatch): def test_cursor_inventory_unrecognized_output_is_unavailable(monkeypatch): - monkeypatch.setattr( - model_inventory.proc, - "run", - lambda argv, **kwargs: agents.proc.Result(0, "model output changed\n", ""), - ) + _patch_cursor_inventory(monkeypatch, agents.proc.Result(0, "model output changed\n", "")) result = model_inventory.ModelInventoryInspector().inspect("cursor", "composer-2.5") @@ -108,11 +100,7 @@ def test_cursor_inventory_unrecognized_output_is_unavailable(monkeypatch): def test_cursor_inventory_error_shaped_success_is_unavailable(monkeypatch): - monkeypatch.setattr( - model_inventory.proc, - "run", - lambda argv, **kwargs: agents.proc.Result(0, "Warning - authentication required\n", ""), - ) + _patch_cursor_inventory(monkeypatch, agents.proc.Result(0, "Warning - authentication required\n", "")) result = model_inventory.ModelInventoryInspector().inspect("cursor", "composer-2.5") @@ -122,11 +110,7 @@ def test_cursor_inventory_error_shaped_success_is_unavailable(monkeypatch): def test_cursor_inventory_accepts_parameterized_exact_model(monkeypatch): base_model = "claude-opus-4-8-thinking-high" - monkeypatch.setattr( - model_inventory.proc, - "run", - lambda argv, **kwargs: agents.proc.Result(0, _cursor_listing(base_model), ""), - ) + _patch_cursor_inventory(monkeypatch, agents.proc.Result(0, _cursor_listing(base_model), "")) result = model_inventory.ModelInventoryInspector().inspect( "cursor", @@ -141,11 +125,11 @@ def test_cursor_inventory_accepts_parameterized_exact_model(monkeypatch): def test_cursor_inventory_is_loaded_once_for_repeated_seats(monkeypatch): calls = [] - def fake_run(argv, **kwargs): - calls.append(argv) + def fake_run(): + calls.append(["cursor-agent", "models"]) return agents.proc.Result(0, _cursor_listing("composer-2.5", "gpt-5.5-high"), "") - monkeypatch.setattr(model_inventory.proc, "run", fake_run) + monkeypatch.setattr(model_inventory, "_run_cursor_inventory", fake_run, raising=False) inspector = model_inventory.ModelInventoryInspector() assert inspector.inspect("cursor", "composer-2.5").state == "exact" @@ -153,6 +137,94 @@ def fake_run(argv, **kwargs): assert calls == [["cursor-agent", "models"]] +def test_cursor_inventory_uses_regular_file_capture(monkeypatch): + listing = _cursor_listing("composer-2.5", "kimi-k2.7-code", "glm-5.2-high") + + def fake_run(argv, **kwargs): + assert kwargs["stdout"] is not subprocess.PIPE + kwargs["stdout"].write(listing.encode()) + return subprocess.CompletedProcess(argv, 0, stderr=b"") + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = model_inventory._run_cursor_inventory() + + assert result.code == 0 + assert result.stdout == listing + + +def test_cursor_inventory_is_stable_across_repeated_probes(monkeypatch): + calls = [] + listing = _cursor_listing("composer-2.5", "kimi-k2.7-code", "glm-5.2-high") + + def fake_run(argv, **kwargs): + calls.append(argv) + assert kwargs["stdout"] is not subprocess.PIPE + kwargs["stdout"].write(listing.encode()) + return subprocess.CompletedProcess(argv, 0, stderr=b"") + + monkeypatch.setattr(subprocess, "run", fake_run) + + for requested in ("kimi-k2.7-code", "glm-5.2-high"): + for _ in range(3): + result = model_inventory.ModelInventoryInspector().inspect("cursor", requested) + assert result is not None + assert result.state == "exact" + + assert calls == [["cursor-agent", "models"]] * 6 + + +def test_cursor_inventory_truncated_listing_is_unavailable(monkeypatch): + _patch_cursor_inventory( + monkeypatch, + agents.proc.Result( + 0, + "Available models\n\ncomposer-2.5 - Composer 2.5\ngpt-5.", + "", + ), + ) + + result = model_inventory.ModelInventoryInspector().inspect("cursor", "kimi-k2.7-code") + + assert result is not None + assert result.state == "unavailable" + assert "unrecognized inventory shape" in result.detail + + +def test_cursor_inventory_completed_listing_with_malformed_row_is_unavailable(monkeypatch): + _patch_cursor_inventory( + monkeypatch, + agents.proc.Result( + 0, + "Available models\n\ncomposer-2.5 - Composer 2.5\nbroken row\n\nTip: use --model to switch.\n", + "", + ), + ) + + result = model_inventory.ModelInventoryInspector().inspect("cursor", "kimi-k2.7-code") + + assert result is not None + assert result.state == "unavailable" + assert "unrecognized inventory shape" in result.detail + + +def test_cursor_inventory_stderr_cannot_complete_truncated_stdout(monkeypatch): + _patch_cursor_inventory( + monkeypatch, + agents.proc.Result( + 0, + "Available models\n\ncomposer-2.5 - Composer 2.5\ngpt-5.", + "Tip: use --model to switch.\n", + ), + ) + + result = model_inventory.ModelInventoryInspector().inspect("cursor", "kimi-k2.7-code") + + assert result is not None + assert result.state == "unavailable" + assert "unrecognized inventory shape" in result.detail + + def test_grok_inventory_parses_exact_and_fuzzy_models(monkeypatch): monkeypatch.setattr( model_inventory.proc, diff --git a/tests/test_roster_cmd.py b/tests/test_roster_cmd.py index f0034de8..9e949131 100644 --- a/tests/test_roster_cmd.py +++ b/tests/test_roster_cmd.py @@ -390,15 +390,18 @@ def test_roster_doctor_reuses_inventory_for_repeated_harness_seats(monkeypatch, monkeypatch.setattr(agents.proc, "which", lambda cmd: "/x/" + cmd) calls = [] - def fake_run(argv, **kwargs): - calls.append(argv) + def fake_run(): + calls.append(["cursor-agent", "models"]) return agents.proc.Result( 0, - "Available models\n\ncomposer-2.5 - Composer 2.5\ngpt-5.5-high - GPT-5.5 High\n", + "Available models\n\n" + "composer-2.5 - Composer 2.5\n" + "gpt-5.5-high - GPT-5.5 High\n\n" + "Tip: use --model to switch.\n", "", ) - monkeypatch.setattr(model_inventory.proc, "run", fake_run) + monkeypatch.setattr(model_inventory, "_run_cursor_inventory", fake_run) assert roster_cmd.doctor(tmp_target) == 0 assert capsys.readouterr().out.count("model inventory") == 2