From ce1a1e8349c2431aeb65c9d277335e372ae224fb Mon Sep 17 00:00:00 2001 From: Sam Rausser Date: Fri, 27 Mar 2026 16:05:41 -0700 Subject: [PATCH 1/5] fix(pm): honor configured interview adapter backend --- src/ouroboros/cli/commands/pm.py | 4 +-- tests/unit/cli/test_pm_runtime_adapter.py | 40 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cli/test_pm_runtime_adapter.py diff --git a/src/ouroboros/cli/commands/pm.py b/src/ouroboros/cli/commands/pm.py index 4d2f8ec2..89abda0b 100644 --- a/src/ouroboros/cli/commands/pm.py +++ b/src/ouroboros/cli/commands/pm.py @@ -20,6 +20,7 @@ from ouroboros.cli.formatters import console from ouroboros.cli.formatters.panels import print_error, print_info, print_success, print_warning from ouroboros.core.types import Result +from ouroboros.providers.factory import create_llm_adapter app = typer.Typer( name="pm", @@ -316,9 +317,8 @@ async def _run_pm_interview( output_dir: Optional output directory for the generated PM document. """ from ouroboros.bigbang.pm_interview import PMInterviewEngine - from ouroboros.providers.litellm_adapter import LiteLLMAdapter - adapter = LiteLLMAdapter() + adapter = create_llm_adapter(use_case="interview") engine = PMInterviewEngine.create(llm_adapter=adapter, model=model) # Check for existing PM seeds before starting a new session diff --git a/tests/unit/cli/test_pm_runtime_adapter.py b/tests/unit/cli/test_pm_runtime_adapter.py new file mode 100644 index 00000000..955af42a --- /dev/null +++ b/tests/unit/cli/test_pm_runtime_adapter.py @@ -0,0 +1,40 @@ +"""Tests for PM CLI adapter selection.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import typer + +from ouroboros.cli.commands.pm import _run_pm_interview + + +def test_run_pm_interview_uses_factory_for_interview_adapter() -> None: + """PM should honor the shared backend factory instead of hardcoding LiteLLM.""" + sentinel_adapter = object() + engine = SimpleNamespace( + load_state=AsyncMock(return_value=SimpleNamespace(is_err=True, error="boom")), + ) + + with ( + patch("ouroboros.cli.commands.pm.create_llm_adapter", return_value=sentinel_adapter) as mock_factory, + patch("ouroboros.bigbang.pm_interview.PMInterviewEngine.create", return_value=engine) as mock_create, + ): + try: + asyncio.run( + _run_pm_interview( + resume_id="session-123", + model="default", + debug=False, + output_dir=None, + ) + ) + except typer.Exit: + pass + else: + raise AssertionError("Expected typer.Exit when mocked load_state returns an error") + + mock_factory.assert_called_once_with(use_case="interview") + mock_create.assert_called_once_with(llm_adapter=sentinel_adapter, model="default") From 241466cb5a77113ecd86fe312c68e132091c9e8f Mon Sep 17 00:00:00 2001 From: Sam Rausser Date: Fri, 27 Mar 2026 16:08:31 -0700 Subject: [PATCH 2/5] style(test): format pm runtime adapter regression test --- tests/unit/cli/test_pm_runtime_adapter.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit/cli/test_pm_runtime_adapter.py b/tests/unit/cli/test_pm_runtime_adapter.py index 955af42a..5dfe9c4d 100644 --- a/tests/unit/cli/test_pm_runtime_adapter.py +++ b/tests/unit/cli/test_pm_runtime_adapter.py @@ -19,8 +19,12 @@ def test_run_pm_interview_uses_factory_for_interview_adapter() -> None: ) with ( - patch("ouroboros.cli.commands.pm.create_llm_adapter", return_value=sentinel_adapter) as mock_factory, - patch("ouroboros.bigbang.pm_interview.PMInterviewEngine.create", return_value=engine) as mock_create, + patch( + "ouroboros.cli.commands.pm.create_llm_adapter", return_value=sentinel_adapter + ) as mock_factory, + patch( + "ouroboros.bigbang.pm_interview.PMInterviewEngine.create", return_value=engine + ) as mock_create, ): try: asyncio.run( From 134ce088f65551945e9314e055654cfcfe863188 Mon Sep 17 00:00:00 2001 From: Sam Rausser Date: Fri, 27 Mar 2026 16:26:13 -0700 Subject: [PATCH 3/5] fix(pm): align interview runtime with backend config --- src/ouroboros/cli/commands/pm.py | 61 +++++++-- tests/unit/cli/test_pm_runtime_adapter.py | 147 +++++++++++++++++++++- 2 files changed, 195 insertions(+), 13 deletions(-) diff --git a/src/ouroboros/cli/commands/pm.py b/src/ouroboros/cli/commands/pm.py index 89abda0b..460a63d7 100644 --- a/src/ouroboros/cli/commands/pm.py +++ b/src/ouroboros/cli/commands/pm.py @@ -19,8 +19,13 @@ from ouroboros.bigbang.interview import InterviewRound from ouroboros.cli.formatters import console from ouroboros.cli.formatters.panels import print_error, print_info, print_success, print_warning +from ouroboros.config import get_clarification_model, get_llm_backend from ouroboros.core.types import Result -from ouroboros.providers.factory import create_llm_adapter +from ouroboros.providers.factory import ( + create_llm_adapter, + resolve_llm_backend, + resolve_llm_permission_mode, +) app = typer.Typer( name="pm", @@ -50,13 +55,13 @@ def pm_command( ), ] = None, model: Annotated[ - str, + str | None, typer.Option( "--model", "-m", help="LLM model to use for the PM interview.", ), - ] = "anthropic/claude-sonnet-4-20250514", + ] = None, debug: Annotated[ bool, typer.Option( @@ -90,17 +95,33 @@ def pm_command( else: print_info("Starting new PM interview session...") - console.print(f" Model: [dim]{model}[/]\n") + resolved_backend = resolve_llm_backend(get_llm_backend()) + resolved_model = model or get_clarification_model(resolved_backend) + permission_mode = resolve_llm_permission_mode( + backend=resolved_backend, + use_case="interview", + ) + + console.print(f" Model: [dim]{resolved_model}[/]\n") + if permission_mode == "bypassPermissions": + print_warning( + "Interview backend " + f"'{resolved_backend}' uses bypassPermissions for question generation." + ) try: asyncio.run( _run_pm_interview( resume_id=resume, - model=model, + model=resolved_model, + backend=resolved_backend, debug=debug, output_dir=output, ) ) + except ValueError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc except KeyboardInterrupt: print_info("\nPM interview interrupted. Progress has been saved.") raise typer.Exit(code=0) @@ -299,10 +320,28 @@ def _save_cli_pm_meta(session_id: str, engine: Any) -> None: meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") +def _make_message_callback(debug: bool): + """Create a debug callback for streaming local agent status.""" + if not debug: + return None + + def callback(msg_type: str, content: str) -> None: + if msg_type == "thinking": + first_line = content.split("\n")[0].strip() + display = first_line[:100] + "..." if len(first_line) > 100 else first_line + if display: + console.print(f" [dim]thinking:[/] {display}") + elif msg_type == "tool": + console.print(f" [yellow]tool:[/] {content}") + + return callback + + async def _run_pm_interview( resume_id: str | None, model: str, - debug: bool, # noqa: ARG001 + backend: str | None, + debug: bool, output_dir: str | None = None, ) -> None: """Run the PM interview loop. @@ -313,12 +352,20 @@ async def _run_pm_interview( Args: resume_id: Optional session ID to resume. model: LLM model identifier. + backend: Resolved LLM backend name. debug: Enable debug output. output_dir: Optional output directory for the generated PM document. """ from ouroboros.bigbang.pm_interview import PMInterviewEngine - adapter = create_llm_adapter(use_case="interview") + adapter = create_llm_adapter( + backend=backend, + use_case="interview", + allowed_tools=None, + max_turns=5, + on_message=_make_message_callback(debug), + cwd=Path.cwd(), + ) engine = PMInterviewEngine.create(llm_adapter=adapter, model=model) # Check for existing PM seeds before starting a new session diff --git a/tests/unit/cli/test_pm_runtime_adapter.py b/tests/unit/cli/test_pm_runtime_adapter.py index 5dfe9c4d..d9094e73 100644 --- a/tests/unit/cli/test_pm_runtime_adapter.py +++ b/tests/unit/cli/test_pm_runtime_adapter.py @@ -1,18 +1,20 @@ -"""Tests for PM CLI adapter selection.""" +"""Tests for PM CLI adapter selection and runtime wiring.""" from __future__ import annotations import asyncio +from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import typer -from ouroboros.cli.commands.pm import _run_pm_interview +from ouroboros.cli.commands.pm import _run_pm_interview, pm_command +from ouroboros.core.types import Result -def test_run_pm_interview_uses_factory_for_interview_adapter() -> None: - """PM should honor the shared backend factory instead of hardcoding LiteLLM.""" +def test_run_pm_interview_uses_factory_for_interview_adapter_on_resume() -> None: + """Resume mode should build the adapter through the shared interview factory.""" sentinel_adapter = object() engine = SimpleNamespace( load_state=AsyncMock(return_value=SimpleNamespace(is_err=True, error="boom")), @@ -31,6 +33,7 @@ def test_run_pm_interview_uses_factory_for_interview_adapter() -> None: _run_pm_interview( resume_id="session-123", model="default", + backend="codex", debug=False, output_dir=None, ) @@ -40,5 +43,137 @@ def test_run_pm_interview_uses_factory_for_interview_adapter() -> None: else: raise AssertionError("Expected typer.Exit when mocked load_state returns an error") - mock_factory.assert_called_once_with(use_case="interview") + mock_factory.assert_called_once_with( + backend="codex", + use_case="interview", + allowed_tools=None, + max_turns=5, + on_message=None, + cwd=Path.cwd(), + ) + mock_create.assert_called_once_with(llm_adapter=sentinel_adapter, model="default") + + +def test_run_pm_interview_uses_interview_runtime_options_on_new_session() -> None: + """New sessions should pass backend-aware interview options into the factory.""" + sentinel_adapter = object() + state = SimpleNamespace( + interview_id="pm-session-123", + is_complete=True, + rounds=[], + ) + engine = SimpleNamespace( + get_opening_question=lambda: "What do you want to build?", + ask_opening_and_start=AsyncMock(return_value=Result.ok(state)), + deferred_items=[], + decide_later_items=[], + codebase_context="", + format_decide_later_summary=lambda: "", + _reframe_map={}, + _selected_brownfield_repos=[], + classifications=[], + ) + + with ( + patch( + "ouroboros.cli.commands.pm.create_llm_adapter", return_value=sentinel_adapter + ) as mock_factory, + patch( + "ouroboros.bigbang.pm_interview.PMInterviewEngine.create", return_value=engine + ) as mock_create, + patch("ouroboros.cli.commands.pm._check_existing_pm_seeds", return_value=True), + patch("ouroboros.cli.commands.pm._load_brownfield_from_db", return_value=[]), + patch("ouroboros.cli.commands.pm._select_repos", return_value=[]), + patch("ouroboros.cli.commands.pm._save_cli_pm_meta"), + patch("ouroboros.cli.commands.pm.console.input", return_value="Build a PM workflow"), + ): + asyncio.run( + _run_pm_interview( + resume_id=None, + model="default", + backend="codex", + debug=True, + output_dir=None, + ) + ) + + mock_factory.assert_called_once() + factory_kwargs = mock_factory.call_args.kwargs + assert factory_kwargs["backend"] == "codex" + assert factory_kwargs["use_case"] == "interview" + assert factory_kwargs["allowed_tools"] is None + assert factory_kwargs["max_turns"] == 5 + assert callable(factory_kwargs["on_message"]) + assert factory_kwargs["cwd"] == Path.cwd() mock_create.assert_called_once_with(llm_adapter=sentinel_adapter, model="default") + engine.ask_opening_and_start.assert_called_once_with( + user_response="Build a PM workflow", + brownfield_repos=None, + ) + + +def test_pm_command_uses_backend_safe_default_model() -> None: + """CLI entrypoint should normalize the default model for the configured backend.""" + ctx = SimpleNamespace(invoked_subcommand=None) + + with ( + patch("ouroboros.cli.commands.pm.get_llm_backend", return_value="codex"), + patch("ouroboros.cli.commands.pm.get_clarification_model", return_value="default"), + patch("ouroboros.cli.commands.pm.resolve_llm_backend", return_value="codex"), + patch( + "ouroboros.cli.commands.pm.resolve_llm_permission_mode", + return_value="bypassPermissions", + ), + patch("ouroboros.cli.commands.pm._run_pm_interview", new=Mock(return_value=object())) as mock_run, + patch("ouroboros.cli.commands.pm.asyncio.run"), + patch("ouroboros.cli.commands.pm.print_warning") as mock_warning, + ): + pm_command( + ctx=ctx, + resume=None, + output=None, + model=None, + debug=False, + ) + + mock_run.assert_called_once_with( + resume_id=None, + model="default", + backend="codex", + debug=False, + output_dir=None, + ) + mock_warning.assert_called_once() + assert "bypassPermissions" in mock_warning.call_args.args[0] + + +def test_pm_command_formats_factory_errors() -> None: + """Backend/config errors should exit cleanly instead of surfacing a traceback.""" + ctx = SimpleNamespace(invoked_subcommand=None) + + with ( + patch("ouroboros.cli.commands.pm.get_llm_backend", return_value="opencode"), + patch("ouroboros.cli.commands.pm.get_clarification_model", return_value="default"), + patch("ouroboros.cli.commands.pm.resolve_llm_backend", return_value="opencode"), + patch("ouroboros.cli.commands.pm.resolve_llm_permission_mode", return_value="acceptEdits"), + patch("ouroboros.cli.commands.pm._run_pm_interview", new=Mock(return_value=object())), + patch( + "ouroboros.cli.commands.pm.asyncio.run", + side_effect=ValueError("Unsupported LLM backend: opencode"), + ), + patch("ouroboros.cli.commands.pm.print_error") as mock_error, + ): + try: + pm_command( + ctx=ctx, + resume=None, + output=None, + model=None, + debug=False, + ) + except typer.Exit as exc: + assert exc.exit_code == 1 + else: + raise AssertionError("Expected typer.Exit for backend configuration errors") + + mock_error.assert_called_once_with("Unsupported LLM backend: opencode") From 455d0ab87bef787704b50537b430b2cdb960d546 Mon Sep 17 00:00:00 2001 From: Sam Rausser Date: Fri, 27 Mar 2026 16:28:06 -0700 Subject: [PATCH 4/5] style(test): format pm runtime adapter test --- tests/unit/cli/test_pm_runtime_adapter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/cli/test_pm_runtime_adapter.py b/tests/unit/cli/test_pm_runtime_adapter.py index d9094e73..e7e3a1c0 100644 --- a/tests/unit/cli/test_pm_runtime_adapter.py +++ b/tests/unit/cli/test_pm_runtime_adapter.py @@ -124,7 +124,9 @@ def test_pm_command_uses_backend_safe_default_model() -> None: "ouroboros.cli.commands.pm.resolve_llm_permission_mode", return_value="bypassPermissions", ), - patch("ouroboros.cli.commands.pm._run_pm_interview", new=Mock(return_value=object())) as mock_run, + patch( + "ouroboros.cli.commands.pm._run_pm_interview", new=Mock(return_value=object()) + ) as mock_run, patch("ouroboros.cli.commands.pm.asyncio.run"), patch("ouroboros.cli.commands.pm.print_warning") as mock_warning, ): From 2449362a5a240c901e0bfbfd0b582014b5d5ffb9 Mon Sep 17 00:00:00 2001 From: Sam Rausser Date: Sat, 28 Mar 2026 07:48:16 -0700 Subject: [PATCH 5/5] fix(pm): format invalid backend errors --- src/ouroboros/cli/commands/pm.py | 26 +++++++++++------------ tests/unit/cli/test_pm_runtime_adapter.py | 11 +++------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/ouroboros/cli/commands/pm.py b/src/ouroboros/cli/commands/pm.py index 460a63d7..846a390c 100644 --- a/src/ouroboros/cli/commands/pm.py +++ b/src/ouroboros/cli/commands/pm.py @@ -95,21 +95,21 @@ def pm_command( else: print_info("Starting new PM interview session...") - resolved_backend = resolve_llm_backend(get_llm_backend()) - resolved_model = model or get_clarification_model(resolved_backend) - permission_mode = resolve_llm_permission_mode( - backend=resolved_backend, - use_case="interview", - ) - - console.print(f" Model: [dim]{resolved_model}[/]\n") - if permission_mode == "bypassPermissions": - print_warning( - "Interview backend " - f"'{resolved_backend}' uses bypassPermissions for question generation." + try: + resolved_backend = resolve_llm_backend(get_llm_backend()) + resolved_model = model or get_clarification_model(resolved_backend) + permission_mode = resolve_llm_permission_mode( + backend=resolved_backend, + use_case="interview", ) - try: + console.print(f" Model: [dim]{resolved_model}[/]\n") + if permission_mode == "bypassPermissions": + print_warning( + "Interview backend " + f"'{resolved_backend}' uses bypassPermissions for question generation." + ) + asyncio.run( _run_pm_interview( resume_id=resume, diff --git a/tests/unit/cli/test_pm_runtime_adapter.py b/tests/unit/cli/test_pm_runtime_adapter.py index e7e3a1c0..d9e09416 100644 --- a/tests/unit/cli/test_pm_runtime_adapter.py +++ b/tests/unit/cli/test_pm_runtime_adapter.py @@ -156,13 +156,6 @@ def test_pm_command_formats_factory_errors() -> None: with ( patch("ouroboros.cli.commands.pm.get_llm_backend", return_value="opencode"), patch("ouroboros.cli.commands.pm.get_clarification_model", return_value="default"), - patch("ouroboros.cli.commands.pm.resolve_llm_backend", return_value="opencode"), - patch("ouroboros.cli.commands.pm.resolve_llm_permission_mode", return_value="acceptEdits"), - patch("ouroboros.cli.commands.pm._run_pm_interview", new=Mock(return_value=object())), - patch( - "ouroboros.cli.commands.pm.asyncio.run", - side_effect=ValueError("Unsupported LLM backend: opencode"), - ), patch("ouroboros.cli.commands.pm.print_error") as mock_error, ): try: @@ -178,4 +171,6 @@ def test_pm_command_formats_factory_errors() -> None: else: raise AssertionError("Expected typer.Exit for backend configuration errors") - mock_error.assert_called_once_with("Unsupported LLM backend: opencode") + mock_error.assert_called_once_with( + "OpenCode LLM adapter is not yet available. Supported backends: claude_code, codex, litellm" + )