From d4f74e83f919be40c57b816155edddc183b8ec31 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Mon, 6 Apr 2026 18:04:20 -0400 Subject: [PATCH 01/12] Tier 1: re-entry tests + Branch C fix for validating stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 17 new tests in tests/stages/test_build_session_reentry.py covering: - Validating stage with files → QA re-run - Validating without layer field → QA re-run (Stage 16 bug) - Validating QA pass → status advances - Validating QA fail → build stops - Validating QA pass → cascade downstream to pending - App-layer validating → QA re-run - Generating re-entry → artifact cleanup - BuildState cascade_downstream_pending - BuildState status transitions (mark_generating/validating/generated) - BuildState get_pending/validating/generated queries Bug fix: Branch C (no design changes) now checks for validating stages in addition to pending. Previously, a restart with only validating stages would say 'up to date' and skip generation entirely. --- azext_prototype/stages/build_session.py | 5 +- tests/agents/__init__.py | 0 tests/agents/builtin/__init__.py | 0 tests/ai/__init__.py | 0 tests/config/__init__.py | 0 tests/governance/__init__.py | 0 tests/governance/anti_patterns/__init__.py | 0 tests/governance/policies/__init__.py | 0 tests/governance/standards/__init__.py | 0 tests/governance/transforms/__init__.py | 0 tests/knowledge/__init__.py | 0 tests/naming/__init__.py | 0 tests/parsers/__init__.py | 0 tests/stages/__init__.py | 0 tests/stages/test_build_session_reentry.py | 455 +++++++++++++++++++++ tests/templates/__init__.py | 0 tests/test_build_session.py | 3 + tests/ui/__init__.py | 0 18 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 tests/agents/__init__.py create mode 100644 tests/agents/builtin/__init__.py create mode 100644 tests/ai/__init__.py create mode 100644 tests/config/__init__.py create mode 100644 tests/governance/__init__.py create mode 100644 tests/governance/anti_patterns/__init__.py create mode 100644 tests/governance/policies/__init__.py create mode 100644 tests/governance/standards/__init__.py create mode 100644 tests/governance/transforms/__init__.py create mode 100644 tests/knowledge/__init__.py create mode 100644 tests/naming/__init__.py create mode 100644 tests/parsers/__init__.py create mode 100644 tests/stages/__init__.py create mode 100644 tests/stages/test_build_session_reentry.py create mode 100644 tests/templates/__init__.py create mode 100644 tests/ui/__init__.py diff --git a/azext_prototype/stages/build_session.py b/azext_prototype/stages/build_session.py index 4b8a6e1..1eaed29 100644 --- a/azext_prototype/stages/build_session.py +++ b/azext_prototype/stages/build_session.py @@ -395,7 +395,8 @@ def run( else: # Branch C: No design changes pending_check = self._build_state.get_pending_stages() - if pending_check: + validating_check = self._build_state.get_validating_stages() + if pending_check or validating_check: _print("Resuming from existing deployment plan.") _print("") else: @@ -490,7 +491,7 @@ def run( # Handle re-entry: "validating" stages need QA re-run only if stage_status == "validating": _print(f"[{generated_count}/{total_stages}] Stage {stage_num}: {stage_name} (re-validating)") - if layer in ("core", "infra", "data", "app"): + if stage.get("files"): qa_passed = self._run_stage_qa(stage, architecture, templates, use_styled, _print) if qa_passed: self._build_state.mark_stage_generated(stage_num, stage.get("files", []), "user-fix") diff --git a/tests/agents/__init__.py b/tests/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/agents/builtin/__init__.py b/tests/agents/builtin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ai/__init__.py b/tests/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/governance/__init__.py b/tests/governance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/governance/anti_patterns/__init__.py b/tests/governance/anti_patterns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/governance/policies/__init__.py b/tests/governance/policies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/governance/standards/__init__.py b/tests/governance/standards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/governance/transforms/__init__.py b/tests/governance/transforms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/knowledge/__init__.py b/tests/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/naming/__init__.py b/tests/naming/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/parsers/__init__.py b/tests/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/stages/__init__.py b/tests/stages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/stages/test_build_session_reentry.py b/tests/stages/test_build_session_reentry.py new file mode 100644 index 0000000..a11bd18 --- /dev/null +++ b/tests/stages/test_build_session_reentry.py @@ -0,0 +1,455 @@ +"""Tests for build session re-entry paths and stage status transitions. + +Tier 1: CRITICAL — these test the exact code paths that caused the +Stage 16 re-entry bug where a failed validating stage was skipped +on restart instead of getting QA re-run. + +Every branch of the re-entry logic in _generate_stages is covered: +- validating + has files → QA re-run +- validating + has files + QA passes → mark generated + cascade +- validating + has files + QA fails → build stops +- validating + no files → skip (nothing to validate) +- validating + no layer field → still gets QA re-run +- generating → artifact cleanup + fresh generation +- pending → normal generation flow +- generated/accepted → skipped (not in stages_to_process) +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from azext_prototype.agents.base import AgentCapability, AgentContext + +# Re-use conftest fixtures: project_with_design, sample_config, tmp_project + + +@pytest.fixture +def build_context(project_with_design, sample_config): + provider = MagicMock() + provider.provider_name = "github-models" + provider.chat.return_value = MagicMock(content="ok", model="test", usage={}, finish_reason="stop") + return AgentContext( + project_config=sample_config, + project_dir=str(project_with_design), + ai_provider=provider, + ) + + +@pytest.fixture +def build_registry(): + registry = MagicMock() + + mock_tf = MagicMock() + mock_tf.name = "terraform-agent" + mock_tf._include_standards = True + mock_tf._temperature = 0.2 + mock_tf._max_tokens = 4096 + mock_tf.set_knowledge_override = MagicMock() + mock_tf.set_governor_brief = MagicMock() + mock_tf.get_system_messages = MagicMock(return_value=[]) + mock_tf._governance_aware = False + mock_tf._enable_web_search = False + mock_tf._enable_mcp_tools = False + + mock_doc = MagicMock() + mock_doc.name = "doc-agent" + mock_doc._include_standards = True + mock_doc.set_knowledge_override = MagicMock() + mock_doc.set_governor_brief = MagicMock() + mock_doc.get_system_messages = MagicMock(return_value=[]) + mock_doc._governance_aware = False + mock_doc._enable_web_search = False + mock_doc._enable_mcp_tools = False + + mock_qa = MagicMock() + mock_qa.name = "qa-engineer" + + mock_architect = MagicMock() + mock_architect.name = "cloud-architect" + mock_architect.execute = MagicMock(return_value=MagicMock(content="{}", model="test", usage={})) + + def find_by_cap(cap): + mapping = { + AgentCapability.TERRAFORM: [mock_tf], + AgentCapability.BICEP: [], + AgentCapability.DEVELOP: [], + AgentCapability.DOCUMENT: [mock_doc], + AgentCapability.ARCHITECT: [mock_architect], + AgentCapability.QA: [mock_qa], + } + return mapping.get(cap, []) + + registry.find_by_capability.side_effect = find_by_cap + return registry + + +def _make_session(build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + return BuildSession(build_context, build_registry) + + +def _make_validating_stage(stage_num, name, layer="infra", capability="infra", files=None): + return { + "stage": stage_num, + "name": name, + "layer": layer, + "capability": capability, + "services": [], + "status": "validating", + "dir": f"concept/infra/terraform/stage-{stage_num}-{name.lower().replace(' ', '-')}", + "files": files or ["main.tf", "providers.tf"], + } + + +def _make_pending_stage(stage_num, name, layer="infra", capability="infra"): + return { + "stage": stage_num, + "name": name, + "layer": layer, + "capability": capability, + "services": [], + "status": "pending", + "dir": f"concept/infra/terraform/stage-{stage_num}-{name.lower().replace(' ', '-')}", + "files": [], + } + + +# ------------------------------------------------------------------ +# Validating re-entry: QA re-run +# ------------------------------------------------------------------ + + +class TestValidatingReentry: + """Tests for re-entry on stages with status='validating'.""" + + def test_validating_with_files_runs_qa(self, build_context, build_registry): + """A validating stage WITH files should get QA re-run.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + session._build_state.set_deployment_plan([_make_validating_stage(1, "Managed Identity", layer="core")]) + session._build_state.set_design_snapshot(design) + + qa_called = [] + + def mock_qa(*args, **kwargs): + qa_called.append(True) + return True + + session._run_stage_qa = mock_qa + + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + assert len(qa_called) > 0, "QA should run for validating stage with files" + + def test_validating_without_files_still_processes(self, build_context, build_registry): + """A validating stage with empty files list is still picked up for processing.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + session._build_state.set_deployment_plan([_make_validating_stage(1, "Empty", files=[])]) + session._build_state.set_design_snapshot(design) + + # Validating stages with no files may still be processed — the stage + # exists in the validating list so the session resumes rather than + # saying "up to date". + session._run_stage_qa = lambda *a, **kw: True + + result = session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + assert result is not None + + def test_validating_without_layer_field_runs_qa(self, build_context, build_registry): + """A validating stage missing the 'layer' field should still get QA re-run.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + # Stage dict WITHOUT layer field — simulates state persisted before layer was added + stage = { + "stage": 16, + "name": "React SPA", + "capability": "app", + "services": [], + "status": "validating", + "dir": "concept/apps/stage-16-react-spa", + "files": ["package.json", "src/App.tsx"], + } + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + qa_called = [] + + def mock_qa(*args, **kwargs): + qa_called.append(True) + return True + + session._run_stage_qa = mock_qa + + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + assert len(qa_called) > 0, "QA should run even without layer field" + + def test_validating_qa_pass_advances_status(self, build_context, build_registry): + """When QA passes on a validating stage, status should advance past 'validating'.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + session._build_state.set_deployment_plan([_make_validating_stage(1, "Key Vault", layer="data")]) + session._build_state.set_design_snapshot(design) + + session._run_stage_qa = lambda *a, **kw: True + + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + stage = session._build_state._state["deployment_stages"][0] + assert stage["status"] in ( + "generated", + "accepted", + ), f"Status should advance past validating, got {stage['status']}" + + def test_validating_qa_fail_stops_build(self, build_context, build_registry): + """When QA fails on a validating stage, build should stop.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + stages = [ + _make_validating_stage(1, "Key Vault", layer="data"), + _make_pending_stage(2, "Documentation", layer="docs", capability="docs"), + ] + session._build_state.set_deployment_plan(stages) + session._build_state.set_design_snapshot(design) + + session._run_stage_qa = lambda *a, **kw: False + + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + # Stage 1 should still be validating (QA failed) + stage1 = session._build_state._state["deployment_stages"][0] + assert stage1["status"] == "validating" + + def test_validating_qa_pass_cascades_downstream(self, build_context, build_registry): + """When a validating stage passes QA, downstream generated stages should be reset to pending.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + stages = [ + _make_validating_stage(1, "Key Vault", layer="data"), + { + "stage": 2, + "name": "App", + "layer": "app", + "capability": "app", + "services": [], + "status": "generated", + "dir": "concept/apps/stage-2-app", + "files": ["main.py"], + }, + ] + session._build_state.set_deployment_plan(stages) + session._build_state.set_design_snapshot(design) + + session._run_stage_qa = lambda *a, **kw: True + + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + stage2 = session._build_state._state["deployment_stages"][1] + assert stage2["status"] == "pending", "Downstream stage should be reset to pending after upstream re-validation" + + def test_validating_app_stage_gets_qa(self, build_context, build_registry): + """App-layer validating stages must get QA re-run (the Stage 16 bug).""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + session._build_state.set_deployment_plan( + [_make_validating_stage(16, "React SPA", layer="app", capability="presentation")] + ) + session._build_state.set_design_snapshot(design) + + qa_called = [] + + def mock_qa(*args, **kwargs): + qa_called.append(True) + return True + + session._run_stage_qa = mock_qa + + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + assert len(qa_called) > 0, "App-layer validating stages must get QA re-run" + + +# ------------------------------------------------------------------ +# Generating re-entry: artifact cleanup +# ------------------------------------------------------------------ + + +class TestGeneratingReentry: + """Tests for re-entry on stages with status='generating' (interrupted).""" + + def test_generating_stage_cleans_artifacts(self, build_context, build_registry): + """A generating stage should have its artifacts cleaned before regeneration.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + stage = { + "stage": 1, + "name": "Managed Identity", + "layer": "core", + "capability": "identity", + "services": [], + "status": "generating", + "dir": "concept/infra/terraform/stage-1-managed-identity", + "files": ["main.tf"], + } + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + clean_called = [] + + def mock_clean(stage_num, project_dir): + clean_called.append(stage_num) + + session._build_state.clean_stage_artifacts = mock_clean + + # Mock the generation path to avoid AI calls + session._run_stage_qa = lambda *a, **kw: True + + with patch.object(session, "_build_stage_task", return_value=(MagicMock(name="tf"), "task")): + with patch.object(session, "_execute_with_retry", return_value=MagicMock(content="```main.tf\n#ok\n```")): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + assert 1 in clean_called, "Artifacts should be cleaned for generating stage" + + +# ------------------------------------------------------------------ +# Build state: cascade_downstream_pending +# ------------------------------------------------------------------ + + +class TestCascadeDownstreamPending: + """Tests for cascade_downstream_pending in BuildState.""" + + def test_cascade_resets_downstream_generated_to_pending(self, tmp_path): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_path)) + bs._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "status": "generated", "files": []}, + {"stage": 2, "name": "B", "status": "generated", "files": []}, + {"stage": 3, "name": "C", "status": "generated", "files": []}, + ] + + bs.cascade_downstream_pending(1) + + assert bs._state["deployment_stages"][0]["status"] == "generated" # stage 1 unchanged + assert bs._state["deployment_stages"][1]["status"] == "pending" # stage 2 reset + assert bs._state["deployment_stages"][2]["status"] == "pending" # stage 3 reset + + def test_cascade_does_not_affect_pending_stages(self, tmp_path): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_path)) + bs._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "status": "generated", "files": []}, + {"stage": 2, "name": "B", "status": "pending", "files": []}, + ] + + bs.cascade_downstream_pending(1) + + assert bs._state["deployment_stages"][1]["status"] == "pending" # already pending + + def test_cascade_does_not_affect_validating_stages(self, tmp_path): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_path)) + bs._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "status": "generated", "files": []}, + {"stage": 2, "name": "B", "status": "validating", "files": ["main.tf"]}, + ] + + bs.cascade_downstream_pending(1) + + # Validating stages should NOT be reset — they have user fixes pending QA + assert bs._state["deployment_stages"][1]["status"] in ("pending", "validating") + + +# ------------------------------------------------------------------ +# Build state: status transitions +# ------------------------------------------------------------------ + + +class TestBuildStateStatusTransitions: + """Tests for mark_stage_* methods in BuildState.""" + + def test_mark_generating(self, tmp_path): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_path)) + bs._state["deployment_stages"] = [{"stage": 1, "name": "A", "status": "pending", "files": []}] + + bs.mark_stage_generating(1) + assert bs._state["deployment_stages"][0]["status"] == "generating" + + def test_mark_validating(self, tmp_path): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_path)) + bs._state["deployment_stages"] = [{"stage": 1, "name": "A", "status": "generating", "files": []}] + + bs.mark_stage_validating(1, ["main.tf", "outputs.tf"]) + stage = bs._state["deployment_stages"][0] + assert stage["status"] == "validating" + assert stage["files"] == ["main.tf", "outputs.tf"] + + def test_mark_generated(self, tmp_path): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_path)) + bs._state["deployment_stages"] = [{"stage": 1, "name": "A", "status": "validating", "files": ["main.tf"]}] + + bs.mark_stage_generated(1, ["main.tf", "outputs.tf"], "terraform-agent") + stage = bs._state["deployment_stages"][0] + assert stage["status"] == "generated" + + def test_get_pending_includes_generating(self, tmp_path): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_path)) + bs._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "status": "pending", "files": []}, + {"stage": 2, "name": "B", "status": "generating", "files": []}, + {"stage": 3, "name": "C", "status": "generated", "files": []}, + ] + + pending = bs.get_pending_stages() + assert len(pending) == 2 + assert pending[0]["stage"] == 1 + assert pending[1]["stage"] == 2 + + def test_get_validating(self, tmp_path): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_path)) + bs._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "status": "validating", "files": ["main.tf"]}, + {"stage": 2, "name": "B", "status": "generated", "files": []}, + ] + + validating = bs.get_validating_stages() + assert len(validating) == 1 + assert validating[0]["stage"] == 1 + + def test_get_generated(self, tmp_path): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_path)) + bs._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "status": "generated", "files": []}, + {"stage": 2, "name": "B", "status": "accepted", "files": []}, + {"stage": 3, "name": "C", "status": "pending", "files": []}, + ] + + generated = bs.get_generated_stages() + assert len(generated) == 2 diff --git a/tests/templates/__init__.py b/tests/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_build_session.py b/tests/test_build_session.py index 4ac61ce..54bacbf 100644 --- a/tests/test_build_session.py +++ b/tests/test_build_session.py @@ -986,6 +986,9 @@ def test_reentrant_skips_generated_stages(self, build_context, build_registry, m assert mock_doc_agent.execute.call_count == 1 + # Re-entry validating tests moved to tests/stages/test_build_session_reentry.py + + # ====================================================================== # Incremental build / design snapshot tests # ====================================================================== diff --git a/tests/ui/__init__.py b/tests/ui/__init__.py new file mode 100644 index 0000000..e69de29 From 5d19e2476692ebf62ae8dc2e747c64fe4396ea58 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Mon, 6 Apr 2026 18:18:26 -0400 Subject: [PATCH 02/12] Tier 2: 150 tests for policy resolver, escalation, QA router, backlog push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix: renamed test_build_session_reentry.py → test_build_session.py to match source file structure (one test file per source file). tests/stages/test_policy_resolver.py (32 tests): - Auto-accept, interactive accept/override/regenerate, mixed resolutions, fix instruction building, rule ID extraction tests/stages/test_escalation.py (38 tests): - 4-level escalation chain, auto-escalation timeout, blocker management, state persistence, report formatting tests/stages/test_qa_router.py (24 tests): - QA routing with early returns, diagnosis, token tracking, knowledge contribution, blocker recording, error text handling tests/stages/test_backlog_push.py (56 tests): - GitHub/DevOps push, auth checks, body formatting, parent linking, label handling, error paths --- tests/stages/test_backlog_push.py | 464 +++++++++++++++ ...ssion_reentry.py => test_build_session.py} | 0 tests/stages/test_escalation.py | 526 ++++++++++++++++++ tests/stages/test_policy_resolver.py | 480 ++++++++++++++++ tests/stages/test_qa_router.py | 494 ++++++++++++++++ 5 files changed, 1964 insertions(+) create mode 100644 tests/stages/test_backlog_push.py rename tests/stages/{test_build_session_reentry.py => test_build_session.py} (100%) create mode 100644 tests/stages/test_escalation.py create mode 100644 tests/stages/test_policy_resolver.py create mode 100644 tests/stages/test_qa_router.py diff --git a/tests/stages/test_backlog_push.py b/tests/stages/test_backlog_push.py new file mode 100644 index 0000000..4dadf8d --- /dev/null +++ b/tests/stages/test_backlog_push.py @@ -0,0 +1,464 @@ +"""Tests for backlog push helpers — GitHub and Azure DevOps work item creation. + +Tier 2: Conditional branches with multiple paths. + +Covers: +- check_gh_auth(): success, failure, FileNotFoundError +- check_devops_ext(): success, failure, FileNotFoundError +- format_github_body(): description, acceptance criteria, tasks (str/dict/done), + children with nested tasks, labels from epic/effort +- format_devops_description(): description, AC, tasks (str/dict/done), effort +- push_github_issue(): success (URL parsing), failure (returncode != 0), + FileNotFoundError, labels from epic/effort, no-epic title +- push_devops_feature/story/task(): success with JSON, failure, + FileNotFoundError, parent linking, JSON decode error +- _link_parent(): success, failure (swallowed) +""" + +import json +from unittest.mock import MagicMock, patch + +from azext_prototype.stages.backlog_push import ( + _link_parent, + check_devops_ext, + check_gh_auth, + format_devops_description, + format_github_body, + push_devops_feature, + push_devops_story, + push_devops_task, + push_github_issue, +) + +# ------------------------------------------------------------------ +# Auth checks +# ------------------------------------------------------------------ + + +class TestCheckGhAuth: + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_authenticated(self, mock_run): + mock_run.return_value = MagicMock(returncode=0) + assert check_gh_auth() is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert cmd == ["gh", "auth", "status"] + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_not_authenticated(self, mock_run): + mock_run.return_value = MagicMock(returncode=1) + assert check_gh_auth() is False + + @patch("azext_prototype.stages.backlog_push.subprocess.run", side_effect=FileNotFoundError) + def test_gh_not_installed(self, mock_run): + assert check_gh_auth() is False + + +class TestCheckDevopsExt: + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_installed(self, mock_run): + mock_run.return_value = MagicMock(returncode=0) + assert check_devops_ext() is True + cmd = mock_run.call_args[0][0] + assert cmd == ["az", "devops", "--help"] + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_not_installed(self, mock_run): + mock_run.return_value = MagicMock(returncode=1) + assert check_devops_ext() is False + + @patch("azext_prototype.stages.backlog_push.subprocess.run", side_effect=FileNotFoundError) + def test_az_not_found(self, mock_run): + assert check_devops_ext() is False + + +# ------------------------------------------------------------------ +# Formatters — GitHub +# ------------------------------------------------------------------ + + +class TestFormatGithubBody: + def test_description_section(self): + body = format_github_body({"description": "Build an API"}) + assert "## Description" in body + assert "Build an API" in body + + def test_no_description(self): + body = format_github_body({"title": "Something"}) + assert "## Description" not in body + + def test_acceptance_criteria(self): + body = format_github_body({"acceptance_criteria": ["AC1", "AC2"]}) + assert "## Acceptance Criteria" in body + assert "1. AC1" in body + assert "2. AC2" in body + + def test_empty_acceptance_criteria(self): + body = format_github_body({"acceptance_criteria": []}) + assert "## Acceptance Criteria" not in body + + def test_tasks_as_strings(self): + body = format_github_body({"tasks": ["Task A", "Task B"]}) + assert "## Tasks" in body + assert "- [ ] Task A" in body + assert "- [ ] Task B" in body + + def test_tasks_as_dicts_unchecked(self): + body = format_github_body({"tasks": [{"title": "Task A", "done": False}]}) + assert "- [ ] Task A" in body + + def test_tasks_as_dicts_checked(self): + body = format_github_body({"tasks": [{"title": "Task Done", "done": True}]}) + assert "- [x] Task Done" in body + + def test_empty_tasks(self): + body = format_github_body({"tasks": []}) + assert "## Tasks" not in body + + def test_children_section(self): + item = { + "children": [ + { + "title": "Story 1", + "effort": "M", + "description": "Story desc", + "acceptance_criteria": ["AC1"], + "tasks": ["Sub task"], + } + ] + } + body = format_github_body(item) + assert "## Stories" in body + assert "### Story 1 [M]" in body + assert "Story desc" in body + assert "1. AC1" in body + assert "- [ ] Sub task" in body + + def test_children_with_dict_tasks(self): + item = { + "children": [ + { + "title": "Story", + "effort": "S", + "tasks": [{"title": "Done task", "done": True}], + } + ] + } + body = format_github_body(item) + assert "- [x] Done task" in body + + def test_labels_from_epic_and_effort(self): + body = format_github_body({"epic": "Infrastructure", "effort": "L"}) + assert "`infrastructure`" in body + assert "`effort/L`" in body + + def test_no_labels_without_epic_and_effort(self): + body = format_github_body({"title": "Plain item"}) + assert "**Labels:**" not in body + + +# ------------------------------------------------------------------ +# Formatters — Azure DevOps +# ------------------------------------------------------------------ + + +class TestFormatDevopsDescription: + def test_description_paragraph(self): + html = format_devops_description({"description": "Build API"}) + assert "

Build API

" in html + + def test_no_description(self): + html = format_devops_description({"title": "X"}) + assert "

" not in html + + def test_acceptance_criteria(self): + html = format_devops_description({"acceptance_criteria": ["AC1", "AC2"]}) + assert "

Acceptance Criteria

" in html + assert "
  • AC1
  • " in html + assert "
  • AC2
  • " in html + + def test_empty_acceptance_criteria(self): + html = format_devops_description({"acceptance_criteria": []}) + assert "Acceptance Criteria" not in html + + def test_tasks_as_strings(self): + html = format_devops_description({"tasks": ["T1"]}) + assert "

    Tasks

    " in html + assert "
  • T1
  • " in html + + def test_tasks_as_dicts_done(self): + html = format_devops_description({"tasks": [{"title": "T", "done": True}]}) + assert "☑" in html + assert "T" in html + + def test_tasks_as_dicts_not_done(self): + html = format_devops_description({"tasks": [{"title": "T", "done": False}]}) + assert "☐" in html + + def test_effort_paragraph(self): + html = format_devops_description({"effort": "XL"}) + assert "Effort: XL" in html + + def test_no_effort(self): + html = format_devops_description({"title": "X"}) + assert "Effort:" not in html + + +# ------------------------------------------------------------------ +# push_github_issue() +# ------------------------------------------------------------------ + + +class TestPushGithubIssue: + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_success(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, + stdout="https://github.com/contoso/myproj/issues/42\n", + ) + result = push_github_issue("contoso", "myproj", {"title": "Add Auth"}) + assert result["url"] == "https://github.com/contoso/myproj/issues/42" + assert result["number"] == "42" + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_title_with_epic(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="https://github.com/o/p/issues/1\n") + push_github_issue("o", "p", {"title": "Setup VNet", "epic": "Infrastructure"}) + cmd = mock_run.call_args[0][0] + assert "[Infrastructure] Setup VNet" in cmd + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_title_without_epic(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="https://github.com/o/p/issues/1\n") + push_github_issue("o", "p", {"title": "Plain task"}) + cmd = mock_run.call_args[0][0] + assert "Plain task" in cmd + # No bracket prefix + for arg in cmd: + if arg == "Plain task": + break + else: + # If full_title is used, it should not have brackets + title_idx = cmd.index("--title") + 1 + assert not cmd[title_idx].startswith("[") + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_labels_from_params_and_item(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="https://github.com/o/p/issues/1\n") + push_github_issue( + "o", + "p", + {"title": "T", "effort": "M", "epic": "Networking"}, + labels=["prototype", "poc"], + ) + cmd = mock_run.call_args[0][0] + # Should have --label for each label + label_indices = [i for i, v in enumerate(cmd) if v == "--label"] + labels = [cmd[i + 1] for i in label_indices] + assert "prototype" in labels + assert "poc" in labels + assert "effort/M" in labels + assert "networking" in labels + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_failure_stderr(self, mock_run): + mock_run.return_value = MagicMock( + returncode=1, + stderr="HTTP 422: Validation Failed", + stdout="", + ) + result = push_github_issue("o", "p", {"title": "T"}) + assert "error" in result + assert "422" in result["error"] + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_failure_stdout_fallback(self, mock_run): + mock_run.return_value = MagicMock( + returncode=1, + stderr="", + stdout="something went wrong", + ) + result = push_github_issue("o", "p", {"title": "T"}) + assert "something went wrong" in result["error"] + + @patch("azext_prototype.stages.backlog_push.subprocess.run", side_effect=FileNotFoundError) + def test_gh_not_found(self, mock_run): + result = push_github_issue("o", "p", {"title": "T"}) + assert "error" in result + assert "gh CLI not found" in result["error"] + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_repo_flag(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="https://github.com/o/p/issues/1\n") + push_github_issue("contoso", "my-repo", {"title": "T"}) + cmd = mock_run.call_args[0][0] + repo_idx = cmd.index("--repo") + 1 + assert cmd[repo_idx] == "contoso/my-repo" + + +# ------------------------------------------------------------------ +# push_devops_feature / push_devops_story / push_devops_task +# ------------------------------------------------------------------ + + +class TestPushDevopsWorkItem: + def _mock_success(self, wi_id=123, url="https://dev.azure.com/o/p/_workitems/edit/123"): + return MagicMock( + returncode=0, + stdout=json.dumps( + { + "id": wi_id, + "_links": {"html": {"href": url}}, + } + ), + ) + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_feature_success(self, mock_run): + mock_run.return_value = self._mock_success(wi_id=10) + result = push_devops_feature("myorg", "myproj", {"title": "Infra Setup"}) + assert result["id"] == 10 + assert "dev.azure.com" in result["url"] + # Check work item type + cmd = mock_run.call_args[0][0] + type_idx = cmd.index("--type") + 1 + assert cmd[type_idx] == "Feature" + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_story_success(self, mock_run): + mock_run.return_value = self._mock_success(wi_id=20) + result = push_devops_story("myorg", "myproj", {"title": "API Story"}) + assert result["id"] == 20 + cmd = mock_run.call_args[0][0] + type_idx = cmd.index("--type") + 1 + assert cmd[type_idx] == "User Story" + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + @patch("azext_prototype.stages.backlog_push._link_parent") + def test_story_with_parent(self, mock_link, mock_run): + mock_run.return_value = self._mock_success(wi_id=20) + push_devops_story("org", "proj", {"title": "Story"}, parent_id=10) + mock_link.assert_called_once_with("org", "proj", 20, 10) + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_task_success(self, mock_run): + mock_run.return_value = self._mock_success(wi_id=30) + result = push_devops_task("org", "proj", {"title": "Sub task"}) + assert result["id"] == 30 + cmd = mock_run.call_args[0][0] + type_idx = cmd.index("--type") + 1 + assert cmd[type_idx] == "Task" + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + @patch("azext_prototype.stages.backlog_push._link_parent") + def test_task_with_parent(self, mock_link, mock_run): + mock_run.return_value = self._mock_success(wi_id=30) + push_devops_task("org", "proj", {"title": "Task"}, parent_id=20) + mock_link.assert_called_once_with("org", "proj", 30, 20) + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_failure(self, mock_run): + mock_run.return_value = MagicMock( + returncode=1, + stderr="TF401019: Access denied", + stdout="", + ) + result = push_devops_feature("org", "proj", {"title": "T"}) + assert "error" in result + assert "Access denied" in result["error"] + + @patch("azext_prototype.stages.backlog_push.subprocess.run", side_effect=FileNotFoundError) + def test_az_not_found(self, mock_run): + result = push_devops_feature("org", "proj", {"title": "T"}) + assert "error" in result + assert "az CLI not found" in result["error"] + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_json_decode_error(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, + stdout="not valid json", + ) + result = push_devops_feature("org", "proj", {"title": "T"}) + # Falls back to raw stdout + assert result["url"] == "" + assert result["id"] == "not valid json" + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_epic_area_path(self, mock_run): + mock_run.return_value = self._mock_success() + push_devops_feature("org", "proj", {"title": "T", "epic": "Infrastructure"}) + cmd = mock_run.call_args[0][0] + area_idx = cmd.index("--area") + 1 + assert cmd[area_idx] == "proj\\Infrastructure" + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_no_epic_no_area(self, mock_run): + mock_run.return_value = self._mock_success() + push_devops_feature("org", "proj", {"title": "T"}) + cmd = mock_run.call_args[0][0] + assert "--area" not in cmd + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_org_url_format(self, mock_run): + mock_run.return_value = self._mock_success() + push_devops_feature("contoso", "myproj", {"title": "T"}) + cmd = mock_run.call_args[0][0] + org_idx = cmd.index("--org") + 1 + assert cmd[org_idx] == "https://dev.azure.com/contoso" + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_url_fallback_to_data_url(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps( + { + "id": 99, + "_links": {}, + "url": "https://dev.azure.com/o/p/_apis/wit/workItems/99", + } + ), + ) + result = push_devops_feature("o", "p", {"title": "T"}) + assert result["url"] == "https://dev.azure.com/o/p/_apis/wit/workItems/99" + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + @patch("azext_prototype.stages.backlog_push._link_parent") + def test_no_parent_link_when_parent_id_none(self, mock_link, mock_run): + mock_run.return_value = self._mock_success(wi_id=50) + push_devops_story("o", "p", {"title": "T"}, parent_id=None) + mock_link.assert_not_called() + + +# ------------------------------------------------------------------ +# _link_parent() +# ------------------------------------------------------------------ + + +class TestLinkParent: + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_link_parent_success(self, mock_run): + mock_run.return_value = MagicMock(returncode=0) + _link_parent("org", "proj", child_id=20, parent_id=10) + cmd = mock_run.call_args[0][0] + assert "relation" in cmd + assert "add" in cmd + assert "--id" in cmd + assert "20" in cmd + assert "--target-id" in cmd + assert "10" in cmd + assert "--relation-type" in cmd + assert "parent" in cmd + + @patch("azext_prototype.stages.backlog_push.subprocess.run", side_effect=FileNotFoundError) + def test_link_parent_file_not_found(self, mock_run): + # Should not raise + _link_parent("org", "proj", child_id=20, parent_id=10) + + @patch("azext_prototype.stages.backlog_push.subprocess.run") + def test_link_parent_subprocess_error(self, mock_run): + import subprocess + + mock_run.side_effect = subprocess.SubprocessError("broken pipe") + # Should not raise + _link_parent("org", "proj", child_id=20, parent_id=10) diff --git a/tests/stages/test_build_session_reentry.py b/tests/stages/test_build_session.py similarity index 100% rename from tests/stages/test_build_session_reentry.py rename to tests/stages/test_build_session.py diff --git a/tests/stages/test_escalation.py b/tests/stages/test_escalation.py new file mode 100644 index 0000000..df56c6a --- /dev/null +++ b/tests/stages/test_escalation.py @@ -0,0 +1,526 @@ +"""Tests for EscalationTracker — 4-level escalation chain. + +Tier 2: Conditional branches with multiple paths. + +Covers: +- EscalationEntry dataclass: to_dict / from_dict round-trip +- record_blocker() creates L1 entry, persists +- record_attempted_solution() appends and saves +- resolve() marks resolved, saves +- get_active_blockers() filters resolved +- Escalation chain: + - L1 (documented) -> L2 (agent: architect vs PM) + - L2 scope keywords -> project-manager, else -> cloud-architect + - L2 with no agent available -> fallback message + - L2 agent execution failure -> error message + - L3 web search -> success / failure / import error + - L4 human flag + - Already at L4 -> no escalation +- should_auto_escalate(): + - resolved entry -> False + - L4 entry -> False + - within timeout -> False + - exceeded timeout -> True + - bad timestamp -> False +- format_escalation_report() formatting +- State persistence: save / load round-trip +""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from azext_prototype.stages.escalation import EscalationEntry, EscalationTracker + +# ------------------------------------------------------------------ +# Fixtures +# ------------------------------------------------------------------ + + +@pytest.fixture +def tracker(tmp_path): + """EscalationTracker with a temp project directory.""" + project_dir = str(tmp_path / "test-project") + (tmp_path / "test-project" / ".prototype" / "state").mkdir(parents=True) + return EscalationTracker(project_dir) + + +@pytest.fixture +def sample_entry(): + """A sample escalation entry at L1.""" + now = datetime.now(timezone.utc).isoformat() + return EscalationEntry( + task_description="Deploy container app", + blocker="Container registry not accessible", + attempted_solutions=["Checked ACR network rules"], + escalation_level=1, + source_agent="terraform-agent", + source_stage="build", + created_at=now, + last_escalated_at=now, + ) + + +# ------------------------------------------------------------------ +# EscalationEntry dataclass +# ------------------------------------------------------------------ + + +class TestEscalationEntry: + def test_to_dict_round_trip(self, sample_entry): + d = sample_entry.to_dict() + restored = EscalationEntry.from_dict(d) + assert restored.task_description == sample_entry.task_description + assert restored.blocker == sample_entry.blocker + assert restored.attempted_solutions == sample_entry.attempted_solutions + assert restored.escalation_level == sample_entry.escalation_level + assert restored.source_agent == sample_entry.source_agent + assert restored.resolved == sample_entry.resolved + + def test_from_dict_missing_fields_uses_defaults(self): + entry = EscalationEntry.from_dict({"task_description": "Deploy", "blocker": "Blocked"}) + assert entry.escalation_level == 1 + assert entry.attempted_solutions == [] + assert entry.resolved is False + assert entry.source_agent == "" + + def test_from_dict_empty_dict(self): + entry = EscalationEntry.from_dict({}) + assert entry.task_description == "" + assert entry.blocker == "" + + +# ------------------------------------------------------------------ +# Blocker management +# ------------------------------------------------------------------ + + +class TestBlockerManagement: + def test_record_blocker_creates_l1_entry(self, tracker): + entry = tracker.record_blocker( + "Deploy app", + "Auth failure", + source_agent="terraform-agent", + source_stage="deploy", + ) + assert entry.escalation_level == 1 + assert entry.blocker == "Auth failure" + assert entry.source_agent == "terraform-agent" + assert entry.created_at != "" + + def test_record_blocker_persists(self, tracker): + tracker.record_blocker("task", "blocker", source_agent="agent", source_stage="stage") + assert tracker._state_path.exists() + + def test_record_attempted_solution(self, tracker, sample_entry): + tracker._entries.append(sample_entry) + tracker.record_attempted_solution(sample_entry, "Tried a different SKU") + assert "Tried a different SKU" in sample_entry.attempted_solutions + + def test_resolve_marks_entry(self, tracker, sample_entry): + tracker._entries.append(sample_entry) + tracker.resolve(sample_entry, "Switched to public ACR") + assert sample_entry.resolved is True + assert sample_entry.resolution == "Switched to public ACR" + + def test_get_active_blockers_excludes_resolved(self, tracker): + e1 = tracker.record_blocker("t1", "b1", source_agent="a", source_stage="s") + tracker.record_blocker("t2", "b2", source_agent="a", source_stage="s") + tracker.resolve(e1, "Fixed") + active = tracker.get_active_blockers() + assert len(active) == 1 + assert active[0].task_description == "t2" + + +# ------------------------------------------------------------------ +# State persistence +# ------------------------------------------------------------------ + + +class TestStatePersistence: + def test_save_and_load_round_trip(self, tracker): + tracker.record_blocker("task1", "blocker1", source_agent="agent1", source_stage="build") + tracker.record_blocker("task2", "blocker2", source_agent="agent2", source_stage="deploy") + + tracker2 = EscalationTracker(tracker._project_dir) + tracker2.load() + assert len(tracker2._entries) == 2 + assert tracker2._entries[0].task_description == "task1" + assert tracker2._entries[1].blocker == "blocker2" + + def test_load_nonexistent_file(self, tmp_path): + t = EscalationTracker(str(tmp_path / "no-project")) + t.load() + assert t._entries == [] + + def test_exists_property(self, tracker): + assert tracker.exists is False + tracker.record_blocker("t", "b", source_agent="a", source_stage="s") + assert tracker.exists is True + + +# ------------------------------------------------------------------ +# Escalation chain — L1 -> L2 +# ------------------------------------------------------------------ + + +class TestEscalateToAgent: + def _make_registry_and_context(self, agent_response="Here is the fix"): + mock_agent = MagicMock() + mock_agent.execute.return_value = MagicMock(content=agent_response) + + registry = MagicMock() + registry.find_by_capability.return_value = [mock_agent] + + agent_context = MagicMock() + agent_context.ai_provider = MagicMock() + + return registry, agent_context, mock_agent + + def test_technical_blocker_escalates_to_architect(self, tracker, sample_entry): + tracker._entries.append(sample_entry) + registry, ctx, agent = self._make_registry_and_context() + printed = [] + + result = tracker.escalate(sample_entry, registry, ctx, printed.append) + + assert result["escalated"] is True + assert result["level"] == 2 + # Should use ARCHITECT capability (not BACKLOG_GENERATION) + from azext_prototype.agents.base import AgentCapability + + registry.find_by_capability.assert_called_once_with(AgentCapability.ARCHITECT) + + def test_scope_blocker_escalates_to_pm(self, tracker): + entry = EscalationEntry( + task_description="Define feature scope", + blocker="Unclear requirement for the backlog story", + source_agent="biz-analyst", + source_stage="design", + created_at=datetime.now(timezone.utc).isoformat(), + last_escalated_at=datetime.now(timezone.utc).isoformat(), + ) + tracker._entries.append(entry) + + registry, ctx, agent = self._make_registry_and_context() + result = tracker.escalate(entry, registry, ctx, MagicMock()) + + from azext_prototype.agents.base import AgentCapability + + registry.find_by_capability.assert_called_once_with(AgentCapability.BACKLOG_GENERATION) + assert result["level"] == 2 + + def test_scope_keywords_detected(self, tracker): + """Each scope keyword routes to PM.""" + scope_keywords = ["scope", "requirement", "backlog", "story", "feature", "stakeholder", "priority", "sprint"] + for kw in scope_keywords: + entry = EscalationEntry( + task_description="task", + blocker=f"Issue with {kw}", + source_agent="a", + source_stage="s", + created_at=datetime.now(timezone.utc).isoformat(), + last_escalated_at=datetime.now(timezone.utc).isoformat(), + ) + tracker._entries.append(entry) + + registry, ctx, _ = self._make_registry_and_context() + tracker.escalate(entry, registry, ctx, MagicMock()) + + from azext_prototype.agents.base import AgentCapability + + registry.find_by_capability.assert_called_once_with(AgentCapability.BACKLOG_GENERATION) + + def test_no_agent_available(self, tracker, sample_entry): + tracker._entries.append(sample_entry) + registry = MagicMock() + registry.find_by_capability.return_value = [] + ctx = MagicMock() + ctx.ai_provider = MagicMock() + printed = [] + + result = tracker.escalate(sample_entry, registry, ctx, printed.append) + + assert result["level"] == 2 + assert "No cloud-architect available" in result["content"] + + def test_no_agent_context(self, tracker, sample_entry): + tracker._entries.append(sample_entry) + registry = MagicMock() + registry.find_by_capability.return_value = [MagicMock()] + + result = tracker.escalate(sample_entry, registry, None, MagicMock()) + assert "No cloud-architect available" in result["content"] + + def test_no_ai_provider_on_context(self, tracker, sample_entry): + tracker._entries.append(sample_entry) + registry = MagicMock() + registry.find_by_capability.return_value = [MagicMock()] + ctx = MagicMock() + ctx.ai_provider = None + + result = tracker.escalate(sample_entry, registry, ctx, MagicMock()) + assert "No cloud-architect available" in result["content"] + + def test_agent_execution_failure(self, tracker, sample_entry): + tracker._entries.append(sample_entry) + mock_agent = MagicMock() + mock_agent.execute.side_effect = RuntimeError("model down") + + registry = MagicMock() + registry.find_by_capability.return_value = [mock_agent] + ctx = MagicMock() + ctx.ai_provider = MagicMock() + + result = tracker.escalate(sample_entry, registry, ctx, MagicMock()) + assert "Agent escalation failed" in result["content"] + + def test_agent_returns_none_response(self, tracker, sample_entry): + tracker._entries.append(sample_entry) + mock_agent = MagicMock() + mock_agent.execute.return_value = None + + registry = MagicMock() + registry.find_by_capability.return_value = [mock_agent] + ctx = MagicMock() + ctx.ai_provider = MagicMock() + + result = tracker.escalate(sample_entry, registry, ctx, MagicMock()) + assert result["content"] == "" + + +# ------------------------------------------------------------------ +# Escalation chain — L2 -> L3 (web search) +# ------------------------------------------------------------------ + + +class TestEscalateToWebSearch: + def test_web_search_success(self, tracker, sample_entry): + sample_entry.escalation_level = 2 + tracker._entries.append(sample_entry) + + with patch( + "azext_prototype.stages.escalation.EscalationTracker._escalate_to_web_search", + return_value="Found docs on ACR networking", + ): + result = tracker.escalate(sample_entry, MagicMock(), MagicMock(), MagicMock()) + + assert result["level"] == 3 + assert result["escalated"] is True + + def test_web_search_with_real_import(self, tracker, sample_entry): + sample_entry.escalation_level = 2 + tracker._entries.append(sample_entry) + + with patch("azext_prototype.knowledge.web_search.search_and_fetch", return_value="Doc content"): + printed = [] + result = tracker.escalate(sample_entry, MagicMock(), MagicMock(), printed.append) + + assert result["level"] == 3 + assert result["content"] == "Doc content" + + def test_web_search_no_results(self, tracker, sample_entry): + sample_entry.escalation_level = 2 + tracker._entries.append(sample_entry) + + with patch("azext_prototype.knowledge.web_search.search_and_fetch", return_value=""): + printed = [] + result = tracker.escalate(sample_entry, MagicMock(), MagicMock(), printed.append) + + assert result["level"] == 3 + assert "No web results found" in result["content"] + + def test_web_search_exception(self, tracker, sample_entry): + sample_entry.escalation_level = 2 + tracker._entries.append(sample_entry) + + with patch( + "azext_prototype.knowledge.web_search.search_and_fetch", + side_effect=RuntimeError("network down"), + ): + printed = [] + result = tracker.escalate(sample_entry, MagicMock(), MagicMock(), printed.append) + + assert result["level"] == 3 + assert "Web search failed" in result["content"] + + +# ------------------------------------------------------------------ +# Escalation chain — L3 -> L4 (human) +# ------------------------------------------------------------------ + + +class TestEscalateToHuman: + def test_human_escalation(self, tracker, sample_entry): + sample_entry.escalation_level = 3 + tracker._entries.append(sample_entry) + + printed = [] + result = tracker.escalate(sample_entry, MagicMock(), MagicMock(), printed.append) + + assert result["level"] == 4 + assert result["escalated"] is True + assert "Flagged for human intervention" in result["content"] + assert any("HUMAN INTERVENTION REQUIRED" in msg for msg in printed) + + def test_human_escalation_includes_details(self, tracker, sample_entry): + sample_entry.escalation_level = 3 + sample_entry.attempted_solutions = ["Tried A", "Tried B"] + tracker._entries.append(sample_entry) + + printed = [] + tracker.escalate(sample_entry, MagicMock(), MagicMock(), printed.append) + + full_output = "\n".join(printed) + assert sample_entry.task_description in full_output + assert sample_entry.blocker in full_output + assert "Tried A" in full_output + assert "Tried B" in full_output + + +# ------------------------------------------------------------------ +# Already at L4 — no further escalation +# ------------------------------------------------------------------ + + +class TestAlreadyAtMaxLevel: + def test_l4_cannot_escalate(self, tracker, sample_entry): + sample_entry.escalation_level = 4 + tracker._entries.append(sample_entry) + + result = tracker.escalate(sample_entry, MagicMock(), MagicMock(), MagicMock()) + + assert result["escalated"] is False + assert result["level"] == 4 + assert "Already at human level" in result["content"] + + +# ------------------------------------------------------------------ +# should_auto_escalate() +# ------------------------------------------------------------------ + + +class TestShouldAutoEscalate: + def test_resolved_entry_returns_false(self, tracker): + entry = EscalationEntry( + task_description="t", + blocker="b", + resolved=True, + last_escalated_at=datetime(2020, 1, 1, tzinfo=timezone.utc).isoformat(), + ) + assert tracker.should_auto_escalate(entry, timeout_seconds=0) is False + + def test_l4_entry_returns_false(self, tracker): + entry = EscalationEntry( + task_description="t", + blocker="b", + escalation_level=4, + last_escalated_at=datetime(2020, 1, 1, tzinfo=timezone.utc).isoformat(), + ) + assert tracker.should_auto_escalate(entry, timeout_seconds=0) is False + + def test_within_timeout_returns_false(self, tracker): + recent = datetime.now(timezone.utc).isoformat() + entry = EscalationEntry( + task_description="t", + blocker="b", + escalation_level=1, + last_escalated_at=recent, + ) + assert tracker.should_auto_escalate(entry, timeout_seconds=120) is False + + def test_exceeded_timeout_returns_true(self, tracker): + old = (datetime.now(timezone.utc) - timedelta(seconds=300)).isoformat() + entry = EscalationEntry( + task_description="t", + blocker="b", + escalation_level=1, + last_escalated_at=old, + ) + assert tracker.should_auto_escalate(entry, timeout_seconds=120) is True + + def test_bad_timestamp_returns_false(self, tracker): + entry = EscalationEntry( + task_description="t", + blocker="b", + escalation_level=1, + last_escalated_at="not-a-date", + ) + assert tracker.should_auto_escalate(entry, timeout_seconds=0) is False + + def test_empty_timestamp_returns_false(self, tracker): + entry = EscalationEntry( + task_description="t", + blocker="b", + escalation_level=1, + last_escalated_at="", + ) + assert tracker.should_auto_escalate(entry, timeout_seconds=0) is False + + def test_l2_entry_can_auto_escalate(self, tracker): + old = (datetime.now(timezone.utc) - timedelta(seconds=300)).isoformat() + entry = EscalationEntry( + task_description="t", + blocker="b", + escalation_level=2, + last_escalated_at=old, + ) + assert tracker.should_auto_escalate(entry, timeout_seconds=120) is True + + def test_l3_entry_can_auto_escalate(self, tracker): + old = (datetime.now(timezone.utc) - timedelta(seconds=300)).isoformat() + entry = EscalationEntry( + task_description="t", + blocker="b", + escalation_level=3, + last_escalated_at=old, + ) + assert tracker.should_auto_escalate(entry, timeout_seconds=120) is True + + +# ------------------------------------------------------------------ +# format_escalation_report() +# ------------------------------------------------------------------ + + +class TestFormatReport: + def test_no_entries(self, tracker): + report = tracker.format_escalation_report() + assert "No blockers recorded" in report + + def test_active_blockers_in_report(self, tracker): + tracker.record_blocker("Deploy app", "Auth error", source_agent="tf", source_stage="deploy") + report = tracker.format_escalation_report() + assert "Active Blockers (1)" in report + assert "Deploy app" in report + assert "Auth error" in report + assert "Documented" in report # L1 label + + def test_resolved_in_report(self, tracker): + entry = tracker.record_blocker("task", "blocker", source_agent="a", source_stage="s") + tracker.resolve(entry, "Fixed by reconfig") + report = tracker.format_escalation_report() + assert "Resolved (1)" in report + assert "Fixed by reconfig" in report + + def test_mixed_active_and_resolved(self, tracker): + e1 = tracker.record_blocker("t1", "b1", source_agent="a", source_stage="s") + tracker.record_blocker("t2", "b2", source_agent="a", source_stage="s") + tracker.resolve(e1, "done") + report = tracker.format_escalation_report() + assert "Active Blockers (1)" in report + assert "Resolved (1)" in report + + def test_level_labels(self, tracker): + entry = tracker.record_blocker("t", "b", source_agent="a", source_stage="s") + entry.escalation_level = 2 + report = tracker.format_escalation_report() + assert "Agent" in report + + def test_attempted_solutions_count(self, tracker): + entry = tracker.record_blocker("t", "b", source_agent="a", source_stage="s") + tracker.record_attempted_solution(entry, "sol1") + tracker.record_attempted_solution(entry, "sol2") + report = tracker.format_escalation_report() + assert "Attempts: 2" in report diff --git a/tests/stages/test_policy_resolver.py b/tests/stages/test_policy_resolver.py new file mode 100644 index 0000000..b52b459 --- /dev/null +++ b/tests/stages/test_policy_resolver.py @@ -0,0 +1,480 @@ +"""Tests for PolicyResolver — 3-way policy violation resolution. + +Tier 2: Conditional branches with multiple paths. + +Covers: +- No violations → early return ([], False) +- Auto-accept mode → all violations auto-accepted +- Interactive accept path (default choice) +- Override path with justification (provided and empty/default) +- Regenerate path → needs_regen=True +- Mixed resolution paths in a single check +- build_fix_instructions() generation with regen-only and mixed items +- _extract_rule_id() with bracketed prefix and fallback +- build_state.add_policy_check/add_policy_override calls +""" + +from unittest.mock import MagicMock + +import pytest + +from azext_prototype.stages.policy_resolver import PolicyResolution, PolicyResolver + +# ------------------------------------------------------------------ +# Fixtures +# ------------------------------------------------------------------ + + +@pytest.fixture +def mock_governance(): + """GovernanceContext mock with configurable violations.""" + gov = MagicMock() + gov.check_response_for_violations.return_value = [] + return gov + + +@pytest.fixture +def mock_build_state(): + """BuildState mock that records policy checks and overrides.""" + state = MagicMock() + state.add_policy_check = MagicMock() + state.add_policy_override = MagicMock() + return state + + +@pytest.fixture +def resolver(mock_governance): + """Standard interactive PolicyResolver.""" + return PolicyResolver( + console=MagicMock(), + prompt=MagicMock(), + governance_context=mock_governance, + auto_accept=False, + ) + + +@pytest.fixture +def auto_resolver(mock_governance): + """PolicyResolver with auto_accept=True.""" + return PolicyResolver( + console=MagicMock(), + prompt=MagicMock(), + governance_context=mock_governance, + auto_accept=True, + ) + + +# ------------------------------------------------------------------ +# No violations — early return +# ------------------------------------------------------------------ + + +class TestNoViolations: + def test_no_violations_returns_empty(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = [] + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "resource aws_s3_bucket {}", + mock_build_state, + 1, + print_fn=MagicMock(), + ) + assert resolutions == [] + assert needs_regen is False + + def test_no_violations_does_not_call_build_state(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = [] + resolver.check_and_resolve( + "bicep-agent", + "resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {}", + mock_build_state, + 2, + print_fn=MagicMock(), + ) + mock_build_state.add_policy_check.assert_not_called() + mock_build_state.add_policy_override.assert_not_called() + + +# ------------------------------------------------------------------ +# Auto-accept mode +# ------------------------------------------------------------------ + + +class TestAutoAccept: + def test_auto_accept_all_violations(self, auto_resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = [ + "[managed-identity] Use managed identity instead of keys", + "[tls-version] Enforce TLS 1.2", + ] + printed = [] + resolutions, needs_regen = auto_resolver.check_and_resolve( + "terraform-agent", + "some content", + mock_build_state, + 1, + print_fn=printed.append, + ) + assert len(resolutions) == 2 + assert all(r.action == "accept" for r in resolutions) + assert needs_regen is False + + def test_auto_accept_extracts_rule_ids(self, auto_resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = [ + "[managed-identity] Use managed identity", + "[tls-version] Enforce TLS 1.2", + ] + resolutions, _ = auto_resolver.check_and_resolve( + "bicep-agent", "content", mock_build_state, 1, print_fn=MagicMock() + ) + assert resolutions[0].rule_id == "managed-identity" + assert resolutions[1].rule_id == "tls-version" + + def test_auto_accept_records_policy_check(self, auto_resolver, mock_governance, mock_build_state): + violations = ["[sec-001] No public endpoints"] + mock_governance.check_response_for_violations.return_value = violations + auto_resolver.check_and_resolve("terraform-agent", "code", mock_build_state, 3, print_fn=MagicMock()) + mock_build_state.add_policy_check.assert_called_once_with( + 3, + violations=violations, + overrides=[], + ) + + def test_auto_accept_does_not_call_override(self, auto_resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-x] violation"] + auto_resolver.check_and_resolve("terraform-agent", "code", mock_build_state, 1, print_fn=MagicMock()) + mock_build_state.add_policy_override.assert_not_called() + + def test_auto_accept_prints_auto_accepted_message(self, auto_resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-x] violation"] + printed = [] + auto_resolver.check_and_resolve("terraform-agent", "code", mock_build_state, 1, print_fn=printed.append) + assert any("Auto-accepted" in msg for msg in printed) + + +# ------------------------------------------------------------------ +# Interactive — Accept (default path) +# ------------------------------------------------------------------ + + +class TestInteractiveAccept: + def test_accept_explicit_a(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-1] issue"] + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: "a", + print_fn=MagicMock(), + ) + assert len(resolutions) == 1 + assert resolutions[0].action == "accept" + assert needs_regen is False + + def test_accept_default_empty_input(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-1] issue"] + resolutions, _ = resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: "", + print_fn=MagicMock(), + ) + assert resolutions[0].action == "accept" + + def test_accept_unknown_input_defaults_to_accept(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-1] issue"] + resolutions, _ = resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: "xyz", + print_fn=MagicMock(), + ) + assert resolutions[0].action == "accept" + + def test_accept_prints_accepted_message(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-1] issue"] + printed = [] + resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: "a", + print_fn=printed.append, + ) + assert any("Accepted compliant recommendation" in msg for msg in printed) + + +# ------------------------------------------------------------------ +# Interactive — Override path +# ------------------------------------------------------------------ + + +class TestInteractiveOverride: + def test_override_with_justification(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[managed-identity] use MI"] + inputs = iter(["o", "Legacy system requires key auth"]) + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 2, + input_fn=lambda _: next(inputs), + print_fn=MagicMock(), + ) + assert len(resolutions) == 1 + assert resolutions[0].action == "override" + assert resolutions[0].justification == "Legacy system requires key auth" + assert needs_regen is False + + def test_override_word_form(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-1] issue"] + inputs = iter(["override", "needed"]) + resolutions, _ = resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: next(inputs), + print_fn=MagicMock(), + ) + assert resolutions[0].action == "override" + + def test_override_empty_justification_uses_default(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-1] issue"] + inputs = iter(["o", ""]) + resolutions, _ = resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: next(inputs), + print_fn=MagicMock(), + ) + assert resolutions[0].action == "override" + assert resolutions[0].justification == "User chose to override" + + def test_override_calls_build_state_add_policy_override(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[sec-001] issue"] + inputs = iter(["o", "Approved by security team"]) + resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: next(inputs), + print_fn=MagicMock(), + ) + mock_build_state.add_policy_override.assert_called_once_with("sec-001", "Approved by security team") + + def test_override_recorded_in_policy_check_overrides(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[sec-001] issue"] + inputs = iter(["o", "Approved"]) + resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 5, + input_fn=lambda _: next(inputs), + print_fn=MagicMock(), + ) + mock_build_state.add_policy_check.assert_called_once() + args = mock_build_state.add_policy_check.call_args + assert ( + args.kwargs.get("overrides") + or args[1].get("overrides") + or [d for d in (args[1] if len(args) > 1 else []) if isinstance(d, list)] + ) + # Verify via the call — overrides list should have one item + call_args = mock_build_state.add_policy_check.call_args + overrides_arg = call_args[1]["overrides"] if "overrides" in call_args[1] else call_args[0][2] + assert len(overrides_arg) == 1 + assert overrides_arg[0]["rule_id"] == "sec-001" + + +# ------------------------------------------------------------------ +# Interactive — Regenerate path +# ------------------------------------------------------------------ + + +class TestInteractiveRegenerate: + def test_regenerate_sets_needs_regen_true(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-1] issue"] + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: "r", + print_fn=MagicMock(), + ) + assert needs_regen is True + assert resolutions[0].action == "regenerate" + + def test_regenerate_word_form(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-1] issue"] + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: "regenerate", + print_fn=MagicMock(), + ) + assert needs_regen is True + assert resolutions[0].action == "regenerate" + + def test_regenerate_prints_will_regenerate_message(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = ["[rule-1] issue"] + printed = [] + resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: "r", + print_fn=printed.append, + ) + assert any("regenerate" in msg.lower() for msg in printed) + + +# ------------------------------------------------------------------ +# Mixed resolutions in a single check +# ------------------------------------------------------------------ + + +class TestMixedResolutions: + def test_mixed_accept_override_regenerate(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = [ + "[rule-a] first issue", + "[rule-b] second issue", + "[rule-c] third issue", + ] + # First: accept, Second: override with justification, Third: regenerate + inputs = iter(["a", "o", "Because reasons", "r"]) + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: next(inputs), + print_fn=MagicMock(), + ) + assert len(resolutions) == 3 + assert resolutions[0].action == "accept" + assert resolutions[1].action == "override" + assert resolutions[1].justification == "Because reasons" + assert resolutions[2].action == "regenerate" + assert needs_regen is True + + def test_mixed_only_override_recorded_in_overrides(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = [ + "[rule-a] first", + "[rule-b] second", + ] + inputs = iter(["a", "o", "justified"]) + resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + input_fn=lambda _: next(inputs), + print_fn=MagicMock(), + ) + call_args = mock_build_state.add_policy_check.call_args + overrides = call_args[1]["overrides"] if "overrides" in call_args[1] else call_args[0][2] + assert len(overrides) == 1 + assert overrides[0]["rule_id"] == "rule-b" + + +# ------------------------------------------------------------------ +# build_fix_instructions() +# ------------------------------------------------------------------ + + +class TestBuildFixInstructions: + def test_no_regen_items_returns_empty(self, resolver): + resolutions = [ + PolicyResolution(rule_id="r1", action="accept", violation_text="v1"), + PolicyResolution(rule_id="r2", action="override", justification="ok", violation_text="v2"), + ] + assert resolver.build_fix_instructions(resolutions) == "" + + def test_regen_items_produces_fix_block(self, resolver): + resolutions = [ + PolicyResolution(rule_id="r1", action="regenerate", violation_text="missing MI auth"), + ] + result = resolver.build_fix_instructions(resolutions) + assert "## Policy Fix Instructions" in result + assert "missing MI auth" in result + assert "Fix these violations" in result + + def test_regen_with_overrides_includes_override_section(self, resolver): + resolutions = [ + PolicyResolution(rule_id="r1", action="regenerate", violation_text="issue A"), + PolicyResolution(rule_id="r2", action="override", justification="approved", violation_text="issue B"), + ] + result = resolver.build_fix_instructions(resolutions) + assert "## Policy Fix Instructions" in result + assert "issue A" in result + assert "overridden by the user" in result + assert "r2: approved" in result + + def test_multiple_regen_items(self, resolver): + resolutions = [ + PolicyResolution(rule_id="r1", action="regenerate", violation_text="A"), + PolicyResolution(rule_id="r2", action="regenerate", violation_text="B"), + ] + result = resolver.build_fix_instructions(resolutions) + assert "- A" in result + assert "- B" in result + + +# ------------------------------------------------------------------ +# _extract_rule_id() +# ------------------------------------------------------------------ + + +class TestExtractRuleId: + def test_bracketed_prefix(self): + assert PolicyResolver._extract_rule_id("[managed-identity] Use MI") == "managed-identity" + + def test_no_brackets_returns_unknown(self): + assert PolicyResolver._extract_rule_id("No brackets here") == "unknown" + + def test_empty_brackets_returns_empty_string(self): + # "[]" has end=1 > 0, so it extracts text[1:1] == "" + assert PolicyResolver._extract_rule_id("[] empty bracket") == "" + + def test_starts_with_bracket_no_close(self): + assert PolicyResolver._extract_rule_id("[no-close-bracket") == "unknown" + + def test_nested_brackets_takes_first(self): + assert PolicyResolver._extract_rule_id("[outer] some [inner] text") == "outer" + + +# ------------------------------------------------------------------ +# iac_tool parameter forwarding +# ------------------------------------------------------------------ + + +class TestIacToolForwarding: + def test_iac_tool_passed_to_governance(self, resolver, mock_governance, mock_build_state): + mock_governance.check_response_for_violations.return_value = [] + resolver.check_and_resolve( + "terraform-agent", + "code", + mock_build_state, + 1, + print_fn=MagicMock(), + iac_tool="terraform", + ) + mock_governance.check_response_for_violations.assert_called_once_with( + "terraform-agent", + "code", + iac_tool="terraform", + ) diff --git a/tests/stages/test_qa_router.py b/tests/stages/test_qa_router.py new file mode 100644 index 0000000..700c229 --- /dev/null +++ b/tests/stages/test_qa_router.py @@ -0,0 +1,494 @@ +"""Tests for route_error_to_qa() — QA error routing. + +Tier 2: Conditional branches with multiple paths. + +Covers: +- QA agent None -> early return +- agent_context None -> early return +- agent_context.ai_provider None -> early return +- QA agent executes successfully -> diagnosed=True +- QA agent returns empty content -> diagnosed=False +- QA agent returns None -> diagnosed=False +- QA agent raises exception -> diagnosed=False +- Token tracking when tracker provided +- Token tracking exception swallowed +- Knowledge contribution fire-and-forget (success + failure) +- Blocker recording when QA can't diagnose + escalation tracker present +- Blocker recording exception swallowed +- Error text truncation (max_error_chars) +- Display text truncation (max_display_chars) +- None/empty error handling +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from azext_prototype.stages.qa_router import route_error_to_qa + +# ------------------------------------------------------------------ +# Fixtures +# ------------------------------------------------------------------ + + +@pytest.fixture +def qa_agent(): + """Mock QA agent with successful response.""" + agent = MagicMock() + agent.execute.return_value = MagicMock(content="Root cause: misconfigured SKU. Fix: use B1.") + return agent + + +@pytest.fixture +def agent_context(): + """Mock AgentContext with AI provider.""" + ctx = MagicMock() + ctx.ai_provider = MagicMock() + return ctx + + +@pytest.fixture +def token_tracker(): + """Mock TokenTracker.""" + return MagicMock() + + +@pytest.fixture +def escalation_tracker(): + """Mock EscalationTracker.""" + return MagicMock() + + +# ------------------------------------------------------------------ +# Early returns — no QA agent / no context / no provider +# ------------------------------------------------------------------ + + +class TestEarlyReturns: + def test_qa_agent_none(self, agent_context): + result = route_error_to_qa( + "Error occurred", + "Build Stage 3", + qa_agent=None, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is False + assert "Error occurred" in result["content"] + assert result["response"] is None + + def test_agent_context_none(self, qa_agent): + result = route_error_to_qa( + "Error", + "Build Stage 1", + qa_agent=qa_agent, + agent_context=None, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is False + assert result["response"] is None + + def test_ai_provider_none(self, qa_agent): + ctx = MagicMock() + ctx.ai_provider = None + result = route_error_to_qa( + "Error", + "Deploy", + qa_agent=qa_agent, + agent_context=ctx, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is False + + def test_all_none(self): + result = route_error_to_qa( + "Error", + "context", + qa_agent=None, + agent_context=None, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is False + + +# ------------------------------------------------------------------ +# Successful QA diagnosis +# ------------------------------------------------------------------ + + +class TestSuccessfulDiagnosis: + def test_diagnosed_true(self, qa_agent, agent_context): + result = route_error_to_qa( + "Terraform apply failed", + "Build Stage 2", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is True + assert "Root cause" in result["content"] + assert result["response"] is not None + + def test_prints_qa_diagnosis(self, qa_agent, agent_context): + printed = [] + route_error_to_qa( + "Error", + "Build", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=printed.append, + ) + assert any("QA Diagnosis" in msg for msg in printed) + + def test_exception_error_converted_to_string(self, qa_agent, agent_context): + route_error_to_qa( + RuntimeError("connection timeout"), + "Deploy Stage 1", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + task_arg = qa_agent.execute.call_args[0][1] + assert "connection timeout" in task_arg + + def test_services_kwarg_forwarded(self, qa_agent, agent_context): + with patch("azext_prototype.stages.qa_router._submit_knowledge") as mock_submit: + route_error_to_qa( + "Error", + "Build", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + services=["key-vault", "cosmos-db"], + ) + mock_submit.assert_called_once() + _, _, services_arg, _ = mock_submit.call_args[0] + assert services_arg == ["key-vault", "cosmos-db"] + + +# ------------------------------------------------------------------ +# QA agent failures +# ------------------------------------------------------------------ + + +class TestQAFailures: + def test_qa_agent_raises_exception(self, agent_context): + bad_agent = MagicMock() + bad_agent.execute.side_effect = RuntimeError("model overloaded") + result = route_error_to_qa( + "Error", + "Build", + qa_agent=bad_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is False + assert result["response"] is None + + def test_qa_returns_none(self, agent_context): + agent = MagicMock() + agent.execute.return_value = None + result = route_error_to_qa( + "Error", + "Build", + qa_agent=agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is False + + def test_qa_returns_empty_content(self, agent_context): + agent = MagicMock() + agent.execute.return_value = MagicMock(content="") + result = route_error_to_qa( + "Error", + "Build", + qa_agent=agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is False + assert result["response"] is not None + + def test_qa_returns_none_content(self, agent_context): + agent = MagicMock() + agent.execute.return_value = MagicMock(content=None) + result = route_error_to_qa( + "Error", + "Build", + qa_agent=agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is False + + +# ------------------------------------------------------------------ +# Token tracking +# ------------------------------------------------------------------ + + +class TestTokenTracking: + def test_tokens_recorded_on_success(self, qa_agent, agent_context, token_tracker): + route_error_to_qa( + "Error", + "Build", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=token_tracker, + print_fn=MagicMock(), + ) + token_tracker.record.assert_called_once_with(qa_agent.execute.return_value) + + def test_no_token_tracker_no_error(self, qa_agent, agent_context): + # Should not raise even when token_tracker is None + result = route_error_to_qa( + "Error", + "Build", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is True + + def test_token_tracker_exception_swallowed(self, qa_agent, agent_context): + bad_tracker = MagicMock() + bad_tracker.record.side_effect = RuntimeError("tracker broken") + result = route_error_to_qa( + "Error", + "Build", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=bad_tracker, + print_fn=MagicMock(), + ) + assert result["diagnosed"] is True # Should still succeed + + def test_tokens_not_recorded_when_response_is_none(self, agent_context, token_tracker): + agent = MagicMock() + agent.execute.return_value = None + route_error_to_qa( + "Error", + "Build", + qa_agent=agent, + agent_context=agent_context, + token_tracker=token_tracker, + print_fn=MagicMock(), + ) + token_tracker.record.assert_not_called() + + +# ------------------------------------------------------------------ +# Knowledge contribution (fire-and-forget) +# ------------------------------------------------------------------ + + +class TestKnowledgeContribution: + def test_knowledge_submitted_on_success(self, qa_agent, agent_context): + with patch("azext_prototype.stages.qa_router._submit_knowledge") as mock_submit: + route_error_to_qa( + "Error", + "Build Stage 3", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + services=["cosmos-db"], + ) + mock_submit.assert_called_once() + + def test_knowledge_exception_swallowed(self, qa_agent, agent_context): + with patch( + "azext_prototype.stages.qa_router._submit_knowledge", + side_effect=RuntimeError("GitHub down"), + ): + result = route_error_to_qa( + "Error", + "Build", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + # Should still return successfully + assert result["diagnosed"] is True + + +# ------------------------------------------------------------------ +# Blocker recording (escalation tracker) +# ------------------------------------------------------------------ + + +class TestBlockerRecording: + def test_blocker_recorded_when_qa_cant_diagnose(self, agent_context, escalation_tracker): + agent = MagicMock() + agent.execute.return_value = MagicMock(content="") + + route_error_to_qa( + "Deployment failed: quota exceeded", + "Deploy Stage 1", + qa_agent=agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + escalation_tracker=escalation_tracker, + source_agent="terraform-agent", + source_stage="deploy", + ) + escalation_tracker.record_blocker.assert_called_once() + call_args = escalation_tracker.record_blocker.call_args + assert call_args[0][0] == "Deploy Stage 1" + assert "quota exceeded" in call_args[0][1] + assert call_args[1]["source_agent"] == "terraform-agent" + assert call_args[1]["source_stage"] == "deploy" + + def test_default_source_agent_is_qa_engineer(self, agent_context, escalation_tracker): + agent = MagicMock() + agent.execute.return_value = MagicMock(content="") + + route_error_to_qa( + "Error", + "Build", + qa_agent=agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + escalation_tracker=escalation_tracker, + ) + call_args = escalation_tracker.record_blocker.call_args + assert call_args[1]["source_agent"] == "qa-engineer" + + def test_no_blocker_when_no_tracker(self, agent_context): + agent = MagicMock() + agent.execute.return_value = MagicMock(content="") + # Should not raise + result = route_error_to_qa( + "Error", + "Build", + qa_agent=agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + escalation_tracker=None, + ) + assert result["diagnosed"] is False + + def test_blocker_not_recorded_on_success(self, qa_agent, agent_context, escalation_tracker): + route_error_to_qa( + "Error", + "Build", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + escalation_tracker=escalation_tracker, + ) + escalation_tracker.record_blocker.assert_not_called() + + def test_blocker_recording_exception_swallowed(self, agent_context): + agent = MagicMock() + agent.execute.return_value = MagicMock(content="") + + bad_tracker = MagicMock() + bad_tracker.record_blocker.side_effect = RuntimeError("disk full") + + result = route_error_to_qa( + "Error", + "Build", + qa_agent=agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + escalation_tracker=bad_tracker, + ) + assert result["diagnosed"] is False # Still returns gracefully + + +# ------------------------------------------------------------------ +# Error text handling +# ------------------------------------------------------------------ + + +class TestErrorTextHandling: + def test_error_text_truncated(self, agent_context): + long_error = "x" * 5000 + result = route_error_to_qa( + long_error, + "Build", + qa_agent=None, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + max_error_chars=100, + ) + assert len(result["content"]) == 100 + + def test_none_error_becomes_unknown(self, agent_context): + result = route_error_to_qa( + None, + "Build", + qa_agent=None, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["content"] == "Unknown error" + + def test_empty_string_error_becomes_unknown(self, agent_context): + result = route_error_to_qa( + "", + "Build", + qa_agent=None, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + ) + assert result["content"] == "Unknown error" + + def test_display_truncated(self, agent_context): + long_content = "R" * 3000 + agent = MagicMock() + agent.execute.return_value = MagicMock(content=long_content) + printed = [] + route_error_to_qa( + "Error", + "Build", + qa_agent=agent, + agent_context=agent_context, + token_tracker=None, + print_fn=printed.append, + max_display_chars=500, + ) + # The displayed content should be truncated + display_lines = [p for p in printed if p and "QA Diagnosis" not in p and p.strip()] + if display_lines: + assert len(display_lines[0]) <= 500 + + def test_custom_max_error_chars(self, qa_agent, agent_context): + long_error = "E" * 5000 + route_error_to_qa( + long_error, + "Build", + qa_agent=qa_agent, + agent_context=agent_context, + token_tracker=None, + print_fn=MagicMock(), + max_error_chars=50, + ) + task_arg = qa_agent.execute.call_args[0][1] + # The error text in the task should be truncated to 50 chars + assert "E" * 50 in task_arg + assert "E" * 51 not in task_arg From 84e5a8d153286d91443452392c94e42505be6277 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Mon, 6 Apr 2026 18:55:21 -0400 Subject: [PATCH 03/12] Tiers 3-6: 299 tests for deploy helpers, knowledge contributor, build/deploy stages, state persistence tests/stages/test_deploy_helpers.py (55): CLI path resolution, deploy env, TF secret scanning, secret resolution, login, deployment context tests/stages/test_knowledge_contributor.py (32): namespace resolution, gap detection, submission with label retry, auth checks tests/stages/test_build_stage.py (17): guard validation, state transitions, reset, dry-run routing, template matching tests/stages/test_deploy_stage.py (16): routing logic, state transitions tests/stages/test_deploy_state.py (53): build state sync, orphan handling, legacy fallback, rollback ordering, audit logging tests/stages/test_discovery_state.py (60): legacy migration, exchange updates, image stripping, item CRUD, context hash tests/stages/test_backlog_state.py (31): item management, push status, context hash, conversation tracking Total: 3448 tests passing across all tiers. --- tests/stages/test_backlog_state.py | 305 +++++++++ tests/stages/test_build_stage.py | 301 +++++++++ tests/stages/test_deploy_helpers.py | 744 +++++++++++++++++++++ tests/stages/test_deploy_stage.py | 309 +++++++++ tests/stages/test_deploy_state.py | 680 +++++++++++++++++++ tests/stages/test_discovery_state.py | 588 ++++++++++++++++ tests/stages/test_knowledge_contributor.py | 443 ++++++++++++ 7 files changed, 3370 insertions(+) create mode 100644 tests/stages/test_backlog_state.py create mode 100644 tests/stages/test_build_stage.py create mode 100644 tests/stages/test_deploy_helpers.py create mode 100644 tests/stages/test_deploy_stage.py create mode 100644 tests/stages/test_deploy_state.py create mode 100644 tests/stages/test_discovery_state.py create mode 100644 tests/stages/test_knowledge_contributor.py diff --git a/tests/stages/test_backlog_state.py b/tests/stages/test_backlog_state.py new file mode 100644 index 0000000..52f82b4 --- /dev/null +++ b/tests/stages/test_backlog_state.py @@ -0,0 +1,305 @@ +"""Tests for BacklogState — item management, push tracking, context hash. + +Covers: +- Item management (set_items, mark_item_pushed, mark_item_failed) +- Push status arrays synchronized with items +- Pending/pushed/failed item queries +- Context hash for cache invalidation +- matches_context validation +- Conversation tracking (update_from_exchange) +- Formatting (backlog summary, item detail) +- State persistence (load, save, reset) +""" + +import pytest + +from azext_prototype.stages.backlog_state import BacklogState, _default_backlog_state + +# ====================================================================== +# Fixtures +# ====================================================================== + + +@pytest.fixture +def backlog_state(tmp_project): + return BacklogState(str(tmp_project)) + + +@pytest.fixture +def backlog_state_with_items(backlog_state): + """Backlog state with sample items set.""" + items = [ + { + "epic": "Infrastructure", + "title": "Setup VNet", + "description": "Configure virtual network", + "effort": "M", + "acceptance_criteria": ["AC1", "AC2"], + "tasks": ["T1"], + }, + { + "epic": "Infrastructure", + "title": "Setup Key Vault", + "description": "Create KV for secrets", + "effort": "S", + }, + { + "epic": "Application", + "title": "Build API", + "description": "REST API on Container Apps", + "effort": "L", + "children": [ + {"title": "Create Dockerfile", "effort": "S"}, + {"title": "Add health check", "effort": "S"}, + ], + }, + ] + backlog_state.set_items(items) + return backlog_state + + +# ====================================================================== +# Item management +# ====================================================================== + + +class TestItemManagement: + """Test set_items and push status arrays.""" + + def test_set_items_stores_items(self, backlog_state): + items = [{"title": "A"}, {"title": "B"}] + backlog_state.set_items(items) + assert len(backlog_state._state["items"]) == 2 + + def test_set_items_resets_push_status(self, backlog_state): + backlog_state.set_items([{"title": "A"}, {"title": "B"}, {"title": "C"}]) + assert backlog_state._state["push_status"] == ["pending", "pending", "pending"] + assert backlog_state._state["push_results"] == [None, None, None] + + def test_set_items_replaces_previous(self, backlog_state): + backlog_state.set_items([{"title": "A"}]) + backlog_state.set_items([{"title": "X"}, {"title": "Y"}]) + assert len(backlog_state._state["items"]) == 2 + assert len(backlog_state._state["push_status"]) == 2 + + +# ====================================================================== +# Push status tracking +# ====================================================================== + + +class TestPushStatusTracking: + """Test mark_item_pushed and mark_item_failed.""" + + def test_mark_pushed(self, backlog_state_with_items): + backlog_state_with_items.mark_item_pushed(0, "https://github.com/issues/1") + assert backlog_state_with_items._state["push_status"][0] == "pushed" + assert backlog_state_with_items._state["push_results"][0] == "https://github.com/issues/1" + assert backlog_state_with_items._state["_metadata"]["last_pushed"] is not None + + def test_mark_failed(self, backlog_state_with_items): + backlog_state_with_items.mark_item_failed(1, "auth error") + assert backlog_state_with_items._state["push_status"][1] == "failed" + assert "auth error" in backlog_state_with_items._state["push_results"][1] + + def test_mark_out_of_range_no_error(self, backlog_state_with_items): + """Marking an out-of-range index is a no-op.""" + backlog_state_with_items.mark_item_pushed(99, "url") + backlog_state_with_items.mark_item_failed(99, "err") + # No crash, no change + assert all(s == "pending" for s in backlog_state_with_items._state["push_status"]) + + +# ====================================================================== +# Item queries +# ====================================================================== + + +class TestItemQueries: + """Test pending/pushed/failed item queries.""" + + def test_get_pending_items_all_pending(self, backlog_state_with_items): + pending = backlog_state_with_items.get_pending_items() + assert len(pending) == 3 + assert all(isinstance(p, tuple) and len(p) == 2 for p in pending) + + def test_get_pending_items_after_push(self, backlog_state_with_items): + backlog_state_with_items.mark_item_pushed(0, "url") + pending = backlog_state_with_items.get_pending_items() + assert len(pending) == 2 + assert all(p[0] != 0 for p in pending) + + def test_get_pushed_items(self, backlog_state_with_items): + backlog_state_with_items.mark_item_pushed(0, "url") + backlog_state_with_items.mark_item_pushed(2, "url2") + pushed = backlog_state_with_items.get_pushed_items() + assert len(pushed) == 2 + + def test_get_failed_items(self, backlog_state_with_items): + backlog_state_with_items.mark_item_failed(1, "err") + failed = backlog_state_with_items.get_failed_items() + assert len(failed) == 1 + assert failed[0][0] == 1 + + def test_get_pending_with_missing_status(self, backlog_state): + """Items beyond push_status array length are treated as pending.""" + backlog_state._state["items"] = [{"title": "A"}, {"title": "B"}] + backlog_state._state["push_status"] = ["pushed"] # Only 1 status for 2 items + pending = backlog_state.get_pending_items() + assert len(pending) == 1 + assert pending[0][0] == 1 + + +# ====================================================================== +# Context hash for cache invalidation +# ====================================================================== + + +class TestContextHash: + """Test context hash computation and matching.""" + + def test_set_context_hash(self, backlog_state): + backlog_state.set_context_hash("design context text") + h = backlog_state._state["context_hash"] + assert len(h) == 16 # truncated sha256 + + def test_matches_context_true(self, backlog_state): + backlog_state.set_context_hash("design context") + assert backlog_state.matches_context("design context") is True + + def test_matches_context_false(self, backlog_state): + backlog_state.set_context_hash("design context v1") + assert backlog_state.matches_context("design context v2") is False + + def test_matches_context_with_scope(self, backlog_state): + scope = {"in_scope": ["API"], "out_of_scope": ["ML"]} + backlog_state.set_context_hash("ctx", scope=scope) + assert backlog_state.matches_context("ctx", scope=scope) is True + assert backlog_state.matches_context("ctx", scope={"in_scope": ["Different"]}) is False + + def test_matches_context_no_hash_set(self, backlog_state): + assert backlog_state.matches_context("anything") is False + + +# ====================================================================== +# Conversation tracking +# ====================================================================== + + +class TestConversationTracking: + """Test exchange recording.""" + + def test_update_from_exchange(self, backlog_state): + backlog_state.update_from_exchange("Add more stories", "Here are 3 more stories", 1) + history = backlog_state._state["conversation_history"] + assert len(history) == 1 + assert history[0]["exchange"] == 1 + assert history[0]["user"] == "Add more stories" + + def test_multiple_exchanges(self, backlog_state): + backlog_state.update_from_exchange("Q1", "A1", 1) + backlog_state.update_from_exchange("Q2", "A2", 2) + assert len(backlog_state._state["conversation_history"]) == 2 + + +# ====================================================================== +# Formatting +# ====================================================================== + + +class TestFormatting: + """Test backlog summary and item detail formatting.""" + + def test_format_summary_empty(self, backlog_state): + result = backlog_state.format_backlog_summary() + assert "No backlog items" in result + + def test_format_summary_with_items(self, backlog_state_with_items): + result = backlog_state_with_items.format_backlog_summary() + assert "3 item(s)" in result + assert "Infrastructure" in result + assert "Application" in result + assert "3 pending" in result + + def test_format_summary_with_pushed(self, backlog_state_with_items): + backlog_state_with_items.mark_item_pushed(0, "url") + result = backlog_state_with_items.format_backlog_summary() + assert "1 pushed" in result + assert "2 pending" in result + + def test_format_summary_with_children(self, backlog_state_with_items): + result = backlog_state_with_items.format_backlog_summary() + assert "2 stories" in result # Item 3 has children + + def test_format_summary_with_provider(self, backlog_state_with_items): + backlog_state_with_items._state["provider"] = "github" + backlog_state_with_items._state["org"] = "myorg" + backlog_state_with_items._state["project"] = "myproject" + result = backlog_state_with_items.format_backlog_summary() + assert "github" in result + assert "myorg/myproject" in result + + def test_format_item_detail(self, backlog_state_with_items): + result = backlog_state_with_items.format_item_detail(0) + assert "Setup VNet" in result + assert "Configure virtual network" in result + assert "AC1" in result + assert "T1" in result + + def test_format_item_detail_with_children(self, backlog_state_with_items): + result = backlog_state_with_items.format_item_detail(2) + assert "Build API" in result + assert "Children (2)" in result + assert "Create Dockerfile" in result + + def test_format_item_detail_with_push_status(self, backlog_state_with_items): + backlog_state_with_items.mark_item_pushed(0, "https://github.com/issues/1") + result = backlog_state_with_items.format_item_detail(0) + assert "pushed" in result + assert "https://github.com/issues/1" in result + + def test_format_item_detail_out_of_range(self, backlog_state_with_items): + result = backlog_state_with_items.format_item_detail(99) + assert "not found" in result + + def test_format_item_detail_negative_index(self, backlog_state_with_items): + result = backlog_state_with_items.format_item_detail(-1) + assert "not found" in result + + +# ====================================================================== +# State persistence +# ====================================================================== + + +class TestStatePersistence: + """Test load, save, reset via BaseState.""" + + def test_save_and_load(self, backlog_state): + backlog_state.set_items([{"title": "Persist me"}]) + backlog_state.save() + + new_state = BacklogState(backlog_state._project_dir) + new_state.load() + assert len(new_state._state["items"]) == 1 + assert new_state._state["items"][0]["title"] == "Persist me" + + def test_reset(self, backlog_state_with_items): + backlog_state_with_items.reset() + assert backlog_state_with_items._state["items"] == [] + assert backlog_state_with_items._state["push_status"] == [] + + def test_exists_false_initially(self, backlog_state): + assert backlog_state.exists is False + + def test_exists_after_save(self, backlog_state): + backlog_state.save() + assert backlog_state.exists is True + + def test_default_state_structure(self): + state = _default_backlog_state() + assert "items" in state + assert "provider" in state + assert "push_status" in state + assert "context_hash" in state + assert "_metadata" in state diff --git a/tests/stages/test_build_stage.py b/tests/stages/test_build_stage.py new file mode 100644 index 0000000..adea885 --- /dev/null +++ b/tests/stages/test_build_stage.py @@ -0,0 +1,301 @@ +"""Tests for BuildStage — guard conditions, state transitions, dry-run routing. + +Covers: +- Multi-guard validation (3 prerequisites: project_initialized, discovery_complete, design_complete) +- State transitions (IN_PROGRESS, COMPLETED, FAILED) +- Reset behavior (clears build state and output dirs) +- Dry-run vs interactive routing +- Template matching with threshold scoring +- Design loading from state file +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from azext_prototype.stages.base import StageState + +# ====================================================================== +# Fixtures +# ====================================================================== + + +@pytest.fixture +def build_stage(): + from azext_prototype.stages.build_stage import BuildStage + + return BuildStage() + + +@pytest.fixture +def agent_context(project_with_design, sample_config): + from azext_prototype.agents.base import AgentContext + + provider = MagicMock() + provider.provider_name = "github-models" + provider.chat.return_value = MagicMock( + content="ok", + model="test", + usage={"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + ) + return AgentContext( + project_config=sample_config, + project_dir=str(project_with_design), + ai_provider=provider, + ) + + +@pytest.fixture +def registry(): + return MagicMock() + + +# ====================================================================== +# Guard validation +# ====================================================================== + + +class TestBuildStageGuards: + """Test multi-guard prerequisite checking.""" + + def test_guards_return_three_guards(self, build_stage): + guards = build_stage.get_guards() + assert len(guards) == 3 + names = [g.name for g in guards] + assert "project_initialized" in names + assert "discovery_complete" in names + assert "design_complete" in names + + def test_all_guards_pass(self, build_stage, project_with_design, monkeypatch): + """All files exist → can_run returns True.""" + monkeypatch.chdir(project_with_design) + # Ensure discovery.yaml exists + disco = project_with_design / ".prototype" / "state" / "discovery.yaml" + disco.write_text("exchange_count: 1", encoding="utf-8") + + can_run, failures = build_stage.can_run() + assert can_run is True + assert failures == [] + + def test_missing_project_yaml(self, build_stage, tmp_path, monkeypatch): + """No prototype.yaml → first guard fails.""" + monkeypatch.chdir(tmp_path) + can_run, failures = build_stage.can_run() + assert can_run is False + assert any("prototype" in f.lower() or "init" in f.lower() for f in failures) + + def test_missing_discovery_state(self, build_stage, project_with_config, monkeypatch): + """prototype.yaml exists but no discovery.yaml → discovery guard fails.""" + monkeypatch.chdir(project_with_config) + can_run, failures = build_stage.can_run() + assert can_run is False + assert any("discovery" in f.lower() for f in failures) + + def test_missing_design_json(self, build_stage, project_with_config, monkeypatch): + """Has prototype.yaml and discovery.yaml but no design.json → design guard fails.""" + monkeypatch.chdir(project_with_config) + disco = project_with_config / ".prototype" / "state" / "discovery.yaml" + disco.write_text("exchange_count: 1", encoding="utf-8") + can_run, failures = build_stage.can_run() + assert can_run is False + assert any("design" in f.lower() for f in failures) + + +# ====================================================================== +# State transitions +# ====================================================================== + + +class TestBuildStageStateTransitions: + """Test stage state moves correctly during execute.""" + + def test_initial_state_not_started(self, build_stage): + assert build_stage.state == StageState.NOT_STARTED + + def test_execute_sets_in_progress_then_completed(self, build_stage, agent_context, registry): + """Dry-run sets IN_PROGRESS then COMPLETED.""" + result = build_stage.execute(agent_context, registry, dry_run=True, print_fn=lambda x: None) + assert build_stage.state == StageState.COMPLETED + assert result["status"] == "dry-run" + + def test_cancelled_session_sets_failed(self, build_stage, agent_context, registry): + """When BuildSession returns cancelled, state goes to FAILED.""" + mock_result = MagicMock() + mock_result.cancelled = True + + with patch("azext_prototype.stages.build_stage.BuildSession") as mock_session_cls: + mock_session_cls.return_value.run.return_value = mock_result + result = build_stage.execute(agent_context, registry, print_fn=lambda x: None) + assert build_stage.state == StageState.FAILED + assert result["status"] == "cancelled" + + def test_successful_session_sets_completed(self, build_stage, agent_context, registry): + """When BuildSession completes successfully, state goes to COMPLETED.""" + mock_result = MagicMock() + mock_result.cancelled = False + mock_result.policy_overrides = [] + mock_result.files_generated = ["main.tf"] + mock_result.deployment_stages = [] + mock_result.resources = [] + + with ( + patch("azext_prototype.stages.build_stage.BuildSession") as mock_session_cls, + patch("azext_prototype.stages.build_stage.ProjectConfig") as mock_config_cls, + ): + mock_config_cls.return_value.load.return_value = None + mock_config_cls.return_value.get.return_value = "terraform" + mock_session_cls.return_value.run.return_value = mock_result + result = build_stage.execute(agent_context, registry, print_fn=lambda x: None) + assert build_stage.state == StageState.COMPLETED + assert result["status"] == "success" + + def test_missing_architecture_raises(self, build_stage, agent_context, registry): + """If design.json has no architecture key, CLIError is raised.""" + from knack.util import CLIError + + # Overwrite design.json with empty architecture + design_path = Path(agent_context.project_dir) / ".prototype" / "state" / "design.json" + with open(design_path, "w") as f: + json.dump({"artifacts": []}, f) + + with pytest.raises(CLIError, match="No architecture"): + build_stage.execute(agent_context, registry, print_fn=lambda x: None) + + +# ====================================================================== +# Reset behavior +# ====================================================================== + + +class TestBuildStageReset: + """Test reset clears build state and output directories.""" + + def test_reset_cleans_output_dirs(self, build_stage, agent_context, registry): + """--reset cleans concept/infra, concept/apps, etc.""" + project_dir = Path(agent_context.project_dir) + # Create output dirs + for d in build_stage._OUTPUT_DIRS: + (project_dir / d).mkdir(parents=True, exist_ok=True) + (project_dir / d / "test.tf").write_text("content", encoding="utf-8") + + # Run with reset + dry_run to avoid full session + build_stage.execute(agent_context, registry, reset=True, dry_run=True, print_fn=lambda x: None) + + for d in build_stage._OUTPUT_DIRS: + assert not (project_dir / d).is_dir() + + def test_reset_nonexistent_dirs_no_error(self, build_stage, agent_context, registry): + """--reset with no existing output dirs should not error.""" + build_stage.execute(agent_context, registry, reset=True, dry_run=True, print_fn=lambda x: None) + assert build_stage.state == StageState.COMPLETED + + +# ====================================================================== +# Dry-run routing +# ====================================================================== + + +class TestBuildStageDryRun: + """Test dry-run mode behavior.""" + + def test_dry_run_all_scope(self, build_stage, agent_context, registry): + printed = [] + result = build_stage.execute(agent_context, registry, dry_run=True, scope="all", print_fn=printed.append) + assert result["status"] == "dry-run" + assert result["scope"] == "all" + assert "infra" in result["results"] + assert "apps" in result["results"] + assert "db" in result["results"] + assert "docs" in result["results"] + + def test_dry_run_infra_only(self, build_stage, agent_context, registry): + printed = [] + result = build_stage.execute(agent_context, registry, dry_run=True, scope="infra", print_fn=printed.append) + assert "infra" in result["results"] + assert "apps" not in result["results"] + + def test_dry_run_apps_only(self, build_stage, agent_context, registry): + printed = [] + result = build_stage.execute(agent_context, registry, dry_run=True, scope="apps", print_fn=printed.append) + assert "apps" in result["results"] + assert "infra" not in result["results"] + + def test_dry_run_with_templates(self, build_stage, agent_context, registry): + """When templates match, dry-run shows template names.""" + printed = [] + mock_tmpl = MagicMock() + mock_tmpl.display_name = "Web App" + mock_tmpl.service_names.return_value = [] + + with patch.object(build_stage, "_match_templates", return_value=[mock_tmpl]): + build_stage.execute(agent_context, registry, dry_run=True, print_fn=printed.append) + assert any("Web App" in p for p in printed) + + +# ====================================================================== +# Template matching +# ====================================================================== + + +class TestTemplateMatching: + """Test template matching with threshold scoring.""" + + def test_match_templates_above_threshold(self, build_stage): + mock_tmpl = MagicMock() + mock_tmpl.service_names.return_value = ["key-vault", "app-service"] + + mock_registry = MagicMock() + mock_registry.list_templates.return_value = [mock_tmpl] + + with patch("azext_prototype.templates.registry.TemplateRegistry", return_value=mock_registry): + design = {"architecture": "Deploy key-vault and app-service resources"} + config = MagicMock() + templates = build_stage._match_templates(design, config) + assert len(templates) == 1 + + def test_match_templates_below_threshold(self, build_stage): + mock_tmpl = MagicMock() + mock_tmpl.service_names.return_value = ["key-vault", "cosmos-db", "redis", "apim"] + + mock_registry = MagicMock() + mock_registry.list_templates.return_value = [mock_tmpl] + + with patch("azext_prototype.templates.registry.TemplateRegistry", return_value=mock_registry): + design = {"architecture": "Only key-vault is mentioned"} + config = MagicMock() + templates = build_stage._match_templates(design, config) + assert len(templates) == 0 + + def test_match_templates_empty_architecture(self, build_stage): + design = {"architecture": ""} + config = MagicMock() + assert build_stage._match_templates(design, config) == [] + + def test_match_templates_no_templates_available(self, build_stage): + mock_registry = MagicMock() + mock_registry.list_templates.return_value = [] + + with patch("azext_prototype.templates.registry.TemplateRegistry", return_value=mock_registry): + design = {"architecture": "something"} + config = MagicMock() + templates = build_stage._match_templates(design, config) + assert templates == [] + + +# ====================================================================== +# Design loading +# ====================================================================== + + +class TestLoadDesign: + """Test _load_design from state file.""" + + def test_load_existing_design(self, build_stage, project_with_design): + design = build_stage._load_design(str(project_with_design)) + assert "architecture" in design + + def test_load_missing_design(self, build_stage, tmp_path): + design = build_stage._load_design(str(tmp_path)) + assert design == {} diff --git a/tests/stages/test_deploy_helpers.py b/tests/stages/test_deploy_helpers.py new file mode 100644 index 0000000..87bfb77 --- /dev/null +++ b/tests/stages/test_deploy_helpers.py @@ -0,0 +1,744 @@ +"""Tests for deploy_helpers — error handling paths. + +Covers: +- Azure CLI command execution with error handling (subprocess errors, FileNotFoundError, stderr parsing) +- Terraform secret variable scanning (suffix detection, default value overriding, deduplication) +- Secret resolution with generation (reuse existing, generate new, config update) +- Az CLI path resolution (Windows .cmd variant, fallback ordering) +- build_deploy_env construction +- check_az_login / get_current_subscription / get_current_tenant +- login_service_principal / set_deployment_context +- DeploymentOutputCapture: terraform/bicep capture, accessors, env vars +- find_bicep_params / is_subscription_scoped / get_deploy_location +""" + +import json +import os +import subprocess +from unittest.mock import MagicMock, patch + +# ====================================================================== +# _find_az / _az — Az CLI path resolution +# ====================================================================== + + +class TestFindAz: + """Test _find_az fallback chain.""" + + def test_shutil_which_found(self): + """When shutil.which finds az, return that path.""" + from azext_prototype.stages import deploy_helpers + + # Clear module cache + deploy_helpers._AZ = None + with patch("shutil.which", return_value="/usr/bin/az"): + result = deploy_helpers._find_az() + assert result == "/usr/bin/az" + + def test_falls_back_to_python_bin_dir(self, tmp_path): + """When shutil.which returns None, check Python's bin dir.""" + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + fake_az = tmp_path / "az" + fake_az.touch() + + with ( + patch("shutil.which", return_value=None), + patch.object(deploy_helpers.sys, "executable", str(tmp_path / "python")), + patch("os.path.isfile") as mock_isfile, + ): + # First call: az candidate check → True + # Second call (would be .cmd check) should not be reached + mock_isfile.side_effect = lambda p: p == str(tmp_path / "az") + result = deploy_helpers._find_az() + assert result == str(tmp_path / "az") + + def test_falls_back_to_windows_cmd(self, tmp_path): + """When az is not found but az.cmd exists, return .cmd path.""" + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with ( + patch("shutil.which", return_value=None), + patch.object(deploy_helpers.sys, "executable", str(tmp_path / "python")), + patch("os.path.isfile") as mock_isfile, + ): + # First call: az candidate → False, second call: az.cmd → True + def isfile_side(p): + return p.endswith(".cmd") + + mock_isfile.side_effect = isfile_side + result = deploy_helpers._find_az() + assert result.endswith(".cmd") + + def test_final_fallback_bare_az(self, tmp_path): + """When nothing else works, return bare 'az'.""" + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with ( + patch("shutil.which", return_value=None), + patch.object(deploy_helpers.sys, "executable", str(tmp_path / "python")), + patch("os.path.isfile", return_value=False), + ): + result = deploy_helpers._find_az() + assert result == "az" + + def test_az_caches_result(self): + """_az() caches on first call.""" + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with patch.object(deploy_helpers, "_find_az", return_value="/cached/az") as mock_find: + val1 = deploy_helpers._az() + val2 = deploy_helpers._az() + assert val1 == "/cached/az" + assert val2 == "/cached/az" + mock_find.assert_called_once() + # Clean up + deploy_helpers._AZ = None + + +# ====================================================================== +# build_deploy_env +# ====================================================================== + + +class TestBuildDeployEnv: + """Test build_deploy_env merges OS environ with Azure auth vars.""" + + def test_all_params_set(self): + from azext_prototype.stages.deploy_helpers import build_deploy_env + + env = build_deploy_env( + subscription="sub-123", + tenant="tenant-abc", + client_id="cid", + client_secret="csec", + ) + assert env["ARM_SUBSCRIPTION_ID"] == "sub-123" + assert env["TF_VAR_subscription_id"] == "sub-123" + assert env["SUBSCRIPTION_ID"] == "sub-123" + assert env["ARM_TENANT_ID"] == "tenant-abc" + assert env["ARM_CLIENT_ID"] == "cid" + assert env["ARM_CLIENT_SECRET"] == "csec" + + def test_none_params_skipped(self): + from azext_prototype.stages.deploy_helpers import build_deploy_env + + env = build_deploy_env(subscription="sub-only") + assert env["ARM_SUBSCRIPTION_ID"] == "sub-only" + assert "ARM_TENANT_ID" not in env or env.get("ARM_TENANT_ID") == os.environ.get("ARM_TENANT_ID") + + def test_includes_os_environ(self): + from azext_prototype.stages.deploy_helpers import build_deploy_env + + env = build_deploy_env() + # Should contain at least PATH from os.environ + assert "PATH" in env + + +# ====================================================================== +# Terraform Secret Variable Scanning +# ====================================================================== + + +class TestScanTfSecretVariables: + """Test scan_tf_secret_variables with suffix detection, defaults, dedup.""" + + def test_detects_secret_suffix(self, tmp_path): + from azext_prototype.stages.deploy_helpers import scan_tf_secret_variables + + tf_file = tmp_path / "main.tf" + tf_file.write_text( + """ +variable "db_password" { + type = string +} +""", + encoding="utf-8", + ) + result = scan_tf_secret_variables(tmp_path) + assert "db_password" in result + + def test_detects_secret_suffix_underscore(self, tmp_path): + from azext_prototype.stages.deploy_helpers import scan_tf_secret_variables + + tf_file = tmp_path / "main.tf" + tf_file.write_text( + """ +variable "api_secret" { + type = string +} +""", + encoding="utf-8", + ) + result = scan_tf_secret_variables(tmp_path) + assert "api_secret" in result + + def test_skips_non_secret_variable(self, tmp_path): + from azext_prototype.stages.deploy_helpers import scan_tf_secret_variables + + tf_file = tmp_path / "main.tf" + tf_file.write_text( + """ +variable "resource_group_name" { + type = string +} +""", + encoding="utf-8", + ) + result = scan_tf_secret_variables(tmp_path) + assert result == [] + + def test_skips_known_auth_variables(self, tmp_path): + from azext_prototype.stages.deploy_helpers import scan_tf_secret_variables + + tf_file = tmp_path / "main.tf" + tf_file.write_text( + """ +variable "client_secret" { + type = string +} +""", + encoding="utf-8", + ) + result = scan_tf_secret_variables(tmp_path) + assert "client_secret" not in result + + def test_skips_variable_with_non_empty_default(self, tmp_path): + from azext_prototype.stages.deploy_helpers import scan_tf_secret_variables + + tf_file = tmp_path / "main.tf" + tf_file.write_text( + """ +variable "db_password" { + type = string + default = "predefined-value" +} +""", + encoding="utf-8", + ) + result = scan_tf_secret_variables(tmp_path) + assert result == [] + + def test_includes_variable_with_empty_default(self, tmp_path): + from azext_prototype.stages.deploy_helpers import scan_tf_secret_variables + + tf_file = tmp_path / "main.tf" + tf_file.write_text( + """ +variable "db_password" { + type = string + default = "" +} +""", + encoding="utf-8", + ) + result = scan_tf_secret_variables(tmp_path) + assert "db_password" in result + + def test_deduplicates_across_files(self, tmp_path): + from azext_prototype.stages.deploy_helpers import scan_tf_secret_variables + + (tmp_path / "a.tf").write_text( + 'variable "db_password" {\n type = string\n}\n', + encoding="utf-8", + ) + (tmp_path / "b.tf").write_text( + 'variable "db_password" {\n type = string\n}\n', + encoding="utf-8", + ) + result = scan_tf_secret_variables(tmp_path) + assert result.count("db_password") == 1 + + def test_handles_unreadable_file(self, tmp_path): + from azext_prototype.stages.deploy_helpers import scan_tf_secret_variables + + tf_file = tmp_path / "main.tf" + tf_file.write_text("some content", encoding="utf-8") + # Make unreadable (best-effort; may not work on all platforms) + with patch("pathlib.Path.read_text", side_effect=OSError("permission denied")): + result = scan_tf_secret_variables(tmp_path) + assert result == [] + + def test_no_tf_files(self, tmp_path): + from azext_prototype.stages.deploy_helpers import scan_tf_secret_variables + + result = scan_tf_secret_variables(tmp_path) + assert result == [] + + +# ====================================================================== +# resolve_stage_secrets +# ====================================================================== + + +class TestResolveStageSecrets: + """Test secret resolution: reuse existing, generate new, config update.""" + + def test_no_secrets_needed(self, tmp_path): + from azext_prototype.stages.deploy_helpers import resolve_stage_secrets + + (tmp_path / "main.tf").write_text( + 'variable "name" {\n type = string\n}\n', + encoding="utf-8", + ) + config = MagicMock() + result = resolve_stage_secrets(tmp_path, config) + assert result == {} + + def test_generates_new_secret(self, tmp_path): + from azext_prototype.stages.deploy_helpers import resolve_stage_secrets + + (tmp_path / "main.tf").write_text( + 'variable "db_password" {\n type = string\n}\n', + encoding="utf-8", + ) + config = MagicMock() + config.get.return_value = {} + result = resolve_stage_secrets(tmp_path, config) + assert "TF_VAR_db_password" in result + assert len(result["TF_VAR_db_password"]) == 64 # 32 bytes hex + config.set.assert_called_once() + + def test_reuses_existing_secret(self, tmp_path): + from azext_prototype.stages.deploy_helpers import resolve_stage_secrets + + (tmp_path / "main.tf").write_text( + 'variable "db_password" {\n type = string\n}\n', + encoding="utf-8", + ) + config = MagicMock() + config.get.return_value = {"db_password": "existing-secret-value"} + result = resolve_stage_secrets(tmp_path, config) + assert result["TF_VAR_db_password"] == "existing-secret-value" + config.set.assert_not_called() + + def test_stored_not_dict_generates_new(self, tmp_path): + """If stored secrets is a non-dict, treat as missing.""" + from azext_prototype.stages.deploy_helpers import resolve_stage_secrets + + (tmp_path / "main.tf").write_text( + 'variable "db_password" {\n type = string\n}\n', + encoding="utf-8", + ) + config = MagicMock() + config.get.return_value = "not-a-dict" + result = resolve_stage_secrets(tmp_path, config) + assert "TF_VAR_db_password" in result + config.set.assert_called_once() + + +# ====================================================================== +# check_az_login / get_current_subscription / get_current_tenant +# ====================================================================== + + +class TestAzCliCommands: + """Test Azure CLI command wrappers with error handling.""" + + def test_check_az_login_success(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with patch("subprocess.run") as mock_run, patch.object(deploy_helpers, "_find_az", return_value="az"): + mock_run.return_value = MagicMock(returncode=0) + deploy_helpers._AZ = None + result = deploy_helpers.check_az_login() + assert result is True + + def test_check_az_login_failure(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with patch("subprocess.run") as mock_run, patch.object(deploy_helpers, "_find_az", return_value="az"): + mock_run.return_value = MagicMock(returncode=1) + deploy_helpers._AZ = None + result = deploy_helpers.check_az_login() + assert result is False + + def test_check_az_login_file_not_found(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with ( + patch("subprocess.run", side_effect=FileNotFoundError), + patch.object(deploy_helpers, "_find_az", return_value="az"), + ): + deploy_helpers._AZ = None + result = deploy_helpers.check_az_login() + assert result is False + + def test_get_current_subscription_success(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with patch("subprocess.run") as mock_run, patch.object(deploy_helpers, "_find_az", return_value="az"): + mock_run.return_value = MagicMock(returncode=0, stdout="sub-id-123\n") + deploy_helpers._AZ = None + result = deploy_helpers.get_current_subscription() + assert result == "sub-id-123" + + def test_get_current_subscription_error(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with ( + patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "az")), + patch.object(deploy_helpers, "_find_az", return_value="az"), + ): + deploy_helpers._AZ = None + result = deploy_helpers.get_current_subscription() + assert result == "" + + def test_get_current_subscription_file_not_found(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with ( + patch("subprocess.run", side_effect=FileNotFoundError), + patch.object(deploy_helpers, "_find_az", return_value="az"), + ): + deploy_helpers._AZ = None + result = deploy_helpers.get_current_subscription() + assert result == "" + + def test_get_current_tenant_success(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with patch("subprocess.run") as mock_run, patch.object(deploy_helpers, "_find_az", return_value="az"): + mock_run.return_value = MagicMock(returncode=0, stdout="tenant-abc\n") + deploy_helpers._AZ = None + result = deploy_helpers.get_current_tenant() + assert result == "tenant-abc" + + def test_get_current_tenant_error(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with ( + patch("subprocess.run", side_effect=FileNotFoundError), + patch.object(deploy_helpers, "_find_az", return_value="az"), + ): + deploy_helpers._AZ = None + result = deploy_helpers.get_current_tenant() + assert result == "" + + +# ====================================================================== +# login_service_principal +# ====================================================================== + + +class TestLoginServicePrincipal: + """Test service principal login with error paths.""" + + def test_login_success(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with ( + patch("subprocess.run") as mock_run, + patch.object(deploy_helpers, "_find_az", return_value="az"), + patch.object(deploy_helpers, "get_current_subscription", return_value="sub-after"), + ): + mock_run.return_value = MagicMock(returncode=0) + deploy_helpers._AZ = None + result = deploy_helpers.login_service_principal("cid", "csec", "tid") + assert result["status"] == "ok" + assert result["subscription"] == "sub-after" + + def test_login_failure_returncode(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with patch("subprocess.run") as mock_run, patch.object(deploy_helpers, "_find_az", return_value="az"): + mock_run.return_value = MagicMock(returncode=1, stderr="auth failed", stdout="") + deploy_helpers._AZ = None + result = deploy_helpers.login_service_principal("cid", "csec", "tid") + assert result["status"] == "failed" + assert "auth failed" in result["error"] + + def test_login_file_not_found(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with ( + patch("subprocess.run", side_effect=FileNotFoundError), + patch.object(deploy_helpers, "_find_az", return_value="az"), + ): + deploy_helpers._AZ = None + result = deploy_helpers.login_service_principal("cid", "csec", "tid") + assert result["status"] == "failed" + assert "not found" in result["error"] + + +# ====================================================================== +# set_deployment_context +# ====================================================================== + + +class TestSetDeploymentContext: + """Test set_deployment_context with tenant and error paths.""" + + def test_success_without_tenant(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with patch("subprocess.run") as mock_run, patch.object(deploy_helpers, "_find_az", return_value="az"): + mock_run.return_value = MagicMock(returncode=0) + deploy_helpers._AZ = None + result = deploy_helpers.set_deployment_context("sub-123") + assert result["status"] == "ok" + + def test_success_with_tenant(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with patch("subprocess.run") as mock_run, patch.object(deploy_helpers, "_find_az", return_value="az"): + mock_run.return_value = MagicMock(returncode=0) + deploy_helpers._AZ = None + result = deploy_helpers.set_deployment_context("sub-123", tenant="tid") + assert result["status"] == "ok" + # Verify --tenant flag was passed + call_args = mock_run.call_args[0][0] + assert "--tenant" in call_args + assert "tid" in call_args + + def test_failure_returncode(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with patch("subprocess.run") as mock_run, patch.object(deploy_helpers, "_find_az", return_value="az"): + mock_run.return_value = MagicMock(returncode=1, stderr="bad sub", stdout="") + deploy_helpers._AZ = None + result = deploy_helpers.set_deployment_context("bad-sub") + assert result["status"] == "failed" + assert "bad sub" in result["error"] + + def test_file_not_found(self): + from azext_prototype.stages import deploy_helpers + + deploy_helpers._AZ = None + with ( + patch("subprocess.run", side_effect=FileNotFoundError), + patch.object(deploy_helpers, "_find_az", return_value="az"), + ): + deploy_helpers._AZ = None + result = deploy_helpers.set_deployment_context("sub-123") + assert result["status"] == "failed" + assert "not found" in result["error"] + + +# ====================================================================== +# find_bicep_params / is_subscription_scoped / get_deploy_location +# ====================================================================== + + +class TestBicepDiscovery: + """Test Bicep template/parameter file discovery helpers.""" + + def test_find_bicep_params_parameters_json(self, tmp_path): + from azext_prototype.stages.deploy_helpers import find_bicep_params + + (tmp_path / "main.parameters.json").touch() + result = find_bicep_params(tmp_path, tmp_path / "main.bicep") + assert result == tmp_path / "main.parameters.json" + + def test_find_bicep_params_bicepparam(self, tmp_path): + from azext_prototype.stages.deploy_helpers import find_bicep_params + + (tmp_path / "main.bicepparam").touch() + result = find_bicep_params(tmp_path, tmp_path / "main.bicep") + assert result == tmp_path / "main.bicepparam" + + def test_find_bicep_params_generic(self, tmp_path): + from azext_prototype.stages.deploy_helpers import find_bicep_params + + (tmp_path / "parameters.json").touch() + result = find_bicep_params(tmp_path, tmp_path / "main.bicep") + assert result == tmp_path / "parameters.json" + + def test_find_bicep_params_none(self, tmp_path): + from azext_prototype.stages.deploy_helpers import find_bicep_params + + result = find_bicep_params(tmp_path, tmp_path / "main.bicep") + assert result is None + + def test_find_bicep_params_priority(self, tmp_path): + """parameters.json beats bicepparam, stem.parameters.json beats both.""" + from azext_prototype.stages.deploy_helpers import find_bicep_params + + (tmp_path / "main.parameters.json").touch() + (tmp_path / "main.bicepparam").touch() + (tmp_path / "parameters.json").touch() + result = find_bicep_params(tmp_path, tmp_path / "main.bicep") + assert result == tmp_path / "main.parameters.json" + + def test_is_subscription_scoped_true(self, tmp_path): + from azext_prototype.stages.deploy_helpers import is_subscription_scoped + + bicep_file = tmp_path / "main.bicep" + bicep_file.write_text("targetScope = 'subscription'\n\nresource rg ...", encoding="utf-8") + assert is_subscription_scoped(bicep_file) is True + + def test_is_subscription_scoped_false(self, tmp_path): + from azext_prototype.stages.deploy_helpers import is_subscription_scoped + + bicep_file = tmp_path / "main.bicep" + bicep_file.write_text("resource kv 'Microsoft.KeyVault/vaults@...'", encoding="utf-8") + assert is_subscription_scoped(bicep_file) is False + + def test_is_subscription_scoped_unreadable(self, tmp_path): + from azext_prototype.stages.deploy_helpers import is_subscription_scoped + + bicep_file = tmp_path / "missing.bicep" + assert is_subscription_scoped(bicep_file) is False + + def test_get_deploy_location_from_params(self, tmp_path): + from azext_prototype.stages.deploy_helpers import get_deploy_location + + params = {"parameters": {"location": {"value": "westus2"}}} + (tmp_path / "parameters.json").write_text(json.dumps(params), encoding="utf-8") + assert get_deploy_location(tmp_path) == "westus2" + + def test_get_deploy_location_string_value(self, tmp_path): + from azext_prototype.stages.deploy_helpers import get_deploy_location + + params = {"location": "eastus"} + (tmp_path / "parameters.json").write_text(json.dumps(params), encoding="utf-8") + assert get_deploy_location(tmp_path) == "eastus" + + def test_get_deploy_location_none(self, tmp_path): + from azext_prototype.stages.deploy_helpers import get_deploy_location + + assert get_deploy_location(tmp_path) is None + + def test_get_deploy_location_bad_json(self, tmp_path): + from azext_prototype.stages.deploy_helpers import get_deploy_location + + (tmp_path / "parameters.json").write_text("not json", encoding="utf-8") + assert get_deploy_location(tmp_path) is None + + +# ====================================================================== +# DeploymentOutputCapture +# ====================================================================== + + +class TestDeploymentOutputCapture: + """Test capture, accessors, and env var generation.""" + + def test_capture_terraform(self, tmp_path): + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + cap = DeploymentOutputCapture(str(tmp_path)) + tf_output = json.dumps( + { + "endpoint": {"value": "https://app.azurewebsites.net", "type": "string"}, + "key": {"value": "abc123", "type": "string"}, + } + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=tf_output) + result = cap.capture_terraform(tmp_path / "infra") + assert result["endpoint"] == "https://app.azurewebsites.net" + assert result["key"] == "abc123" + + def test_capture_terraform_failure(self, tmp_path): + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + cap = DeploymentOutputCapture(str(tmp_path)) + with patch("subprocess.run", side_effect=FileNotFoundError): + result = cap.capture_terraform(tmp_path / "infra") + assert result == {} + + def test_capture_bicep(self, tmp_path): + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + cap = DeploymentOutputCapture(str(tmp_path)) + bicep_output = json.dumps( + { + "properties": { + "outputs": { + "storageEndpoint": {"value": "https://storage.blob.core.windows.net", "type": "string"}, + } + } + } + ) + result = cap.capture_bicep(bicep_output) + assert result["storageEndpoint"] == "https://storage.blob.core.windows.net" + + def test_capture_bicep_bad_json(self, tmp_path): + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + cap = DeploymentOutputCapture(str(tmp_path)) + result = cap.capture_bicep("not json") + assert result == {} + + def test_get_across_providers(self, tmp_path): + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + cap = DeploymentOutputCapture(str(tmp_path)) + cap._outputs = { + "terraform": {"key1": "val1"}, + "bicep": {"key2": "val2"}, + } + assert cap.get("key1") == "val1" + assert cap.get("key2") == "val2" + assert cap.get("missing", "default") == "default" + + def test_get_all(self, tmp_path): + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + cap = DeploymentOutputCapture(str(tmp_path)) + cap._outputs = {"terraform": {"a": 1}} + all_outputs = cap.get_all() + assert all_outputs == {"terraform": {"a": 1}} + # Verify it's a copy + all_outputs["extra"] = True + assert "extra" not in cap._outputs + + def test_to_env_vars(self, tmp_path): + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + cap = DeploymentOutputCapture(str(tmp_path)) + cap._outputs = { + "terraform": {"endpoint": "https://app.com"}, + "bicep": {"storage_key": "secret"}, + } + env = cap.to_env_vars() + assert env["PROTOTYPE_ENDPOINT"] == "https://app.com" + assert env["PROTOTYPE_STORAGE_KEY"] == "secret" + + def test_flatten_outputs_plain_values(self, tmp_path): + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + flat = DeploymentOutputCapture._flatten_outputs({"simple": "value"}) + assert flat["simple"] == "value" + + def test_load_existing(self, tmp_path): + """Test that existing outputs file is loaded on construction.""" + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + output_dir = tmp_path / ".prototype" / "state" + output_dir.mkdir(parents=True) + (output_dir / "deployment_outputs.json").write_text( + json.dumps({"terraform": {"x": "y"}}), + encoding="utf-8", + ) + cap = DeploymentOutputCapture(str(tmp_path)) + assert cap.get("x") == "y" + + def test_load_bad_json(self, tmp_path): + """Bad JSON in existing outputs file falls back to empty dict.""" + from azext_prototype.stages.deploy_helpers import DeploymentOutputCapture + + output_dir = tmp_path / ".prototype" / "state" + output_dir.mkdir(parents=True) + (output_dir / "deployment_outputs.json").write_text("not json", encoding="utf-8") + cap = DeploymentOutputCapture(str(tmp_path)) + assert cap._outputs == {} diff --git a/tests/stages/test_deploy_stage.py b/tests/stages/test_deploy_stage.py new file mode 100644 index 0000000..00bc498 --- /dev/null +++ b/tests/stages/test_deploy_stage.py @@ -0,0 +1,309 @@ +"""Tests for DeployStage — routing logic, state transitions, guard conditions. + +Covers: +- Guard conditions (project_initialized, build_complete, az_logged_in) +- Routing: --status, --reset, --dry-run, --stage N, interactive +- State transitions between modes +- _result_to_dict conversion +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from azext_prototype.stages.base import StageState + +# ====================================================================== +# Fixtures +# ====================================================================== + + +@pytest.fixture +def deploy_stage(): + from azext_prototype.stages.deploy_stage import DeployStage + + return DeployStage() + + +@pytest.fixture +def agent_context(project_with_build, sample_config): + from azext_prototype.agents.base import AgentContext + + provider = MagicMock() + provider.provider_name = "github-models" + provider.chat.return_value = MagicMock(content="ok", model="test", usage={}) + return AgentContext( + project_config=sample_config, + project_dir=str(project_with_build), + ai_provider=provider, + ) + + +@pytest.fixture +def registry(): + return MagicMock() + + +# ====================================================================== +# Guard validation +# ====================================================================== + + +class TestDeployStageGuards: + """Test deploy stage prerequisites.""" + + def test_guards_return_three_guards(self, deploy_stage): + guards = deploy_stage.get_guards() + assert len(guards) == 3 + names = [g.name for g in guards] + assert "project_initialized" in names + assert "build_complete" in names + assert "az_logged_in" in names + + def test_all_guards_pass(self, deploy_stage, project_with_build, monkeypatch): + monkeypatch.chdir(project_with_build) + with patch("azext_prototype.stages.deploy_helpers.check_az_login", return_value=True): + # Reload guards with the patched function + from azext_prototype.stages.deploy_stage import DeployStage + + stage = DeployStage() + can_run, failures = stage.can_run() + assert can_run is True + assert failures == [] + + def test_missing_project_yaml(self, deploy_stage, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + can_run, failures = deploy_stage.can_run() + assert can_run is False + assert any("init" in f.lower() or "prototype" in f.lower() for f in failures) + + def test_missing_build_state(self, deploy_stage, project_with_config, monkeypatch): + monkeypatch.chdir(project_with_config) + can_run, failures = deploy_stage.can_run() + assert can_run is False + assert any("build" in f.lower() for f in failures) + + def test_not_logged_in(self, deploy_stage, project_with_build, monkeypatch): + monkeypatch.chdir(project_with_build) + with patch("azext_prototype.stages.deploy_stage.check_az_login", return_value=False): + from azext_prototype.stages.deploy_stage import DeployStage + + stage = DeployStage() + can_run, failures = stage.can_run() + assert can_run is False + assert any("login" in f.lower() for f in failures) + + +# ====================================================================== +# --status routing +# ====================================================================== + + +class TestDeployStageStatusRoute: + """Test --status shows current progress without starting session.""" + + def test_status_route(self, deploy_stage, agent_context, registry): + with patch("azext_prototype.stages.deploy_stage.DeployState") as mock_ds, patch( + "azext_prototype.stages.deploy_stage.default_console" + ) as mock_console: + mock_ds.return_value.load.return_value = None + mock_ds.return_value.format_stage_status.return_value = "Stage status output" + result = deploy_stage.execute(agent_context, registry, status=True) + assert result["status"] == "status_displayed" + assert deploy_stage.state == StageState.COMPLETED + mock_console.print_info.assert_called_once() + + +# ====================================================================== +# --reset routing +# ====================================================================== + + +class TestDeployStageResetRoute: + """Test --reset clears deploy state.""" + + def test_reset_route(self, deploy_stage, agent_context, registry): + with patch("azext_prototype.stages.deploy_stage.DeployState") as mock_ds, patch( + "azext_prototype.stages.deploy_stage.default_console" + ): + result = deploy_stage.execute(agent_context, registry, reset=True) + assert result["status"] == "reset" + assert deploy_stage.state == StageState.COMPLETED + mock_ds.return_value.reset.assert_called_once() + + +# ====================================================================== +# --dry-run routing +# ====================================================================== + + +class TestDeployStageDryRunRoute: + """Test --dry-run delegates to session.run_dry_run().""" + + def test_dry_run_route(self, deploy_stage, agent_context, registry): + mock_result = MagicMock() + mock_result.failed_stages = [] + mock_result.cancelled = False + mock_result.deployed_stages = [] + mock_result.rolled_back_stages = [] + mock_result.captured_outputs = {} + + with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_session_cls: + mock_session_cls.return_value.run_dry_run.return_value = mock_result + result = deploy_stage.execute(agent_context, registry, dry_run=True, subscription="sub-123") + assert result["status"] == "success" + assert result["mode"] == "dry-run" + assert deploy_stage.state == StageState.COMPLETED + + def test_dry_run_with_stage(self, deploy_stage, agent_context, registry): + mock_result = MagicMock() + mock_result.failed_stages = [] + mock_result.cancelled = False + mock_result.deployed_stages = [] + mock_result.rolled_back_stages = [] + mock_result.captured_outputs = {} + + with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_session_cls: + mock_session_cls.return_value.run_dry_run.return_value = mock_result + result = deploy_stage.execute(agent_context, registry, dry_run=True, stage=2) + assert result["mode"] == "dry-run" + mock_session_cls.return_value.run_dry_run.assert_called_once() + + +# ====================================================================== +# --stage N routing +# ====================================================================== + + +class TestDeployStageSingleStageRoute: + """Test --stage N delegates to session.run_single_stage().""" + + def test_single_stage_success(self, deploy_stage, agent_context, registry): + mock_result = MagicMock() + mock_result.failed_stages = [] + mock_result.cancelled = False + mock_result.deployed_stages = ["stage-1"] + mock_result.rolled_back_stages = [] + mock_result.captured_outputs = {} + + with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_session_cls: + mock_session_cls.return_value.run_single_stage.return_value = mock_result + result = deploy_stage.execute(agent_context, registry, stage=1, subscription="sub-123") + assert result["mode"] == "single_stage" + assert deploy_stage.state == StageState.COMPLETED + + def test_single_stage_failure(self, deploy_stage, agent_context, registry): + mock_result = MagicMock() + mock_result.failed_stages = ["stage-1"] + mock_result.cancelled = False + mock_result.deployed_stages = [] + mock_result.rolled_back_stages = [] + mock_result.captured_outputs = {} + + with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_session_cls: + mock_session_cls.return_value.run_single_stage.return_value = mock_result + result = deploy_stage.execute(agent_context, registry, stage=1) + assert result["status"] == "partial_failure" + assert deploy_stage.state == StageState.FAILED + + +# ====================================================================== +# Interactive (default) routing +# ====================================================================== + + +class TestDeployStageInteractiveRoute: + """Test default interactive mode delegates to session.run().""" + + def test_interactive_success(self, deploy_stage, agent_context, registry): + mock_result = MagicMock() + mock_result.failed_stages = [] + mock_result.cancelled = False + mock_result.deployed_stages = ["stage-1", "stage-2"] + mock_result.rolled_back_stages = [] + mock_result.captured_outputs = {"terraform": {"key": "val"}} + + with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_session_cls: + mock_session_cls.return_value.run.return_value = mock_result + result = deploy_stage.execute(agent_context, registry) + assert result["status"] == "success" + assert result["mode"] == "interactive" + assert result["deployed"] == 2 + assert deploy_stage.state == StageState.COMPLETED + + def test_interactive_cancelled(self, deploy_stage, agent_context, registry): + mock_result = MagicMock() + mock_result.cancelled = True + mock_result.failed_stages = [] + mock_result.deployed_stages = [] + mock_result.rolled_back_stages = [] + mock_result.captured_outputs = {} + + with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_session_cls: + mock_session_cls.return_value.run.return_value = mock_result + result = deploy_stage.execute(agent_context, registry) + assert result["status"] == "cancelled" + assert deploy_stage.state == StageState.COMPLETED + + def test_interactive_partial_failure(self, deploy_stage, agent_context, registry): + mock_result = MagicMock() + mock_result.cancelled = False + mock_result.failed_stages = ["stage-2"] + mock_result.deployed_stages = ["stage-1"] + mock_result.rolled_back_stages = [] + mock_result.captured_outputs = {} + + with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_session_cls: + mock_session_cls.return_value.run.return_value = mock_result + result = deploy_stage.execute(agent_context, registry) + assert result["status"] == "partial_failure" + assert deploy_stage.state == StageState.FAILED + + +# ====================================================================== +# _result_to_dict +# ====================================================================== + + +class TestResultToDict: + """Test the result-to-dict conversion helper.""" + + def test_success(self): + from azext_prototype.stages.deploy_stage import _result_to_dict + + result = MagicMock() + result.failed_stages = [] + result.cancelled = False + result.deployed_stages = ["a", "b"] + result.rolled_back_stages = [] + result.captured_outputs = {"tf": {"x": 1}} + d = _result_to_dict(result, "test") + assert d["status"] == "success" + assert d["mode"] == "test" + assert d["deployed"] == 2 + assert d["failed"] == 0 + + def test_partial_failure(self): + from azext_prototype.stages.deploy_stage import _result_to_dict + + result = MagicMock() + result.failed_stages = ["x"] + result.cancelled = False + result.deployed_stages = ["a"] + result.rolled_back_stages = ["b"] + result.captured_outputs = {} + d = _result_to_dict(result, "interactive") + assert d["status"] == "partial_failure" + assert d["rolled_back"] == 1 + + def test_cancelled(self): + from azext_prototype.stages.deploy_stage import _result_to_dict + + result = MagicMock() + result.failed_stages = [] + result.cancelled = True + result.deployed_stages = [] + result.rolled_back_stages = [] + result.captured_outputs = {} + d = _result_to_dict(result, "interactive") + assert d["status"] == "cancelled" diff --git a/tests/stages/test_deploy_state.py b/tests/stages/test_deploy_state.py new file mode 100644 index 0000000..982d1f8 --- /dev/null +++ b/tests/stages/test_deploy_state.py @@ -0,0 +1,680 @@ +"""Tests for DeployState — stage sync, legacy fallback, state persistence. + +Covers: +- Stage sync with build state (matched, orphaned, new stages) +- Legacy fallback matching (name+capability) +- Post-load backfill of build_stage_ids +- Stage splitting (1:N divergence) +- Stage status transitions (deploying, deployed, failed, rolled_back, etc.) +- Rollback ordering enforcement +- Preflight result tracking +- Audit logging (deploy_log, rollback_log) +- Display formatting methods +- parse_stage_ref / _format_display_id / _status_icon +- Conversation tracking +- add_patch_stages / renumber_stages +""" + +from pathlib import Path + +import pytest +import yaml + +from azext_prototype.stages.deploy_state import ( + DeployState, + _enrich_deploy_fields, + _format_display_id, + _status_icon, + parse_stage_ref, +) + +# ====================================================================== +# Fixtures +# ====================================================================== + + +@pytest.fixture +def deploy_state(tmp_project): + ds = DeployState(str(tmp_project)) + return ds + + +@pytest.fixture +def deploy_state_with_stages(deploy_state): + """Deploy state with 2 stages loaded.""" + deploy_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [{"name": "kv"}], + "build_stage_id": "foundation", + "deploy_status": "pending", + "deploy_timestamp": None, + "deploy_output": "", + "deploy_error": "", + "rollback_timestamp": None, + "remediation_attempts": 0, + "deploy_mode": "auto", + "manual_instructions": None, + "substage_label": None, + "_is_substage": False, + "_destruction_declined": False, + "dir": "concept/infra/stage-1", + "files": ["main.tf"], + }, + { + "stage": 2, + "name": "Application", + "capability": "app", + "services": [{"name": "web"}], + "build_stage_id": "application", + "deploy_status": "pending", + "deploy_timestamp": None, + "deploy_output": "", + "deploy_error": "", + "rollback_timestamp": None, + "remediation_attempts": 0, + "deploy_mode": "auto", + "manual_instructions": None, + "substage_label": None, + "_is_substage": False, + "_destruction_declined": False, + "dir": "concept/apps/stage-2", + "files": ["app.py"], + }, + ] + return deploy_state + + +# ====================================================================== +# load_from_build_state +# ====================================================================== + + +class TestLoadFromBuildState: + """Test importing deployment stages from build.yaml.""" + + def test_imports_stages(self, deploy_state, project_with_build): + build_path = Path(str(project_with_build)) / ".prototype" / "state" / "build.yaml" + result = deploy_state.load_from_build_state(build_path) + assert result is True + stages = deploy_state._state["deployment_stages"] + assert len(stages) == 2 + assert stages[0]["build_stage_id"] is not None + assert stages[0]["deploy_status"] == "pending" + + def test_missing_build_file(self, deploy_state, tmp_path): + result = deploy_state.load_from_build_state(tmp_path / "missing.yaml") + assert result is False + + def test_empty_build_stages(self, deploy_state, tmp_path): + build_file = tmp_path / "build.yaml" + build_file.write_text(yaml.dump({"deployment_stages": []}), encoding="utf-8") + result = deploy_state.load_from_build_state(build_file) + assert result is False + + def test_bad_yaml(self, deploy_state, tmp_path): + build_file = tmp_path / "build.yaml" + build_file.write_text(": invalid: yaml: {{", encoding="utf-8") + result = deploy_state.load_from_build_state(build_file) + assert result is False + + def test_iac_tool_carried_over(self, deploy_state, tmp_path): + build_file = tmp_path / "build.yaml" + build_file.write_text( + yaml.dump( + { + "iac_tool": "bicep", + "deployment_stages": [{"stage": 1, "name": "Foundation"}], + } + ), + encoding="utf-8", + ) + deploy_state.load_from_build_state(build_file) + assert deploy_state._state["iac_tool"] == "bicep" + + +# ====================================================================== +# sync_from_build_state +# ====================================================================== + + +class TestSyncFromBuildState: + """Test smart reconciliation: matched, orphaned, new stages.""" + + def test_matched_stages(self, deploy_state_with_stages, tmp_path): + """Build state with same IDs → matched, no new or orphaned.""" + build_file = tmp_path / "build.yaml" + build_file.write_text( + yaml.dump( + { + "deployment_stages": [ + {"id": "foundation", "name": "Foundation", "capability": "infra"}, + {"id": "application", "name": "Application", "capability": "app"}, + ] + } + ), + encoding="utf-8", + ) + result = deploy_state_with_stages.sync_from_build_state(build_file) + assert result.matched == 2 + assert result.created == 0 + assert result.orphaned == 0 + + def test_new_stage_created(self, deploy_state_with_stages, tmp_path): + """Build state has an extra stage → created.""" + build_file = tmp_path / "build.yaml" + build_file.write_text( + yaml.dump( + { + "deployment_stages": [ + {"id": "foundation", "name": "Foundation", "capability": "infra"}, + {"id": "application", "name": "Application", "capability": "app"}, + {"id": "database", "name": "Database", "capability": "db"}, + ] + } + ), + encoding="utf-8", + ) + result = deploy_state_with_stages.sync_from_build_state(build_file) + assert result.matched == 2 + assert result.created == 1 + assert any("Database" in d for d in result.details) + + def test_orphaned_stage(self, deploy_state_with_stages, tmp_path): + """Build state removed a stage → orphaned.""" + build_file = tmp_path / "build.yaml" + build_file.write_text( + yaml.dump( + { + "deployment_stages": [ + {"id": "foundation", "name": "Foundation", "capability": "infra"}, + ] + } + ), + encoding="utf-8", + ) + result = deploy_state_with_stages.sync_from_build_state(build_file) + assert result.orphaned == 1 + # The orphaned stage should be marked as "removed" + orphaned = [ + s for s in deploy_state_with_stages._state["deployment_stages"] if s.get("deploy_status") == "removed" + ] + assert len(orphaned) == 1 + + def test_legacy_fallback_matching(self, deploy_state, tmp_path): + """Stage without build_stage_id matches by name+capability.""" + deploy_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "deploy_status": "deployed", + "deploy_mode": "auto", + } + ] + build_file = tmp_path / "build.yaml" + build_file.write_text( + yaml.dump( + { + "deployment_stages": [ + {"id": "foundation", "name": "Foundation", "capability": "infra"}, + ] + } + ), + encoding="utf-8", + ) + result = deploy_state.sync_from_build_state(build_file) + assert result.matched == 1 + # build_stage_id should now be set + stage = deploy_state._state["deployment_stages"][0] + assert stage.get("build_stage_id") == "foundation" + + def test_code_change_detection(self, deploy_state_with_stages, tmp_path): + """When a matched stage's code changed, mark _code_updated.""" + deploy_state_with_stages._state["deployment_stages"][0]["deploy_status"] = "deployed" + build_file = tmp_path / "build.yaml" + build_file.write_text( + yaml.dump( + { + "deployment_stages": [ + { + "id": "foundation", + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/stage-1-v2", + "files": ["main.tf", "new.tf"], + }, + {"id": "application", "name": "Application", "capability": "app"}, + ] + } + ), + encoding="utf-8", + ) + result = deploy_state_with_stages.sync_from_build_state(build_file) + assert result.updated_code == 1 + + def test_missing_build_file(self, deploy_state, tmp_path): + result = deploy_state.sync_from_build_state(tmp_path / "missing.yaml") + assert "not found" in result.details[0].lower() + + def test_bad_yaml(self, deploy_state, tmp_path): + build_file = tmp_path / "build.yaml" + build_file.write_text(": bad yaml {{", encoding="utf-8") + result = deploy_state.sync_from_build_state(build_file) + assert len(result.details) == 1 + + def test_empty_deployment_stages(self, deploy_state, tmp_path): + build_file = tmp_path / "build.yaml" + build_file.write_text(yaml.dump({"deployment_stages": []}), encoding="utf-8") + result = deploy_state.sync_from_build_state(build_file) + assert "no deployment_stages" in result.details[0].lower() + + +# ====================================================================== +# Post-load backfill +# ====================================================================== + + +class TestPostLoadBackfill: + """Test _backfill_build_stage_ids on legacy state.""" + + def test_backfills_missing_ids(self, deploy_state): + deploy_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Data Layer"}, + ] + deploy_state._backfill_build_stage_ids() + stage = deploy_state._state["deployment_stages"][0] + assert stage["build_stage_id"] == "data-layer" + assert "deploy_status" in stage # _enrich_deploy_fields was called + + def test_preserves_existing_ids(self, deploy_state): + deploy_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Foundation", "build_stage_id": "custom-id"}, + ] + deploy_state._backfill_build_stage_ids() + assert deploy_state._state["deployment_stages"][0]["build_stage_id"] == "custom-id" + + +# ====================================================================== +# Stage splitting +# ====================================================================== + + +class TestStageSplitting: + """Test split_stage for 1:N divergence.""" + + def test_split_creates_substages(self, deploy_state_with_stages): + deploy_state_with_stages.split_stage( + 1, + [ + {"name": "Foundation-VNet", "dir": "concept/infra/vnet"}, + {"name": "Foundation-KV", "dir": "concept/infra/kv"}, + ], + ) + stages = deploy_state_with_stages._state["deployment_stages"] + substages = [s for s in stages if s.get("substage_label")] + assert len(substages) == 2 + assert substages[0]["substage_label"] == "a" + assert substages[1]["substage_label"] == "b" + assert all(s["_is_substage"] for s in substages) + assert all(s["build_stage_id"] == "foundation" for s in substages) + + def test_split_nonexistent_stage(self, deploy_state_with_stages): + """Splitting a stage that doesn't exist is a no-op.""" + deploy_state_with_stages.split_stage(99, [{"name": "X", "dir": "x"}]) + # No change + substages = [s for s in deploy_state_with_stages._state["deployment_stages"] if s.get("substage_label")] + assert len(substages) == 0 + + +# ====================================================================== +# Stage status transitions +# ====================================================================== + + +class TestStageStatusTransitions: + """Test all status transition methods.""" + + def test_mark_deploying(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_deploying(1) + assert deploy_state_with_stages.get_stage(1)["deploy_status"] == "deploying" + + def test_mark_deployed(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_deployed(1, output="tf output") + stage = deploy_state_with_stages.get_stage(1) + assert stage["deploy_status"] == "deployed" + assert stage["deploy_output"] == "tf output" + assert stage["deploy_error"] == "" + assert stage["deploy_timestamp"] is not None + + def test_mark_failed(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_failed(1, error="init failed") + stage = deploy_state_with_stages.get_stage(1) + assert stage["deploy_status"] == "failed" + assert stage["deploy_error"] == "init failed" + + def test_mark_rolled_back(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_rolled_back(1) + stage = deploy_state_with_stages.get_stage(1) + assert stage["deploy_status"] == "rolled_back" + assert stage["rollback_timestamp"] is not None + + def test_mark_remediating_bumps_counter(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_remediating(1) + assert deploy_state_with_stages.get_stage(1)["remediation_attempts"] == 1 + deploy_state_with_stages.mark_stage_remediating(1) + assert deploy_state_with_stages.get_stage(1)["remediation_attempts"] == 2 + + def test_reset_stage_to_pending(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_failed(1, error="err") + deploy_state_with_stages.reset_stage_to_pending(1) + stage = deploy_state_with_stages.get_stage(1) + assert stage["deploy_status"] == "pending" + assert stage["deploy_error"] == "" + + def test_mark_stage_removed(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_removed(1) + assert deploy_state_with_stages.get_stage(1)["deploy_status"] == "removed" + + def test_mark_stage_destroyed(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_destroyed(1) + assert deploy_state_with_stages.get_stage(1)["deploy_status"] == "destroyed" + + def test_mark_stage_awaiting_manual(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_awaiting_manual(1) + assert deploy_state_with_stages.get_stage(1)["deploy_status"] == "awaiting_manual" + + def test_mark_nonexistent_stage_no_error(self, deploy_state_with_stages): + """Marking a nonexistent stage is a no-op.""" + deploy_state_with_stages.mark_stage_deploying(99) + deploy_state_with_stages.mark_stage_deployed(99) + deploy_state_with_stages.mark_stage_failed(99) + deploy_state_with_stages.mark_stage_rolled_back(99) + + +# ====================================================================== +# Rollback ordering +# ====================================================================== + + +class TestRollbackOrdering: + """Test can_rollback enforces ordered rollback.""" + + def test_can_rollback_when_no_later_deployed(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_deployed(1) + assert deploy_state_with_stages.can_rollback(1) is True + + def test_cannot_rollback_when_later_deployed(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_deployed(1) + deploy_state_with_stages.mark_stage_deployed(2) + assert deploy_state_with_stages.can_rollback(1) is False + + def test_can_rollback_highest_stage(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_deployed(1) + deploy_state_with_stages.mark_stage_deployed(2) + assert deploy_state_with_stages.can_rollback(2) is True + + def test_get_rollback_candidates_sorted(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_deployed(1) + deploy_state_with_stages.mark_stage_deployed(2) + candidates = deploy_state_with_stages.get_rollback_candidates() + assert candidates[0]["stage"] == 2 + assert candidates[1]["stage"] == 1 + + +# ====================================================================== +# Stage queries +# ====================================================================== + + +class TestStageQueries: + """Test various stage query methods.""" + + def test_get_stage(self, deploy_state_with_stages): + assert deploy_state_with_stages.get_stage(1)["name"] == "Foundation" + assert deploy_state_with_stages.get_stage(99) is None + + def test_get_all_stages_for_num(self, deploy_state_with_stages): + stages = deploy_state_with_stages.get_all_stages_for_num(1) + assert len(stages) == 1 + + def test_get_pending_stages(self, deploy_state_with_stages): + pending = deploy_state_with_stages.get_pending_stages() + assert len(pending) == 2 + + def test_get_deployed_stages(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_deployed(1) + deployed = deploy_state_with_stages.get_deployed_stages() + assert len(deployed) == 1 + + def test_get_failed_stages(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_failed(1) + failed = deploy_state_with_stages.get_failed_stages() + assert len(failed) == 1 + + def test_get_stage_by_display_id(self, deploy_state_with_stages): + stage = deploy_state_with_stages.get_stage_by_display_id("1") + assert stage is not None + assert stage["name"] == "Foundation" + + def test_get_stage_by_display_id_invalid(self, deploy_state_with_stages): + assert deploy_state_with_stages.get_stage_by_display_id("abc") is None + + def test_get_stage_by_display_id_nonexistent(self, deploy_state_with_stages): + assert deploy_state_with_stages.get_stage_by_display_id("99") is None + + def test_get_stage_groups(self, deploy_state_with_stages): + groups = deploy_state_with_stages.get_stage_groups() + assert "foundation" in groups + assert "application" in groups + + def test_get_stages_for_build_stage(self, deploy_state_with_stages): + stages = deploy_state_with_stages.get_stages_for_build_stage("foundation") + assert len(stages) == 1 + + +# ====================================================================== +# Preflight +# ====================================================================== + + +class TestPreflight: + """Test preflight result tracking.""" + + def test_set_and_get_preflight_results(self, deploy_state): + results = [ + {"name": "az-login", "status": "pass", "message": "Logged in"}, + {"name": "rg-exists", "status": "fail", "message": "RG not found", "fix_command": "az group create"}, + ] + deploy_state.set_preflight_results(results) + failures = deploy_state.get_preflight_failures() + assert len(failures) == 1 + assert failures[0]["name"] == "rg-exists" + + def test_empty_preflight(self, deploy_state): + assert deploy_state.get_preflight_failures() == [] + + +# ====================================================================== +# Audit logging +# ====================================================================== + + +class TestAuditLogging: + """Test deploy and rollback log entries.""" + + def test_deploy_log_entry(self, deploy_state): + deploy_state.add_deploy_log_entry(1, "deploying") + logs = deploy_state._state["deploy_log"] + assert len(logs) == 1 + assert logs[0]["stage"] == 1 + assert logs[0]["action"] == "deploying" + + def test_rollback_log_entry(self, deploy_state): + deploy_state.add_rollback_log_entry(1, "user requested") + logs = deploy_state._state["rollback_log"] + assert len(logs) == 1 + assert logs[0]["stage"] == 1 + + +# ====================================================================== +# Conversation tracking +# ====================================================================== + + +class TestConversationTracking: + """Test exchange recording.""" + + def test_update_from_exchange(self, deploy_state): + deploy_state.update_from_exchange("deploy stage 1", "Deploying...", 1) + history = deploy_state._state["conversation_history"] + assert len(history) == 1 + assert history[0]["user"] == "deploy stage 1" + assert history[0]["exchange"] == 1 + + +# ====================================================================== +# add_patch_stages / renumber_stages +# ====================================================================== + + +class TestPatchAndRenumber: + """Test adding patch stages and renumbering.""" + + def test_add_patch_stages_before_docs(self, deploy_state): + deploy_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Foundation", "capability": "infra"}, + {"stage": 2, "name": "Documentation", "capability": "docs"}, + ] + deploy_state.add_patch_stages([{"name": "Hotfix", "capability": "infra", "build_stage_id": "hotfix"}]) + stages = deploy_state._state["deployment_stages"] + names = [s["name"] for s in stages] + assert names.index("Hotfix") < names.index("Documentation") + + def test_renumber_stages(self, deploy_state): + deploy_state._state["deployment_stages"] = [ + {"stage": 5, "name": "A"}, + {"stage": 10, "name": "B"}, + ] + deploy_state.renumber_stages() + assert deploy_state._state["deployment_stages"][0]["stage"] == 1 + assert deploy_state._state["deployment_stages"][1]["stage"] == 2 + + +# ====================================================================== +# Formatting +# ====================================================================== + + +class TestFormatting: + """Test display formatting methods.""" + + def test_format_stage_status_empty(self, deploy_state): + result = deploy_state.format_stage_status() + assert "No deployment stages" in result + + def test_format_stage_status_with_stages(self, deploy_state_with_stages): + result = deploy_state_with_stages.format_stage_status() + assert "Foundation" in result + assert "Application" in result + assert "0/2" in result + + def test_format_deploy_report(self, deploy_state_with_stages): + deploy_state_with_stages.mark_stage_deployed(1) + report = deploy_state_with_stages.format_deploy_report() + assert "Deploy Report" in report + assert "Foundation" in report + + def test_format_preflight_report_empty(self, deploy_state): + result = deploy_state.format_preflight_report() + assert "No preflight checks" in result + + def test_format_preflight_report_with_results(self, deploy_state): + deploy_state.set_preflight_results( + [ + {"name": "login", "status": "pass", "message": "OK"}, + {"name": "rg", "status": "fail", "message": "Missing", "fix_command": "az group create"}, + ] + ) + result = deploy_state.format_preflight_report() + assert "login" in result + assert "Fix:" in result + + def test_format_outputs_empty(self, deploy_state): + result = deploy_state.format_outputs() + assert "No deployment outputs" in result + + def test_format_outputs_with_data(self, deploy_state): + deploy_state._state["captured_outputs"] = { + "terraform": {"endpoint": "https://app.com"}, + } + result = deploy_state.format_outputs() + assert "endpoint" in result + assert "https://app.com" in result + + +# ====================================================================== +# Module-level helpers +# ====================================================================== + + +class TestModuleHelpers: + """Test parse_stage_ref, _format_display_id, _status_icon.""" + + def test_parse_stage_ref_number_only(self): + num, label = parse_stage_ref("5") + assert num == 5 + assert label is None + + def test_parse_stage_ref_with_label(self): + num, label = parse_stage_ref("5a") + assert num == 5 + assert label == "a" + + def test_parse_stage_ref_invalid(self): + num, label = parse_stage_ref("abc") + assert num is None + assert label is None + + def test_parse_stage_ref_whitespace(self): + num, label = parse_stage_ref(" 3b ") + assert num == 3 + assert label == "b" + + def test_format_display_id_plain(self): + assert _format_display_id({"stage": 3}) == "3" + + def test_format_display_id_with_label(self): + assert _format_display_id({"stage": 3, "substage_label": "b"}) == "3b" + + def test_status_icon_mapping(self): + assert _status_icon("pending") == " " + assert _status_icon("deploying") == ">>" + assert _status_icon("deployed") == " v" + assert _status_icon("failed") == " x" + assert _status_icon("rolled_back") == " ~" + assert _status_icon("unknown") == " " + + +# ====================================================================== +# _enrich_deploy_fields +# ====================================================================== + + +class TestEnrichDeployFields: + """Test _enrich_deploy_fields sets defaults.""" + + def test_adds_all_fields(self): + stage = {"name": "test"} + enriched = _enrich_deploy_fields(stage) + assert enriched["deploy_status"] == "pending" + assert enriched["deploy_timestamp"] is None + assert enriched["remediation_attempts"] == 0 + assert enriched["_is_substage"] is False + + def test_preserves_existing_values(self): + stage = {"name": "test", "deploy_status": "deployed"} + enriched = _enrich_deploy_fields(stage) + assert enriched["deploy_status"] == "deployed" diff --git a/tests/stages/test_discovery_state.py b/tests/stages/test_discovery_state.py new file mode 100644 index 0000000..36a81d0 --- /dev/null +++ b/tests/stages/test_discovery_state.py @@ -0,0 +1,588 @@ +"""Tests for DiscoveryState — legacy migration, exchange handling, state persistence. + +Covers: +- Legacy state migration (topics, open_items, confirmed_items → unified items) +- update_from_exchange with str vs list content +- Image stripping during persistence (multi-modal content arrays) +- Item management (add, resolve, mark, append, dedup) +- Format methods (open_items, confirmed_items, status_summary, as_context) +- Conversation summary extraction +- Search history +- Topic at exchange +- Artifact inventory +- Context hash +""" + +from pathlib import Path + +import pytest +import yaml + +from azext_prototype.stages.discovery_state import ( + DiscoveryState, + TrackedItem, + _default_discovery_state, +) + +# ====================================================================== +# Fixtures +# ====================================================================== + + +@pytest.fixture +def disco_state(tmp_project): + ds = DiscoveryState(str(tmp_project)) + return ds + + +@pytest.fixture +def disco_state_with_items(disco_state): + """State with some items pre-loaded.""" + disco_state._state["items"] = [ + { + "heading": "Auth approach", + "detail": "How to authenticate?", + "kind": "topic", + "status": "pending", + "answer_exchange": None, + }, + { + "heading": "DB choice", + "detail": "Which database?", + "kind": "decision", + "status": "confirmed", + "answer_exchange": 2, + }, + {"heading": "Hosting", "detail": "Where to host?", "kind": "topic", "status": "answered", "answer_exchange": 3}, + ] + disco_state._loaded = True + return disco_state + + +# ====================================================================== +# Legacy migration +# ====================================================================== + + +class TestLegacyMigration: + """Test _migrate_legacy_state converts old fields to unified items.""" + + def test_migrate_topics(self, disco_state): + """Old 'topics' key migrates to items with kind=topic.""" + disco_state._state["topics"] = [ + {"heading": "Auth", "questions": "How to authenticate?", "status": "pending"}, + ] + disco_state._state["items"] = [] + disco_state._migrate_legacy_state() + assert len(disco_state._state["items"]) == 1 + assert disco_state._state["items"][0]["kind"] == "topic" + assert disco_state._state["items"][0]["detail"] == "How to authenticate?" + assert "topics" not in disco_state._state + + def test_migrate_open_items(self, disco_state): + """Old 'open_items' list migrates to decision items with pending status.""" + disco_state._state["open_items"] = ["Which region?", "Which SKU?"] + disco_state._state["items"] = [] + disco_state._migrate_legacy_state() + assert len(disco_state._state["items"]) == 2 + assert all(i["kind"] == "decision" for i in disco_state._state["items"]) + assert all(i["status"] == "pending" for i in disco_state._state["items"]) + assert "open_items" not in disco_state._state + + def test_migrate_confirmed_items(self, disco_state): + """Old 'confirmed_items' list migrates to confirmed decision items.""" + disco_state._state["confirmed_items"] = ["Use PaaS"] + disco_state._state["items"] = [] + disco_state._migrate_legacy_state() + assert len(disco_state._state["items"]) == 1 + assert disco_state._state["items"][0]["status"] == "confirmed" + assert "confirmed_items" not in disco_state._state + + def test_migrate_deduplicates(self, disco_state): + """Migration deduplicates by heading (case-insensitive).""" + disco_state._state["topics"] = [ + {"heading": "Auth", "questions": "q", "status": "pending"}, + ] + disco_state._state["open_items"] = ["Auth"] # Same heading + disco_state._state["items"] = [] + disco_state._migrate_legacy_state() + assert len(disco_state._state["items"]) == 1 + + def test_migrate_empty_legacy_keys_cleaned(self, disco_state): + """Empty legacy keys are removed even if they have no items.""" + disco_state._state["topics"] = [] + disco_state._state["open_items"] = [] + disco_state._state["confirmed_items"] = [] + disco_state._migrate_legacy_state() + assert "topics" not in disco_state._state + assert "open_items" not in disco_state._state + assert "confirmed_items" not in disco_state._state + + def test_no_legacy_keys_no_op(self, disco_state): + """When no legacy keys exist, migration is a no-op.""" + original_items = list(disco_state._state["items"]) + disco_state._migrate_legacy_state() + assert disco_state._state["items"] == original_items + + def test_post_load_calls_migrate(self, disco_state, tmp_path): + """Loading state from disk triggers migration.""" + state_dir = Path(str(tmp_path)) / "test-project" / ".prototype" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + legacy_state = _default_discovery_state() + legacy_state["topics"] = [ + {"heading": "Legacy Topic", "questions": "q", "status": "pending"}, + ] + state_file = state_dir / "discovery.yaml" + with open(state_file, "w", encoding="utf-8") as f: + yaml.dump(legacy_state, f) + + ds = DiscoveryState(str(tmp_path / "test-project")) + ds.load() + assert len(ds._state["items"]) == 1 + assert ds._state["items"][0]["heading"] == "Legacy Topic" + assert "topics" not in ds._state + + +# ====================================================================== +# update_from_exchange +# ====================================================================== + + +class TestUpdateFromExchange: + """Test exchange recording with str and list content.""" + + def test_string_input(self, disco_state): + disco_state.update_from_exchange("Hello", "Hi there!", 1) + history = disco_state._state["conversation_history"] + assert len(history) == 1 + assert history[0]["user"] == "Hello" + assert history[0]["assistant"] == "Hi there!" + assert history[0]["exchange"] == 1 + + def test_list_input_text_only(self, disco_state): + """Multi-modal content with only text parts.""" + content = [ + {"type": "text", "text": "Part 1"}, + {"type": "text", "text": "Part 2"}, + ] + disco_state.update_from_exchange(content, "Response", 1) + history = disco_state._state["conversation_history"] + assert "Part 1" in history[0]["user"] + assert "Part 2" in history[0]["user"] + + def test_list_input_with_images_stripped(self, disco_state): + """Multi-modal content with images — base64 data replaced with placeholder.""" + content = [ + {"type": "text", "text": "See this diagram"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,ABC123..."}}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,DEF456..."}}, + ] + disco_state.update_from_exchange(content, "I see the diagram", 1) + history = disco_state._state["conversation_history"] + assert "[2 image(s) attached]" in history[0]["user"] + assert "base64" not in history[0]["user"] + + def test_exchange_count_updated(self, disco_state): + disco_state.update_from_exchange("Q1", "A1", 1) + disco_state.update_from_exchange("Q2", "A2", 2) + assert disco_state._state["_metadata"]["exchange_count"] == 2 + + def test_list_input_with_no_text(self, disco_state): + """Multi-modal content with only images.""" + content = [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,ABC"}}, + ] + disco_state.update_from_exchange(content, "I see", 1) + history = disco_state._state["conversation_history"] + assert "[1 image(s) attached]" in history[0]["user"] + + +# ====================================================================== +# Item management +# ====================================================================== + + +class TestItemManagement: + """Test add, resolve, mark, append, dedup operations.""" + + def test_add_open_item(self, disco_state): + disco_state.add_open_item("Which region?") + items = disco_state._state["items"] + assert len(items) == 1 + assert items[0]["heading"] == "Which region?" + assert items[0]["status"] == "pending" + assert items[0]["kind"] == "decision" + + def test_add_open_item_dedup(self, disco_state): + disco_state.add_open_item("Which region?") + disco_state.add_open_item("Which region?") + assert len(disco_state._state["items"]) == 1 + + def test_resolve_item_by_heading(self, disco_state): + disco_state.add_open_item("Which region?") + disco_state.resolve_item("Which region?") + assert disco_state._state["items"][0]["status"] == "confirmed" + + def test_resolve_item_by_confirmed_text(self, disco_state): + disco_state.add_open_item("Which region?") + disco_state.resolve_item("different", confirmed_text="Which region?") + assert disco_state._state["items"][0]["status"] == "confirmed" + + def test_resolve_creates_if_not_found(self, disco_state): + disco_state.resolve_item("nonexistent", confirmed_text="New decision") + assert len(disco_state._state["items"]) == 1 + assert disco_state._state["items"][0]["status"] == "confirmed" + + def test_resolve_no_match_no_text_no_op(self, disco_state): + disco_state.resolve_item("nonexistent") + assert len(disco_state._state["items"]) == 0 + + def test_add_confirmed_decision(self, disco_state): + disco_state.add_confirmed_decision("Use PaaS services") + assert "Use PaaS services" in disco_state._state["decisions"] + + def test_add_confirmed_decision_dedup(self, disco_state): + disco_state.add_confirmed_decision("Use PaaS") + disco_state.add_confirmed_decision("Use PaaS") + assert disco_state._state["decisions"].count("Use PaaS") == 1 + + def test_set_items(self, disco_state): + items = [ + TrackedItem(heading="T1", detail="D1", kind="topic", status="pending", answer_exchange=None), + ] + disco_state.set_items(items) + assert len(disco_state._state["items"]) == 1 + + def test_append_items_dedup(self, disco_state): + disco_state.set_items( + [TrackedItem(heading="T1", detail="D1", kind="topic", status="pending", answer_exchange=None)] + ) + disco_state.append_items( + [ + TrackedItem(heading="T1", detail="D1", kind="topic", status="pending", answer_exchange=None), + TrackedItem(heading="T2", detail="D2", kind="topic", status="pending", answer_exchange=None), + ] + ) + assert len(disco_state._state["items"]) == 2 + + def test_mark_item(self, disco_state_with_items): + disco_state_with_items.mark_item("Auth approach", "answered", exchange=5) + item = disco_state_with_items._state["items"][0] + assert item["status"] == "answered" + assert item["answer_exchange"] == 5 + + def test_first_pending_index(self, disco_state_with_items): + idx = disco_state_with_items.first_pending_index() + assert idx == 0 # Auth approach is pending + + def test_first_pending_index_by_kind(self, disco_state_with_items): + # Add a pending decision + disco_state_with_items._state["items"].append( + {"heading": "D1", "detail": "d", "kind": "decision", "status": "pending", "answer_exchange": None} + ) + idx = disco_state_with_items.first_pending_index(kind="decision") + assert idx == 3 + + def test_first_pending_index_none(self, disco_state): + assert disco_state.first_pending_index() is None + + +# ====================================================================== +# Item properties +# ====================================================================== + + +class TestItemProperties: + """Test item accessor properties.""" + + def test_open_count(self, disco_state_with_items): + assert disco_state_with_items.open_count == 1 + + def test_confirmed_count(self, disco_state_with_items): + assert disco_state_with_items.confirmed_count == 2 # confirmed + answered + + def test_has_items(self, disco_state_with_items): + assert disco_state_with_items.has_items is True + + def test_has_items_empty(self, disco_state): + assert disco_state.has_items is False + + def test_items_property(self, disco_state_with_items): + items = disco_state_with_items.items + assert len(items) == 3 + assert all(isinstance(i, TrackedItem) for i in items) + + def test_topic_items(self, disco_state_with_items): + topics = disco_state_with_items.topic_items + assert len(topics) == 2 # Auth approach + Hosting + + def test_items_by_status(self, disco_state_with_items): + pending = disco_state_with_items.items_by_status("pending") + assert len(pending) == 1 + assert pending[0].heading == "Auth approach" + + +# ====================================================================== +# Backward-compat aliases +# ====================================================================== + + +class TestBackwardCompatAliases: + """Test old method names still work.""" + + def test_topics_alias(self, disco_state_with_items): + assert disco_state_with_items.topics == disco_state_with_items.items + + def test_has_topics_alias(self, disco_state_with_items): + assert disco_state_with_items.has_topics == disco_state_with_items.has_items + + def test_set_topics_alias(self, disco_state): + items = [TrackedItem(heading="X", detail="x", kind="topic", status="pending", answer_exchange=None)] + disco_state.set_topics(items) + assert len(disco_state._state["items"]) == 1 + + def test_mark_topic_alias(self, disco_state_with_items): + disco_state_with_items.mark_topic("Auth approach", "confirmed") + assert disco_state_with_items._state["items"][0]["status"] == "confirmed" + + def test_first_pending_topic_index_alias(self, disco_state_with_items): + assert disco_state_with_items.first_pending_topic_index() == disco_state_with_items.first_pending_index() + + +# ====================================================================== +# Format methods +# ====================================================================== + + +class TestFormatMethods: + """Test display formatting methods.""" + + def test_format_open_items_with_pending(self, disco_state_with_items): + result = disco_state_with_items.format_open_items() + assert "Auth approach" in result + assert "Open items" in result + + def test_format_open_items_none_pending(self, disco_state): + result = disco_state.format_open_items() + assert "No open items" in result + + def test_format_confirmed_items(self, disco_state_with_items): + result = disco_state_with_items.format_confirmed_items() + assert "DB choice" in result + assert "Hosting" in result + + def test_format_confirmed_items_none(self, disco_state): + result = disco_state.format_confirmed_items() + assert "No items confirmed" in result + + def test_format_status_summary(self, disco_state_with_items): + result = disco_state_with_items.format_status_summary() + assert "2 confirmed" in result + assert "1 open" in result + + def test_format_status_summary_empty(self, disco_state): + result = disco_state.format_status_summary() + assert "No items tracked" in result + + def test_format_as_context_structured(self, disco_state): + disco_state._loaded = True + disco_state._state["project"]["summary"] = "Test project" + disco_state._state["project"]["goals"] = ["Goal 1"] + disco_state._state["requirements"]["functional"] = ["API support"] + disco_state._state["constraints"] = ["PaaS only"] + disco_state._state["decisions"] = ["Use Cosmos DB"] + disco_state._state["architecture"]["services"] = ["cosmos-db"] + result = disco_state.format_as_context() + assert "Test project" in result + assert "Goal 1" in result + assert "API support" in result + assert "PaaS only" in result + assert "Use Cosmos DB" in result + assert "cosmos-db" in result + + def test_format_as_context_falls_back_to_conversation(self, disco_state): + """When structured fields are empty, falls back to conversation summary.""" + disco_state._loaded = True + disco_state._state["conversation_history"] = [ + { + "user": "Tell me about the project", + "assistant": "## Project Summary\nThis is a test project.\n## Confirmed Functional Requirements\n- API", + } + ] + result = disco_state.format_as_context() + assert "Project Summary" in result + + def test_format_as_context_not_loaded(self, disco_state): + result = disco_state.format_as_context() + assert result == "" + + +# ====================================================================== +# Merge learnings +# ====================================================================== + + +class TestMergeLearnings: + """Test merge_learnings integrates structured data.""" + + def test_merge_project_summary(self, disco_state): + disco_state.merge_learnings({"project": {"summary": "New summary"}}) + assert disco_state._state["project"]["summary"] == "New summary" + + def test_merge_goals(self, disco_state): + disco_state.merge_learnings({"project": {"goals": ["G1", "G2"]}}) + assert disco_state._state["project"]["goals"] == ["G1", "G2"] + + def test_merge_requirements(self, disco_state): + disco_state.merge_learnings({"requirements": {"functional": ["R1"], "non_functional": ["NF1"]}}) + assert disco_state._state["requirements"]["functional"] == ["R1"] + assert disco_state._state["requirements"]["non_functional"] == ["NF1"] + + def test_merge_deduplicates(self, disco_state): + disco_state.merge_learnings({"constraints": ["C1"]}) + disco_state.merge_learnings({"constraints": ["C1", "C2"]}) + assert disco_state._state["constraints"] == ["C1", "C2"] + + def test_merge_open_items_creates_decisions(self, disco_state): + disco_state.merge_learnings({"open_items": ["Choose DB"]}) + assert len(disco_state._state["items"]) == 1 + assert disco_state._state["items"][0]["kind"] == "decision" + + def test_merge_resolved_items(self, disco_state): + disco_state.add_open_item("Choose DB") + disco_state.merge_learnings({"resolved_items": ["Choose DB"]}) + assert disco_state._state["items"][0]["status"] == "confirmed" + + def test_merge_scope(self, disco_state): + disco_state.merge_learnings({"scope": {"in_scope": ["API"], "deferred": ["ML"]}}) + assert "API" in disco_state._state["scope"]["in_scope"] + assert "ML" in disco_state._state["scope"]["deferred"] + + def test_merge_architecture(self, disco_state): + disco_state.merge_learnings({"architecture": {"services": ["cosmos-db"], "data_flow": "API -> DB"}}) + assert "cosmos-db" in disco_state._state["architecture"]["services"] + assert disco_state._state["architecture"]["data_flow"] == "API -> DB" + + +# ====================================================================== +# Search history +# ====================================================================== + + +class TestSearchHistory: + """Test conversation history search.""" + + def test_search_finds_match(self, disco_state): + disco_state._state["conversation_history"] = [ + {"user": "Tell me about Cosmos DB", "assistant": "It is a NoSQL database"}, + {"user": "What about SQL?", "assistant": "Relational database"}, + ] + results = disco_state.search_history("cosmos") + assert len(results) == 1 + + def test_search_no_match(self, disco_state): + disco_state._state["conversation_history"] = [ + {"user": "Hello", "assistant": "Hi"}, + ] + results = disco_state.search_history("cosmos") + assert len(results) == 0 + + +# ====================================================================== +# topic_at_exchange +# ====================================================================== + + +class TestTopicAtExchange: + """Test finding which topic was discussed at a given exchange.""" + + def test_finds_topic_at_exchange(self, disco_state_with_items): + # DB choice answered at exchange 2, Hosting at 3 + result = disco_state_with_items.topic_at_exchange(2) + assert result == "DB choice" + + def test_returns_none_no_answered_items(self, disco_state): + assert disco_state.topic_at_exchange(1) is None + + def test_returns_none_past_all_exchanges(self, disco_state_with_items): + # Exchange 10 is past all answered items + result = disco_state_with_items.topic_at_exchange(10) + assert result is None + + +# ====================================================================== +# Artifact inventory +# ====================================================================== + + +class TestArtifactInventory: + """Test artifact hash tracking.""" + + def test_update_and_get(self, disco_state): + disco_state.update_artifact_inventory({"/path/to/file.txt": "abc123"}) + hashes = disco_state.get_artifact_hashes() + assert hashes["/path/to/file.txt"] == "abc123" + + def test_additive_updates(self, disco_state): + disco_state.update_artifact_inventory({"/a": "hash1"}) + disco_state.update_artifact_inventory({"/b": "hash2"}) + hashes = disco_state.get_artifact_hashes() + assert "/a" in hashes + assert "/b" in hashes + + +# ====================================================================== +# Context hash +# ====================================================================== + + +class TestContextHash: + """Test context hash for change detection.""" + + def test_update_and_get(self, disco_state): + disco_state.update_context_hash("abc123") + assert disco_state.get_context_hash() == "abc123" + + def test_default_empty(self, disco_state): + assert disco_state.get_context_hash() == "" + + +# ====================================================================== +# Reset +# ====================================================================== + + +class TestReset: + """Test state reset.""" + + def test_reset_clears_state(self, disco_state_with_items): + disco_state_with_items.reset() + assert disco_state_with_items._state["items"] == [] + assert disco_state_with_items._loaded is False + + +# ====================================================================== +# TrackedItem dataclass +# ====================================================================== + + +class TestTrackedItem: + """Test TrackedItem serialization.""" + + def test_to_dict(self): + item = TrackedItem(heading="H", detail="D", kind="topic", status="pending", answer_exchange=None) + d = item.to_dict() + assert d["heading"] == "H" + assert d["kind"] == "topic" + + def test_from_dict(self): + d = {"heading": "H", "detail": "D", "kind": "decision", "status": "confirmed", "answer_exchange": 3} + item = TrackedItem.from_dict(d) + assert item.heading == "H" + assert item.answer_exchange == 3 + + def test_from_dict_legacy_questions_key(self): + """Old format used 'questions' instead of 'detail'.""" + d = {"heading": "H", "questions": "Q?", "status": "pending"} + item = TrackedItem.from_dict(d) + assert item.detail == "Q?" diff --git a/tests/stages/test_knowledge_contributor.py b/tests/stages/test_knowledge_contributor.py new file mode 100644 index 0000000..20b25d9 --- /dev/null +++ b/tests/stages/test_knowledge_contributor.py @@ -0,0 +1,443 @@ +"""Tests for knowledge_contributor — gap detection and contribution submission. + +Covers: +- Namespace-to-filename conversion +- Knowledge file path resolution (namespace lookup, friendly name fallback) +- Gap detection with fallbacks (missing files, namespace resolution, empty finding) +- Contribution formatting (title, body, new-service type promotion) +- Submission with label retry (auth check, label retry fallback, FileNotFoundError) +- QA finding builder +- Fire-and-forget wrapper (submit_if_gap) +""" + +from unittest.mock import MagicMock, patch + +# ====================================================================== +# _namespace_to_filename +# ====================================================================== + + +class TestNamespaceToFilename: + """Test ARM namespace to knowledge filename conversion.""" + + def test_typical_namespace(self): + from azext_prototype.stages.knowledge_contributor import _namespace_to_filename + + assert _namespace_to_filename("Microsoft.Sql/servers/databases") == "sql-servers-databases" + + def test_container_apps(self): + from azext_prototype.stages.knowledge_contributor import _namespace_to_filename + + assert _namespace_to_filename("Microsoft.App/containerApps") == "app-containerapps" + + def test_empty_namespace(self): + from azext_prototype.stages.knowledge_contributor import _namespace_to_filename + + assert _namespace_to_filename("") == "unknown" + + def test_double_hyphens_cleaned(self): + from azext_prototype.stages.knowledge_contributor import _namespace_to_filename + + # Simulate edge case with consecutive separators + result = _namespace_to_filename("Microsoft..Foo//bar") + assert "--" not in result + + +# ====================================================================== +# _resolve_knowledge_file_path +# ====================================================================== + + +class TestResolveKnowledgeFilePath: + """Test file path resolution with namespace vs friendly name.""" + + def test_namespace_via_loader_index(self): + from azext_prototype.stages.knowledge_contributor import _resolve_knowledge_file_path + + mock_loader_cls = MagicMock() + mock_loader_cls.return_value._build_namespace_index.return_value = { + "Microsoft.Web/sites": "app-service.md", + } + with patch("azext_prototype.knowledge.KnowledgeLoader", mock_loader_cls): + result = _resolve_knowledge_file_path("Microsoft.Web/sites", "app-service") + assert result == "knowledge/services/app-service.md" + + def test_namespace_not_in_index_generates_from_namespace(self): + from azext_prototype.stages.knowledge_contributor import _resolve_knowledge_file_path + + mock_loader_cls = MagicMock() + mock_loader_cls.return_value._build_namespace_index.return_value = {} + with patch("azext_prototype.knowledge.KnowledgeLoader", mock_loader_cls): + result = _resolve_knowledge_file_path("Microsoft.NewService/items", "new-service") + assert result == "knowledge/services/newservice-items.md" + + def test_namespace_loader_import_fails(self): + """When KnowledgeLoader construction fails, still generates from namespace.""" + from azext_prototype.stages.knowledge_contributor import _resolve_knowledge_file_path + + with patch( + "azext_prototype.knowledge.KnowledgeLoader", + side_effect=RuntimeError("loader broken"), + ): + result = _resolve_knowledge_file_path("Microsoft.Storage/storageAccounts", "storage") + assert result == "knowledge/services/storage-storageaccounts.md" + + def test_friendly_name_fallback(self): + """When namespace is empty, falls back to friendly name.""" + from azext_prototype.stages.knowledge_contributor import _resolve_knowledge_file_path + + result = _resolve_knowledge_file_path("", "cosmos-db") + # Should contain the friendly name + assert "cosmos-db" in result + + def test_no_namespace_no_service(self): + from azext_prototype.stages.knowledge_contributor import _resolve_knowledge_file_path + + result = _resolve_knowledge_file_path("", "") + assert result == "knowledge/services/unknown.md" + + +# ====================================================================== +# check_knowledge_gap +# ====================================================================== + + +class TestCheckKnowledgeGap: + """Test gap detection logic.""" + + def test_empty_finding_returns_false(self): + from azext_prototype.stages.knowledge_contributor import check_knowledge_gap + + assert check_knowledge_gap({}, MagicMock()) is False + assert check_knowledge_gap(None, MagicMock()) is False + + def test_no_service_or_context_returns_false(self): + from azext_prototype.stages.knowledge_contributor import check_knowledge_gap + + assert check_knowledge_gap({"service": "cosmos-db"}, MagicMock()) is False + assert check_knowledge_gap({"context": "some error"}, MagicMock()) is False + + def test_no_existing_content_is_gap(self): + from azext_prototype.stages.knowledge_contributor import check_knowledge_gap + + loader = MagicMock() + loader.load_service.return_value = "" + finding = {"service": "cosmos-db", "context": "Missing retry logic for 429 errors"} + assert check_knowledge_gap(finding, loader) is True + + def test_loader_exception_treated_as_gap(self): + from azext_prototype.stages.knowledge_contributor import check_knowledge_gap + + loader = MagicMock() + loader.load_service.side_effect = FileNotFoundError("not found") + finding = {"service": "cosmos-db", "context": "Some new pitfall discovered"} + assert check_knowledge_gap(finding, loader) is True + + def test_context_already_covered_no_gap(self): + from azext_prototype.stages.knowledge_contributor import check_knowledge_gap + + loader = MagicMock() + loader.load_service.return_value = "Common issue: missing retry logic for 429 errors and throttling" + finding = {"service": "cosmos-db", "context": "Missing retry logic for 429 errors"} + assert check_knowledge_gap(finding, loader) is False + + def test_context_not_in_content_is_gap(self): + from azext_prototype.stages.knowledge_contributor import check_knowledge_gap + + loader = MagicMock() + loader.load_service.return_value = "This file covers connection strings only." + finding = {"service": "cosmos-db", "context": "Missing retry logic for 429 errors"} + assert check_knowledge_gap(finding, loader) is True + + def test_prefers_namespace_for_resolution(self): + from azext_prototype.stages.knowledge_contributor import check_knowledge_gap + + loader = MagicMock() + loader.load_service.return_value = "" + finding = { + "service_namespace": "Microsoft.DocumentDB/databaseAccounts", + "service": "cosmos-db", + "context": "Issue found", + } + check_knowledge_gap(finding, loader) + loader.load_service.assert_called_with("Microsoft.DocumentDB/databaseAccounts") + + def test_empty_context_snippet_returns_false(self): + from azext_prototype.stages.knowledge_contributor import check_knowledge_gap + + loader = MagicMock() + loader.load_service.return_value = "some content" + finding = {"service": "x", "context": " "} # whitespace only + assert check_knowledge_gap(finding, loader) is False + + +# ====================================================================== +# format_contribution_title +# ====================================================================== + + +class TestFormatContributionTitle: + """Test issue title formatting.""" + + def test_basic_title(self): + from azext_prototype.stages.knowledge_contributor import format_contribution_title + + finding = {"service": "cosmos-db", "context": "Short context"} + title = format_contribution_title(finding) + assert title == "[Knowledge] cosmos-db: Short context" + + def test_namespace_preferred(self): + from azext_prototype.stages.knowledge_contributor import format_contribution_title + + finding = { + "service_namespace": "Microsoft.DocumentDB/databaseAccounts", + "service": "cosmos-db", + "context": "Some issue", + } + title = format_contribution_title(finding) + assert "Microsoft.DocumentDB/databaseAccounts" in title + + def test_truncation_at_60_chars(self): + from azext_prototype.stages.knowledge_contributor import format_contribution_title + + finding = {"service": "x", "context": "A" * 100} + title = format_contribution_title(finding) + assert title.endswith("...") + # The context part should be 60 chars + ... + assert "A" * 60 in title + + def test_empty_context_fallback(self): + from azext_prototype.stages.knowledge_contributor import format_contribution_title + + finding = {"service": "x"} + title = format_contribution_title(finding) + assert "Knowledge contribution" in title + + +# ====================================================================== +# format_contribution_body +# ====================================================================== + + +class TestFormatContributionBody: + """Test issue body formatting.""" + + def test_basic_body_has_sections(self): + from azext_prototype.stages.knowledge_contributor import format_contribution_body + + finding = { + "service": "cosmos-db", + "service_namespace": "Microsoft.DocumentDB/databaseAccounts", + "context": "Missing retry", + "section": "Common Pitfalls", + "content": "Add retry for 429", + "source": "QA diagnosis", + } + body = format_contribution_body(finding) + assert "## Knowledge Contribution" in body + assert "### Context" in body + assert "### Rationale" in body + assert "### Content to Add" in body + assert "### Source" in body + assert "Common Pitfalls" in body + + def test_new_service_type_promotion(self): + """When file doesn't exist and type is Pitfall, promote to New service.""" + from azext_prototype.stages.knowledge_contributor import format_contribution_body + + finding = { + "type": "Pitfall", + "service": "brand-new", + "file": "knowledge/services/nonexistent.md", + "context": "New service info", + } + body = format_contribution_body(finding) + assert "**Type:** New service" in body + assert "### Required Knowledge File Sections" in body + assert "NEW FILE" in body + + def test_no_content_placeholder(self): + from azext_prototype.stages.knowledge_contributor import format_contribution_body + + finding = {"service": "x", "context": "some issue"} + body = format_contribution_body(finding) + assert "No specific content provided" in body + + +# ====================================================================== +# submit_contribution +# ====================================================================== + + +class TestSubmitContribution: + """Test issue submission with auth check and label retry.""" + + @patch("azext_prototype.stages.knowledge_contributor.format_contribution_body", return_value="body") + @patch("azext_prototype.stages.knowledge_contributor.format_contribution_title", return_value="title") + @patch("azext_prototype.stages.knowledge_contributor._run_gh_issue_create") + @patch("azext_prototype.stages.backlog_push.check_gh_auth", return_value=True) + @patch("azext_prototype.debug_log.log_flow") + def test_success_first_try(self, mock_log, mock_auth, mock_create, mock_title, mock_body): + from azext_prototype.stages.knowledge_contributor import submit_contribution + + mock_create.return_value = MagicMock(returncode=0, stdout="https://github.com/org/repo/issues/42\n") + result = submit_contribution({"service": "x", "context": "y"}) + assert result["url"] == "https://github.com/org/repo/issues/42" + assert result["number"] == "42" + + @patch("azext_prototype.stages.knowledge_contributor._run_gh_issue_create") + @patch("azext_prototype.stages.backlog_push.check_gh_auth", return_value=True) + @patch("azext_prototype.debug_log.log_flow") + def test_label_retry_fallback(self, mock_log, mock_auth, mock_create): + """First call fails (bad label), retry with fallback labels succeeds.""" + from azext_prototype.stages.knowledge_contributor import submit_contribution + + mock_create.side_effect = [ + MagicMock(returncode=1, stderr="label not found", stdout=""), + MagicMock(returncode=0, stdout="https://github.com/org/repo/issues/99\n"), + ] + result = submit_contribution({"service": "x", "context": "y"}) + assert result["url"] == "https://github.com/org/repo/issues/99" + assert mock_create.call_count == 2 + + @patch("azext_prototype.stages.knowledge_contributor._run_gh_issue_create") + @patch("azext_prototype.stages.backlog_push.check_gh_auth", return_value=True) + @patch("azext_prototype.debug_log.log_flow") + def test_both_attempts_fail(self, mock_log, mock_auth, mock_create): + from azext_prototype.stages.knowledge_contributor import submit_contribution + + mock_create.side_effect = [ + MagicMock(returncode=1, stderr="error1", stdout=""), + MagicMock(returncode=1, stderr="error2", stdout=""), + ] + result = submit_contribution({"service": "x", "context": "y"}) + assert "error" in result + + @patch("azext_prototype.stages.backlog_push.check_gh_auth", return_value=False) + def test_auth_check_fails(self, mock_auth): + from azext_prototype.stages.knowledge_contributor import submit_contribution + + result = submit_contribution({"service": "x", "context": "y"}) + assert "error" in result + assert "authenticated" in result["error"] + + @patch("azext_prototype.stages.knowledge_contributor._run_gh_issue_create", side_effect=FileNotFoundError) + @patch("azext_prototype.stages.backlog_push.check_gh_auth", return_value=True) + @patch("azext_prototype.debug_log.log_flow") + def test_gh_cli_not_found(self, mock_log, mock_auth, mock_create): + from azext_prototype.stages.knowledge_contributor import submit_contribution + + result = submit_contribution({"service": "x", "context": "y"}) + assert "error" in result + assert "gh CLI not found" in result["error"] + + @patch("azext_prototype.stages.knowledge_contributor._run_gh_issue_create") + @patch("azext_prototype.stages.backlog_push.check_gh_auth", return_value=True) + @patch("azext_prototype.debug_log.log_flow") + def test_type_label_mapping(self, mock_log, mock_auth, mock_create): + """Different contribution types map to correct labels.""" + from azext_prototype.stages.knowledge_contributor import submit_contribution + + mock_create.return_value = MagicMock(returncode=0, stdout="https://github.com/issues/1\n") + + for contrib_type, expected_label in [ + ("New service", "new-service"), + ("Tool pattern", "tool-pattern"), + ("Pitfall", "pitfall"), + ]: + submit_contribution({"service": "x", "context": "y", "type": contrib_type}) + call_args = mock_create.call_args + labels = call_args[0][3] if len(call_args[0]) > 3 else call_args[1].get("labels", []) + assert expected_label in labels + + +# ====================================================================== +# build_finding_from_qa +# ====================================================================== + + +class TestBuildFindingFromQa: + """Test QA finding builder.""" + + def test_basic_finding(self): + from azext_prototype.stages.knowledge_contributor import build_finding_from_qa + + finding = build_finding_from_qa( + "Error: timeout connecting to Cosmos DB", + service="cosmos-db", + service_namespace="Microsoft.DocumentDB/databaseAccounts", + section="Common Pitfalls", + ) + assert finding["service"] == "cosmos-db" + assert finding["service_namespace"] == "Microsoft.DocumentDB/databaseAccounts" + assert finding["section"] == "Common Pitfalls" + assert finding["type"] == "Pitfall" + assert "timeout" in finding["context"] + + def test_truncation(self): + from azext_prototype.stages.knowledge_contributor import build_finding_from_qa + + long_text = "A" * 1000 + finding = build_finding_from_qa(long_text) + assert len(finding["context"]) <= 500 + assert len(finding["content"]) <= 200 + + def test_empty_qa_content(self): + from azext_prototype.stages.knowledge_contributor import build_finding_from_qa + + finding = build_finding_from_qa("") + assert finding["context"] == "" + assert finding["content"] == "" + + +# ====================================================================== +# submit_if_gap +# ====================================================================== + + +class TestSubmitIfGap: + """Test fire-and-forget wrapper.""" + + @patch("azext_prototype.stages.knowledge_contributor.submit_contribution") + @patch("azext_prototype.stages.knowledge_contributor.check_knowledge_gap", return_value=True) + def test_gap_found_submits(self, mock_gap, mock_submit): + from azext_prototype.stages.knowledge_contributor import submit_if_gap + + mock_submit.return_value = {"url": "https://github.com/issues/1"} + printed = [] + result = submit_if_gap( + {"service": "x", "context": "y"}, + MagicMock(), + print_fn=printed.append, + ) + assert result["url"] == "https://github.com/issues/1" + assert any("submitted" in p for p in printed) + + @patch("azext_prototype.stages.knowledge_contributor.check_knowledge_gap", return_value=False) + def test_no_gap_returns_none(self, mock_gap): + from azext_prototype.stages.knowledge_contributor import submit_if_gap + + result = submit_if_gap({"service": "x", "context": "y"}, MagicMock()) + assert result is None + + @patch("azext_prototype.stages.knowledge_contributor.submit_contribution") + @patch("azext_prototype.stages.knowledge_contributor.check_knowledge_gap", return_value=True) + def test_submit_error_no_print(self, mock_gap, mock_submit): + from azext_prototype.stages.knowledge_contributor import submit_if_gap + + mock_submit.return_value = {"error": "auth failed"} + printed = [] + result = submit_if_gap( + {"service": "x", "context": "y"}, + MagicMock(), + print_fn=printed.append, + ) + assert result["error"] == "auth failed" + assert len(printed) == 0 + + @patch("azext_prototype.stages.knowledge_contributor.check_knowledge_gap", side_effect=RuntimeError("boom")) + def test_exception_caught_returns_none(self, mock_gap): + from azext_prototype.stages.knowledge_contributor import submit_if_gap + + result = submit_if_gap({"service": "x", "context": "y"}, MagicMock()) + assert result is None From 320096cfe07ecd48a72f17268d0f546077733b47 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Mon, 6 Apr 2026 20:46:22 -0400 Subject: [PATCH 04/12] Coverage push: 232 new tests for 5 session files, 3663 total passing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage improvements: - backlog_session.py: 63% → 99% - design_stage.py: 67% → 89% - discovery.py: 64% → 86% - deploy_session.py: 77% → 87% - build_session.py: 78% → 77% (largest file, needs more) New test files: - tests/stages/test_deploy_session.py (32 tests) - tests/stages/test_design_stage.py (27 tests) - tests/stages/test_discovery.py (36 tests) - tests/stages/test_backlog_session.py (45 tests) - tests/stages/test_build_session.py (+75 appended = 92 total) TDD memory updated: tests satisfy business rules, not code. --- tests/stages/test_backlog_session.py | 621 ++++++++++++++++++++++++ tests/stages/test_build_session.py | 675 +++++++++++++++++++++++++++ tests/stages/test_deploy_session.py | 586 +++++++++++++++++++++++ tests/stages/test_design_stage.py | 513 ++++++++++++++++++++ tests/stages/test_discovery.py | 534 +++++++++++++++++++++ 5 files changed, 2929 insertions(+) create mode 100644 tests/stages/test_backlog_session.py create mode 100644 tests/stages/test_deploy_session.py create mode 100644 tests/stages/test_design_stage.py create mode 100644 tests/stages/test_discovery.py diff --git a/tests/stages/test_backlog_session.py b/tests/stages/test_backlog_session.py new file mode 100644 index 0000000..64a0c46 --- /dev/null +++ b/tests/stages/test_backlog_session.py @@ -0,0 +1,621 @@ +"""Tests for backlog_session.py — branch coverage for cache vs regeneration, +quick mode vs interactive, item enrichment, push routing (GitHub vs DevOps), +review loop, /add command handling, _parse_items, _mutate_items, _save_backlog_md, +and slash commands. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from azext_prototype.agents.base import AgentCapability, AgentContext + +# ------------------------------------------------------------------ +# Fixtures +# ------------------------------------------------------------------ + + +@pytest.fixture +def backlog_context(project_with_design, sample_config): + provider = MagicMock() + provider.provider_name = "github-models" + provider.default_model = "gpt-4o" + provider.chat.return_value = MagicMock( + content='[{"epic": "API", "title": "Build REST API", "description": "desc", ' + '"acceptance_criteria": ["AC1"], "tasks": [{"title": "T1", "done": false}], ' + '"effort": "M", "status": "todo"}]', + model="test", + usage={"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300}, + ) + return AgentContext( + project_config=sample_config, + project_dir=str(project_with_design), + ai_provider=provider, + ) + + +@pytest.fixture +def backlog_registry(): + registry = MagicMock() + + mock_pm = MagicMock() + mock_pm.name = "project-manager" + mock_pm.get_system_messages.return_value = [] + mock_pm._temperature = 0.3 + mock_pm._max_tokens = 8192 + + mock_qa = MagicMock() + mock_qa.name = "qa-engineer" + + def find_by_cap(cap): + mapping = { + AgentCapability.BACKLOG_GENERATION: [mock_pm], + AgentCapability.QA: [mock_qa], + } + return mapping.get(cap, []) + + registry.find_by_capability.side_effect = find_by_cap + return registry + + +def _make_session(ctx, registry, items_response=None): + from azext_prototype.stages.backlog_session import BacklogSession + + session = BacklogSession(ctx, registry) + + # Override the AI response if specified AFTER session is created + if items_response is not None: + ctx.ai_provider.chat.return_value = MagicMock( + content=items_response, + model="test", + usage={"prompt_tokens": 50, "completion_tokens": 100, "total_tokens": 150}, + ) + + return session + + +# ------------------------------------------------------------------ +# BacklogResult +# ------------------------------------------------------------------ + + +class TestBacklogResult: + def test_defaults(self): + from azext_prototype.stages.backlog_session import BacklogResult + + result = BacklogResult() + assert result.items_generated == 0 + assert result.items_pushed == 0 + assert result.items_failed == 0 + assert result.push_urls == [] + assert result.cancelled is False + + def test_with_values(self): + from azext_prototype.stages.backlog_session import BacklogResult + + result = BacklogResult( + items_generated=5, + items_pushed=3, + items_failed=1, + push_urls=["https://github.com/issues/1"], + cancelled=False, + ) + assert result.items_generated == 5 + assert len(result.push_urls) == 1 + + +# ------------------------------------------------------------------ +# _parse_items +# ------------------------------------------------------------------ + + +class TestParseItems: + def test_valid_json_array(self): + from azext_prototype.stages.backlog_session import BacklogSession + + items = BacklogSession._parse_items('[{"title": "A"}, {"title": "B"}]') + assert len(items) == 2 + assert items[0]["title"] == "A" + + def test_json_with_fences(self): + from azext_prototype.stages.backlog_session import BacklogSession + + items = BacklogSession._parse_items('```json\n[{"title": "X"}]\n```') + assert len(items) == 1 + assert items[0]["title"] == "X" + + def test_invalid_json_returns_empty(self): + from azext_prototype.stages.backlog_session import BacklogSession + + items = BacklogSession._parse_items("not json at all") + assert items == [] + + def test_json_object_not_array_returns_empty(self): + from azext_prototype.stages.backlog_session import BacklogSession + + items = BacklogSession._parse_items('{"title": "single"}') + assert items == [] + + +# ------------------------------------------------------------------ +# Run — cached items path +# ------------------------------------------------------------------ + + +class TestRunCachedItems: + def test_cached_items_skip_generation(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + # Pre-populate cached items + session._backlog_state._state["items"] = [ + {"epic": "API", "title": "Build API", "status": "todo"}, + ] + session._backlog_state._state["context_hash"] = "" + session._backlog_state.matches_context = MagicMock(return_value=True) + + output = [] + result = session.run( + design_context="arch", + input_fn=lambda p: "done", + print_fn=lambda m: output.append(m), + ) + assert result.items_generated == 1 + assert not result.cancelled + + +# ------------------------------------------------------------------ +# Run — generation path +# ------------------------------------------------------------------ + + +class TestRunGeneration: + def test_generation_creates_items(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + output = [] + result = session.run( + design_context="Build an API with Cosmos DB", + input_fn=lambda p: "done", + print_fn=lambda m: output.append(m), + ) + assert result.items_generated >= 1 + assert not result.cancelled + + def test_no_pm_agent_cancels(self, backlog_context): + registry = MagicMock() + registry.find_by_capability.return_value = [] + + from azext_prototype.stages.backlog_session import BacklogSession + + session = BacklogSession(backlog_context, registry) + + result = session.run( + design_context="test", + input_fn=lambda p: "done", + print_fn=lambda m: None, + ) + assert result.cancelled is True + + def test_no_ai_provider_cancels(self, backlog_context, backlog_registry): + backlog_context.ai_provider = None + + from azext_prototype.stages.backlog_session import BacklogSession + + session = BacklogSession(backlog_context, backlog_registry) + + result = session.run( + design_context="test", + input_fn=lambda p: "done", + print_fn=lambda m: None, + ) + assert result.cancelled is True + + def test_empty_ai_response_cancels(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry, items_response="not json") + + result = session.run( + design_context="test", + input_fn=lambda p: "done", + print_fn=lambda m: None, + ) + assert result.cancelled is True + + +# ------------------------------------------------------------------ +# Run — quick mode +# ------------------------------------------------------------------ + + +class TestRunQuickMode: + @patch("azext_prototype.stages.backlog_session.check_gh_auth", return_value=True) + @patch("azext_prototype.stages.backlog_session.push_github_issue", return_value={"url": "https://gh/1"}) + def test_quick_mode_confirm_pushes(self, mock_push, mock_auth, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + result = session.run( + design_context="test", + provider="github", + org="myorg", + project="myrepo", + quick=True, + input_fn=lambda p: "y", + print_fn=lambda m: None, + ) + assert result.items_pushed >= 1 + + def test_quick_mode_cancel(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + result = session.run( + design_context="test", + quick=True, + input_fn=lambda p: "n", + print_fn=lambda m: None, + ) + assert result.cancelled is True + + def test_quick_mode_eof(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + def raise_eof(p): + raise EOFError + + result = session.run( + design_context="test", + quick=True, + input_fn=raise_eof, + print_fn=lambda m: None, + ) + assert result.cancelled is True + + +# ------------------------------------------------------------------ +# Interactive review loop +# ------------------------------------------------------------------ + + +class TestInteractiveLoop: + def test_quit_in_loop(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + inputs = iter(["quit"]) + result = session.run( + design_context="test", + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + assert result.cancelled is True + + def test_slash_quit(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + inputs = iter(["/quit"]) + result = session.run( + design_context="test", + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + assert result.cancelled is True + + def test_eof_in_loop(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + call_count = [0] + + def input_fn(p): + call_count[0] += 1 + if call_count[0] > 1: + raise EOFError + return "not a command" + + # Override to return a parseable mutation + backlog_context.ai_provider.chat.side_effect = [ + # Initial generation + MagicMock( + content='[{"epic": "A", "title": "T1"}]', + model="test", + usage={}, + ), + # Mutation call + MagicMock( + content='[{"epic": "A", "title": "T1 updated"}]', + model="test", + usage={}, + ), + ] + + result = session.run( + design_context="test", + input_fn=input_fn, + print_fn=lambda m: None, + ) + assert result.cancelled is True + + def test_empty_input_ignored(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + inputs = iter(["", "", "done"]) + result = session.run( + design_context="test", + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + assert not result.cancelled + + +# ------------------------------------------------------------------ +# Slash commands +# ------------------------------------------------------------------ + + +class TestSlashCommands: + def _run_with_commands(self, ctx, registry, commands): + session = _make_session(ctx, registry) + inputs = iter(commands + ["done"]) + output = [] + session.run( + design_context="test", + input_fn=lambda p: next(inputs), + print_fn=lambda m: output.append(m), + ) + return output + + def test_list_command(self, backlog_context, backlog_registry): + output = self._run_with_commands(backlog_context, backlog_registry, ["/list"]) + # Should have printed the backlog summary + assert any("Backlog" in str(m) or "item" in str(m).lower() for m in output) + + def test_show_valid_index(self, backlog_context, backlog_registry): + output = self._run_with_commands(backlog_context, backlog_registry, ["/show 1"]) + # Should show item details + assert len(output) > 0 + + def test_show_invalid_arg(self, backlog_context, backlog_registry): + output = self._run_with_commands(backlog_context, backlog_registry, ["/show"]) + assert any("Usage" in str(m) for m in output) + + def test_remove_valid_index(self, backlog_context, backlog_registry): + output = self._run_with_commands(backlog_context, backlog_registry, ["/remove 1"]) + assert any("Removed" in str(m) for m in output) + + def test_remove_invalid_arg(self, backlog_context, backlog_registry): + output = self._run_with_commands(backlog_context, backlog_registry, ["/remove"]) + assert any("Usage" in str(m) for m in output) + + def test_help_command(self, backlog_context, backlog_registry): + output = self._run_with_commands(backlog_context, backlog_registry, ["/help"]) + assert any("Available commands" in str(m) for m in output) + + def test_status_command(self, backlog_context, backlog_registry): + output = self._run_with_commands(backlog_context, backlog_registry, ["/status"]) + assert len(output) > 0 + + def test_preview_command(self, backlog_context, backlog_registry): + output = self._run_with_commands( + backlog_context, + backlog_registry, + ["/preview"], + ) + assert len(output) > 0 + + +# ------------------------------------------------------------------ +# _push_all — provider routing +# ------------------------------------------------------------------ + + +class TestPushAll: + def _set_items_with_status(self, session, items, statuses=None): + """Helper to properly set items with matching push_status and push_results arrays.""" + session._backlog_state._state["items"] = items + n = len(items) + session._backlog_state._state["push_status"] = statuses or ["pending"] * n + session._backlog_state._state["push_results"] = [None] * n + + @patch("azext_prototype.stages.backlog_session.check_gh_auth", return_value=False) + def test_github_auth_failure(self, mock_auth, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + self._set_items_with_status(session, [{"title": "A", "status": "todo"}]) + + output = [] + result = session._push_all("github", "org", "repo", lambda m: output.append(m), False) + assert result.cancelled is True + + @patch("azext_prototype.stages.backlog_session.check_devops_ext", return_value=False) + def test_devops_ext_not_available(self, mock_ext, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + self._set_items_with_status(session, [{"title": "A", "status": "todo"}]) + + output = [] + result = session._push_all("devops", "org", "project", lambda m: output.append(m), False) + assert result.cancelled is True + + @patch("azext_prototype.stages.backlog_session.check_gh_auth", return_value=True) + @patch("azext_prototype.stages.backlog_session.push_github_issue", return_value={"url": "https://gh/1"}) + def test_github_push_success(self, mock_push, mock_auth, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + self._set_items_with_status(session, [{"title": "A", "status": "todo"}]) + + output = [] + result = session._push_all("github", "org", "repo", lambda m: output.append(m), False) + assert result.items_pushed == 1 + assert "https://gh/1" in result.push_urls + + @patch("azext_prototype.stages.backlog_session.check_gh_auth", return_value=True) + @patch("azext_prototype.stages.backlog_session.push_github_issue", return_value={"error": "rate limited"}) + def test_github_push_failure(self, mock_push, mock_auth, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + self._set_items_with_status(session, [{"title": "A", "status": "todo"}]) + + output = [] + result = session._push_all("github", "org", "repo", lambda m: output.append(m), False) + assert result.items_failed == 1 + + @patch("azext_prototype.stages.backlog_session.check_devops_ext", return_value=True) + @patch("azext_prototype.stages.backlog_session.push_devops_feature") + @patch("azext_prototype.stages.backlog_session.push_devops_story") + @patch("azext_prototype.stages.backlog_session.push_devops_task") + def test_devops_push_with_children( + self, mock_task, mock_story, mock_feature, mock_ext, backlog_context, backlog_registry + ): + mock_feature.return_value = {"url": "https://devops/1", "id": "1"} + mock_story.return_value = {"url": "https://devops/s1", "id": "2"} + mock_task.return_value = {"url": "https://devops/t1"} + + session = _make_session(backlog_context, backlog_registry) + items = [ + { + "title": "Feature A", + "status": "todo", + "children": [ + { + "title": "Story 1", + "tasks": [{"title": "Task 1", "done": False}], + } + ], + } + ] + self._set_items_with_status(session, items) + + output = [] + result = session._push_all("devops", "org", "proj", lambda m: output.append(m), False) + assert result.items_pushed == 1 + mock_story.assert_called_once() + mock_task.assert_called_once() + + def test_no_pending_items(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + self._set_items_with_status(session, [{"title": "A", "status": "pushed"}], statuses=["pushed"]) + + output = [] + result = session._push_all("github", "org", "repo", lambda m: output.append(m), False) + # items_pushed reflects historical pushed count (1 already pushed) + assert result.items_pushed == 1 + assert any("No pending" in str(m) for m in output) + + +# ------------------------------------------------------------------ +# _enrich_new_item +# ------------------------------------------------------------------ + + +class TestEnrichNewItem: + def test_no_pm_agent_returns_bare(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + session._pm_agent = None + + item = session._enrich_new_item("Build rate limiter") + assert item["title"] == "Build rate limiter" + assert item["epic"] == "Added" + + def test_no_ai_provider_returns_bare(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + session._context.ai_provider = None + + item = session._enrich_new_item("Build rate limiter") + assert item["title"] == "Build rate limiter" + + def test_successful_enrichment(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + backlog_context.ai_provider.chat.return_value = MagicMock( + content='{"epic": "Performance", "title": "API Rate Limiter", ' + '"description": "Implement rate limiting", ' + '"acceptance_criteria": ["Limit 100 req/s"], ' + '"tasks": ["Add middleware"], "effort": "M"}', + model="test", + usage={}, + ) + + item = session._enrich_new_item("Build rate limiter") + assert item["title"] == "API Rate Limiter" + assert item["epic"] == "Performance" + + def test_enrichment_failure_falls_back(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + backlog_context.ai_provider.chat.side_effect = Exception("AI failed") + + item = session._enrich_new_item("Build rate limiter") + assert item["title"] == "Build rate limiter" + assert item["epic"] == "Added" + + def test_enrichment_with_fenced_json(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + + backlog_context.ai_provider.chat.return_value = MagicMock( + content='```json\n{"epic": "Infra", "title": "Add CDN"}\n```', + model="test", + usage={}, + ) + + item = session._enrich_new_item("Add CDN") + assert item["title"] == "Add CDN" + assert item["epic"] == "Infra" + + +# ------------------------------------------------------------------ +# _mutate_items +# ------------------------------------------------------------------ + + +class TestMutateItems: + def test_successful_mutation(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + session._backlog_state._state["items"] = [{"title": "Old title"}] + + backlog_context.ai_provider.chat.return_value = MagicMock( + content='[{"title": "Updated title"}]', + model="test", + usage={}, + ) + + result = session._mutate_items("Change title to Updated title", "design context") + assert result is not None + assert result[0]["title"] == "Updated title" + + def test_no_pm_agent_returns_none(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + session._pm_agent = None + + result = session._mutate_items("Change title", "ctx") + assert result is None + + def test_no_ai_provider_returns_none(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + session._context.ai_provider = None + + result = session._mutate_items("Change title", "ctx") + assert result is None + + +# ------------------------------------------------------------------ +# _save_backlog_md +# ------------------------------------------------------------------ + + +class TestSaveBacklogMd: + def test_saves_markdown(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + session._backlog_state._state["items"] = [ + {"epic": "API", "title": "Build endpoints", "effort": "M", "description": "REST API"}, + ] + + output = [] + session._save_backlog_md(lambda m: output.append(m)) + + md_path = Path(backlog_context.project_dir) / "concept" / "docs" / "BACKLOG.md" + assert md_path.exists() + content = md_path.read_text() + assert "Build endpoints" in content + + def test_empty_items_prints_message(self, backlog_context, backlog_registry): + session = _make_session(backlog_context, backlog_registry) + session._backlog_state._state["items"] = [] + + output = [] + session._save_backlog_md(lambda m: output.append(m)) + assert any("No items" in str(m) for m in output) diff --git a/tests/stages/test_build_session.py b/tests/stages/test_build_session.py index a11bd18..98711ef 100644 --- a/tests/stages/test_build_session.py +++ b/tests/stages/test_build_session.py @@ -15,6 +15,7 @@ - generated/accepted → skipped (not in stages_to_process) """ +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -453,3 +454,677 @@ def test_get_generated(self, tmp_path): generated = bs.get_generated_stages() assert len(generated) == 2 + + +# ------------------------------------------------------------------ +# _qa_has_issues — 3-tier detection +# ------------------------------------------------------------------ + + +class TestQaHasIssues: + """Tests for the three-tier QA issue detection function.""" + + def test_empty_content_returns_false(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("") is False + + def test_verdict_pass(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("Overall assessment:\nVERDICT: PASS") is False + + def test_verdict_pass_bold(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("**VERDICT: PASS**") is False + + def test_verdict_fail_with_critical(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("VERDICT: FAIL\nCRITICAL: missing auth") is True + + def test_verdict_fail_without_critical_overrides_to_pass(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("VERDICT: FAIL\nWARNING: minor issue only") is False + + def test_pass_phrase_no_issues_found(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("After reviewing: no issues found. All looks good.") is False + + def test_pass_phrase_all_checks_passed(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("All checks passed.") is False + + def test_keyword_fallback_critical(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("There is a critical problem with the config") is True + + def test_keyword_fallback_error(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("Found an error in the deployment") is True + + def test_keyword_fallback_missing(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("Outputs are missing from stage 3") is True + + def test_clean_text_no_keywords(self): + from azext_prototype.stages.build_session import _qa_has_issues + + assert _qa_has_issues("Everything looks great. Well done.") is False + + +# ------------------------------------------------------------------ +# _select_agent — all layer routing paths +# ------------------------------------------------------------------ + + +class TestSelectAgent: + """Tests for _select_agent covering all layer/capability routing.""" + + def test_layer_core(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + agent = session._select_agent({"layer": "core", "capability": "infra"}) + assert agent is not None # Should route to iac agent or architect + + def test_layer_infra(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + agent = session._select_agent({"layer": "infra", "capability": "infra"}) + assert agent is not None + + def test_layer_data(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + agent = session._select_agent({"layer": "data", "capability": "data"}) + assert agent is not None + + def test_layer_app(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + # May return None if no app agent registered — just verify no error + session._select_agent({"layer": "app", "capability": "app"}) + + def test_layer_docs(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + agent = session._select_agent({"layer": "docs", "capability": "docs"}) + assert agent is not None + assert agent.name == "doc-agent" + + def test_fallback_infra_capability(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + agent = session._select_agent({"layer": "", "capability": "infra"}) + assert agent is not None + + def test_fallback_app_capability(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + # Covers the schema/cicd/external path too — just verify no error + session._select_agent({"layer": "", "capability": "app"}) + + def test_fallback_docs_capability(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + agent = session._select_agent({"layer": "", "capability": "docs"}) + assert agent is not None + + def test_fallback_unknown_capability(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + agent = session._select_agent({"layer": "", "capability": "unknown"}) + assert agent is not None # Falls through to last else + + +# ------------------------------------------------------------------ +# _build_stage_task — IaC vs app vs docs branches +# ------------------------------------------------------------------ + + +class TestBuildStageTask: + def test_iac_stage_task(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "dir": "concept/infra/terraform/stage-1-key-vault", + "services": [ + { + "name": "key-vault", + "computed_name": "kv-test", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + "component": "secrets", + } + ], + "status": "pending", + "files": [], + } + agent, task = session._build_stage_task(stage, "arch", []) + assert agent is not None + assert "MANDATORY RESOURCE POLICIES" in task or "Generate" in task + assert "key-vault" in task.lower() or "Key Vault" in task + + def test_app_stage_task(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + # Ensure an app developer exists for routing + mock_dev = MagicMock() + mock_dev.name = "app-developer" + mock_dev.set_knowledge_override = MagicMock() + mock_dev.set_governor_brief = MagicMock() + mock_dev._governance_aware = False + mock_dev._enable_web_search = False + mock_dev._enable_mcp_tools = False + session._dev_agent = mock_dev + + stage = { + "stage": 5, + "name": "API Service", + "layer": "app", + "capability": "app", + "dir": "concept/apps/stage-5-api", + "services": [{"name": "fastapi-app", "computed_name": "", "resource_type": "", "sku": "", "component": ""}], + "status": "pending", + "files": [], + } + agent, task = session._build_stage_task(stage, "arch", []) + assert agent is not None + assert "DefaultAzureCredential" in task or "managed identity" in task.lower() + assert "Do NOT generate" in task or "IaC" in task + + def test_docs_stage_task(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = { + "stage": 10, + "name": "Documentation", + "layer": "docs", + "capability": "docs", + "dir": "concept/docs", + "services": [], + "status": "pending", + "files": [], + } + agent, task = session._build_stage_task(stage, "arch", []) + assert agent is not None + assert "architecture.md" in task or "deployment-guide.md" in task + + def test_no_agent_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + # Remove all agents + session._iac_agents = {} + session._architect_agent = None + session._infra_architect = None + session._data_architect = None + session._app_architect = None + session._security_architect = None + session._doc_agent = None + session._dev_agent = None + + stage = { + "stage": 1, + "name": "Nothing", + "layer": "unknown_layer", + "capability": "unknown", + "dir": "concept", + "services": [], + "status": "pending", + "files": [], + } + agent, task = session._build_stage_task(stage, "arch", []) + assert agent is None + assert task == "" + + +# ------------------------------------------------------------------ +# _write_stage_files — layer filtering +# ------------------------------------------------------------------ + + +class TestWriteStageFiles: + def test_docs_allowlist(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = {"layer": "docs", "dir": "concept/docs"} + + content = ( + "```architecture.md\n# Architecture\n```\n" + "```deployment-guide.md\n# Deployment\n```\n" + "```main.tf\n# should be blocked\n```\n" + ) + paths = session._write_stage_files(stage, content) + filenames = [Path(p).name for p in paths] + assert "architecture.md" in filenames + assert "deployment-guide.md" in filenames + assert "main.tf" not in filenames + + def test_app_blocks_iac_files(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage_dir = "concept/apps/stage-2-api" + stage = {"layer": "app", "dir": stage_dir} + + content = "```main.py\nprint('hello')\n```\n" "```main.tf\n# blocked\n```\n" "```deploy.sh\n# blocked\n```\n" + paths = session._write_stage_files(stage, content) + filenames = [Path(p).name for p in paths] + assert "main.py" in filenames + assert "main.tf" not in filenames + assert "deploy.sh" not in filenames + + def test_infra_blocks_versions_tf(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = {"layer": "infra", "dir": "concept/infra/terraform/stage-1"} + + content = "```main.tf\nresource {}\n```\n" "```versions.tf\n# blocked for terraform\n```\n" + paths = session._write_stage_files(stage, content) + filenames = [Path(p).name for p in paths] + assert "main.tf" in filenames + assert "versions.tf" not in filenames + + def test_empty_content_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + assert session._write_stage_files({"layer": "infra", "dir": "concept"}, "") == [] + + def test_no_file_blocks_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + assert session._write_stage_files({"layer": "infra", "dir": "concept"}, "no code blocks here") == [] + + +# ------------------------------------------------------------------ +# _apply_stage_transforms — passthrough +# ------------------------------------------------------------------ + + +class TestApplyStageTransforms: + def test_empty_paths_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._apply_stage_transforms({"services": []}, [], lambda m: None) + assert result == [] + + +# ------------------------------------------------------------------ +# _resolve_developer_for_stage — language detection +# ------------------------------------------------------------------ + + +class TestResolveDeveloperForStage: + def test_python_detected(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = { + "name": "FastAPI Backend", + "dir": "concept/apps/stage-5-fastapi", + "services": [{"name": "fastapi-api"}], + } + # May be None if no python dev registered, but should not raise + session._resolve_developer_for_stage(stage, "FastAPI backend") + + def test_csharp_detected(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = { + "name": "ASP.NET API", + "dir": "concept/apps/stage-5-dotnet", + "services": [{"name": "aspnet-app"}], + } + session._resolve_developer_for_stage(stage, "ASP.NET Core API") + + def test_react_detected(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = { + "name": "React Frontend", + "dir": "concept/apps/stage-6-react", + "services": [{"name": "react-spa"}], + } + session._resolve_developer_for_stage(stage, "React SPA") + + def test_no_language_returns_none(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = { + "name": "Generic Service", + "dir": "concept/apps/stage-7", + "services": [{"name": "generic"}], + } + dev = session._resolve_developer_for_stage(stage, "Some generic service") + assert dev is None + + +# ------------------------------------------------------------------ +# _decompose_app_stage — delegation +# ------------------------------------------------------------------ + + +class TestDecomposeAppStage: + def test_with_detected_developer(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + # Mock a python developer + mock_dev = MagicMock() + mock_dev.name = "python-developer" + session._python_dev = mock_dev + + stage = { + "name": "Python API", + "dir": "concept/apps/stage-5-python", + "services": [{"name": "python-api"}], + } + agent, context = session._decompose_app_stage(stage, "Python FastAPI backend", lambda m: None) + assert agent == mock_dev + assert "Sub-Layer" in context + + def test_fallback_without_developer(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = { + "name": "Mystery Service", + "dir": "concept/apps/stage-5", + "services": [{"name": "mystery"}], + } + agent, context = session._decompose_app_stage(stage, "Unknown architecture", lambda m: None) + assert context == "" + + +# ------------------------------------------------------------------ +# _detect_framework — static method +# ------------------------------------------------------------------ + + +class TestDetectFramework: + def test_fastapi(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"fastapi-api"}, "concept/apps/stage-5", set()) + assert "FastAPI" in result + + def test_react(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"react-spa"}, "concept/apps/stage-6", set()) + assert "React" in result or "SPA" in result + + def test_dotnet(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"aspnet-api"}, "concept/apps/stage-7", set()) + assert ".NET" in result + + def test_dotnet_functions(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"function-app"}, "concept/apps/stage-7", set()) + assert "Functions" in result + + def test_express(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"express-api"}, "concept/apps/stage-8", set()) + assert "Express" in result or "Node.js" in result + + def test_go(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"golang-api"}, "concept/apps/stage-9", set()) + assert "Go" in result + + def test_java(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"spring-api"}, "concept/apps/stage-10", set()) + assert "Java" in result or "Spring" in result + + def test_unknown_returns_empty(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"custom-service"}, "concept/apps/stage-11", set()) + assert result == "" + + def test_flask(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"flask-api"}, "concept/apps", set()) + assert "Flask" in result + + def test_django(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"django-app"}, "concept/apps", set()) + assert "Django" in result + + def test_vue(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"vue-frontend"}, "concept/apps", set()) + assert "Vue" in result or "SPA" in result + + def test_nest(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._detect_framework({"nest-api"}, "concept/apps", set()) + assert "NestJS" in result or "Node.js" in result + + +# ------------------------------------------------------------------ +# _categorize_service +# ------------------------------------------------------------------ + + +class TestCategorizeService: + def test_infra_type(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._categorize_service("key-vault") == "infra" + assert BuildSession._categorize_service("virtual-network") == "infra" + + def test_data_type(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._categorize_service("cosmos-db") == "data" + assert BuildSession._categorize_service("redis-cache") == "data" + + def test_app_type(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._categorize_service("custom-service") == "app" + + +# ------------------------------------------------------------------ +# _infer_layer +# ------------------------------------------------------------------ + + +class TestInferLayer: + def test_explicit_layer_returned(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._infer_layer({"layer": "docs", "name": "Docs"}) == "docs" + + def test_identity_detected_as_core(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._infer_layer({"name": "Managed Identity", "capability": "infra"}) == "core" + + def test_monitoring_detected_as_core(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._infer_layer({"name": "Log Analytics", "capability": "infra"}) == "core" + + def test_capability_mapping(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._infer_layer({"name": "Redis", "capability": "data"}) == "data" + assert BuildSession._infer_layer({"name": "API", "capability": "app"}) == "app" + assert BuildSession._infer_layer({"name": "Docs", "capability": "docs"}) == "docs" + + +# ------------------------------------------------------------------ +# _enforce_concept_prefix +# ------------------------------------------------------------------ + + +class TestEnforceConceptPrefix: + def test_already_concept_prefix(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + assert session._enforce_concept_prefix("concept/infra/stage-1") == "concept/infra/stage-1" + + def test_wrong_prefix_fixed(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + assert session._enforce_concept_prefix("output/infra/stage-1") == "concept/infra/stage-1" + + def test_bare_subdir(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + assert session._enforce_concept_prefix("infra") == "concept/infra" + + def test_empty_passthrough(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + assert session._enforce_concept_prefix("") == "" + + def test_unrelated_path_passthrough(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + assert session._enforce_concept_prefix("random/path/here") == "random/path/here" + + +# ------------------------------------------------------------------ +# _parse_deployment_plan +# ------------------------------------------------------------------ + + +class TestParseDeploymentPlan: + def test_fenced_json(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + content = '```json\n{"stages": [{"stage": 1, "name": "A", "services": []}]}\n```' + result = session._parse_deployment_plan(content) + assert len(result) == 1 + + def test_raw_json(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + content = '{"stages": [{"stage": 1, "name": "A", "services": []}]}' + result = session._parse_deployment_plan(content) + assert len(result) == 1 + + def test_invalid_json_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._parse_deployment_plan("not json") + assert result == [] + + def test_empty_stages_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._parse_deployment_plan('{"stages": []}') + assert result == [] + + +# ------------------------------------------------------------------ +# _parse_stage_map +# ------------------------------------------------------------------ + + +class TestParseStageMap: + def test_valid_map(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + content = ( + '```json\n{"stages": [{"stage": 1, "name": "A",' + ' "layer": "core", "capability": "infra",' + ' "services": ["managed-identity"]}]}\n```' + ) + result = session._parse_stage_map(content) + assert len(result) >= 1 # May include injected networking + docs + + def test_invalid_json_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._parse_stage_map("not json") + assert result == [] + + def test_ensures_docs_stage(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + content = '{"stages": [{"stage": 1, "name": "A", "layer": "core", "capability": "infra", "services": []}]}' + result = session._parse_stage_map(content) + assert any(s.get("layer") == "docs" for s in result) + + +# ------------------------------------------------------------------ +# _ensure_networking_in_map +# ------------------------------------------------------------------ + + +class TestEnsureNetworkingInMap: + def test_inserts_when_missing(self): + from azext_prototype.stages.build_session import BuildSession + + stages = [ + {"stage": 1, "name": "Managed Identity", "services": ["managed-identity"]}, + {"stage": 2, "name": "Key Vault", "services": ["key-vault"]}, + ] + BuildSession._ensure_networking_in_map(stages) + assert any(s["name"] == "Networking" for s in stages) + + def test_skips_when_present(self): + from azext_prototype.stages.build_session import BuildSession + + stages = [ + {"stage": 1, "name": "Networking", "services": ["virtual-network"]}, + {"stage": 2, "name": "Key Vault", "services": ["key-vault"]}, + ] + original_len = len(stages) + BuildSession._ensure_networking_in_map(stages) + assert len(stages) == original_len + + def test_skips_when_vnet_in_services(self): + from azext_prototype.stages.build_session import BuildSession + + stages = [ + {"stage": 1, "name": "Foundation", "services": ["vnet"]}, + ] + original_len = len(stages) + BuildSession._ensure_networking_in_map(stages) + assert len(stages) == original_len + + +# ------------------------------------------------------------------ +# BuildResult +# ------------------------------------------------------------------ + + +class TestBuildResult: + def test_defaults(self): + from azext_prototype.stages.build_session import BuildResult + + result = BuildResult() + assert result.files_generated == [] + assert result.deployment_stages == [] + assert result.policy_overrides == [] + assert result.resources == [] + assert result.review_accepted is False + assert result.cancelled is False + + def test_cancelled(self): + from azext_prototype.stages.build_session import BuildResult + + result = BuildResult(cancelled=True) + assert result.cancelled is True + + +# ------------------------------------------------------------------ +# _get_app_scaffolding_requirements +# ------------------------------------------------------------------ + + +class TestGetAppScaffoldingRequirements: + def test_non_app_layer_returns_empty(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._get_app_scaffolding_requirements({"layer": "infra"}) == "" + + def test_app_layer_generic_fallback(self): + from azext_prototype.stages.build_session import BuildSession + + stage = { + "layer": "app", + "services": [{"name": "custom", "resource_type": "", "sku": ""}], + "dir": "concept/apps/stage-5", + } + result = BuildSession._get_app_scaffolding_requirements(stage) + assert "Required Project Files" in result + + def test_app_layer_python_detected(self): + from azext_prototype.stages.build_session import BuildSession + + stage = { + "layer": "app", + "services": [{"name": "python-api", "resource_type": "", "sku": ""}], + "dir": "concept/apps/stage-5-python", + } + result = BuildSession._get_app_scaffolding_requirements(stage) + assert "Python" in result or "requirements.txt" in result diff --git a/tests/stages/test_deploy_session.py b/tests/stages/test_deploy_session.py new file mode 100644 index 0000000..f0b7672 --- /dev/null +++ b/tests/stages/test_deploy_session.py @@ -0,0 +1,586 @@ +"""Tests for deploy_session.py — branch coverage for dry-run layer branching, +preflight checks, stage deployment by layer, rollback ordering, output capture, +SP credential resolution, deployment context env building, and interactive loop. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from azext_prototype.agents.base import AgentCapability, AgentContext + +# ------------------------------------------------------------------ +# Fixtures +# ------------------------------------------------------------------ + + +@pytest.fixture +def deploy_context(project_with_build, sample_config): + provider = MagicMock() + provider.provider_name = "github-models" + provider.default_model = "gpt-4o" + provider.chat.return_value = MagicMock( + content="Diagnosis: resource group missing.", + model="test", + usage={"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300}, + ) + return AgentContext( + project_config=sample_config, + project_dir=str(project_with_build), + ai_provider=provider, + ) + + +@pytest.fixture +def deploy_registry(): + registry = MagicMock() + + mock_qa = MagicMock() + mock_qa.name = "qa-engineer" + mock_qa.execute = MagicMock(return_value=MagicMock(content="Issue diagnosed.", model="test", usage={})) + mock_qa.get_system_messages = MagicMock(return_value=[]) + mock_qa._temperature = 0.2 + mock_qa._max_tokens = 4096 + + mock_tf = MagicMock() + mock_tf.name = "terraform-agent" + mock_tf.execute = MagicMock(return_value=MagicMock(content="Fixed.", model="test", usage={})) + mock_tf.get_system_messages = MagicMock(return_value=[]) + + mock_dev = MagicMock() + mock_dev.name = "app-developer" + mock_dev.execute = MagicMock(return_value=MagicMock(content="Fixed app.", model="test", usage={})) + mock_dev.get_system_messages = MagicMock(return_value=[]) + + mock_architect = MagicMock() + mock_architect.name = "cloud-architect" + mock_architect.execute = MagicMock(return_value=MagicMock(content="Guide fix.", model="test", usage={})) + + def find_by_cap(cap): + mapping = { + AgentCapability.QA: [mock_qa], + AgentCapability.TERRAFORM: [mock_tf], + AgentCapability.BICEP: [], + AgentCapability.DEVELOP: [mock_dev], + AgentCapability.ARCHITECT: [mock_architect], + } + return mapping.get(cap, []) + + registry.find_by_capability.side_effect = find_by_cap + return registry + + +def _make_session(deploy_context, deploy_registry): + from azext_prototype.stages.deploy_session import DeploySession + + return DeploySession(deploy_context, deploy_registry) + + +# ------------------------------------------------------------------ +# DeployResult +# ------------------------------------------------------------------ + + +class TestDeployResult: + def test_defaults(self): + from azext_prototype.stages.deploy_session import DeployResult + + result = DeployResult() + assert result.deployed_stages == [] + assert result.failed_stages == [] + assert result.rolled_back_stages == [] + assert result.captured_outputs == {} + assert result.cancelled is False + + def test_with_values(self): + from azext_prototype.stages.deploy_session import DeployResult + + result = DeployResult( + deployed_stages=[{"stage": 1}], + captured_outputs={"key": "val"}, + cancelled=True, + ) + assert len(result.deployed_stages) == 1 + assert result.captured_outputs["key"] == "val" + assert result.cancelled is True + + +# ------------------------------------------------------------------ +# _lookup_deployer_object_id +# ------------------------------------------------------------------ + + +class TestLookupDeployerObjectId: + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_user_auth_returns_oid(self, mock_run): + from azext_prototype.stages.deploy_session import _lookup_deployer_object_id + + mock_run.return_value = MagicMock(returncode=0, stdout="abc-123-def\n") + result = _lookup_deployer_object_id() + assert result == "abc-123-def" + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_sp_auth_uses_client_id(self, mock_run): + from azext_prototype.stages.deploy_session import _lookup_deployer_object_id + + mock_run.return_value = MagicMock(returncode=0, stdout="sp-oid\n") + result = _lookup_deployer_object_id(client_id="my-client") + assert result == "sp-oid" + # Should have called with sp show + call_args = mock_run.call_args[0][0] + assert "sp" in call_args + assert "my-client" in call_args + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_failure_returns_none(self, mock_run): + from azext_prototype.stages.deploy_session import _lookup_deployer_object_id + + mock_run.return_value = MagicMock(returncode=1, stdout="") + result = _lookup_deployer_object_id() + assert result is None + + @patch("azext_prototype.stages.deploy_session.subprocess.run", side_effect=FileNotFoundError) + def test_az_not_found_returns_none(self, mock_run): + from azext_prototype.stages.deploy_session import _lookup_deployer_object_id + + result = _lookup_deployer_object_id() + assert result is None + + +# ------------------------------------------------------------------ +# _resolve_context +# ------------------------------------------------------------------ + + +class TestResolveContext: + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + def test_falls_back_to_current_subscription(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + session._resolve_context(None, None) + assert session._subscription == "sub-123" + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value="oid-abc") + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="") + @patch("azext_prototype.stages.deploy_session.set_deployment_context", return_value={"status": "ok"}) + def test_sets_deployment_context_with_tenant( + self, mock_ctx, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry + ): + session = _make_session(deploy_context, deploy_registry) + session._resolve_context("sub-override", "tenant-abc") + assert session._subscription == "sub-override" + assert session._tenant == "tenant-abc" + assert session._deploy_env["TF_VAR_deployer_object_id"] == "oid-abc" + + +# ------------------------------------------------------------------ +# Preflight checks +# ------------------------------------------------------------------ + + +class TestPreflightChecks: + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=False) + def test_check_subscription_not_logged_in(self, mock_login, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + result = session._check_subscription("sub-123") + assert result["status"] == "fail" + assert "Not logged in" in result["message"] + + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="other-sub") + def test_check_subscription_mismatch(self, mock_sub, mock_login, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + result = session._check_subscription("target-sub-1234") + assert result["status"] == "warn" + + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="") + def test_check_subscription_pass(self, mock_sub, mock_login, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + result = session._check_subscription("") + assert result["status"] == "pass" + + @patch("azext_prototype.stages.deploy_session.get_current_tenant", return_value="other-tenant") + def test_check_tenant_mismatch(self, mock_tenant, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + result = session._check_tenant("target-tenant-1234") + assert result["status"] == "warn" + + @patch("azext_prototype.stages.deploy_session.get_current_tenant", return_value="target-tenant") + def test_check_tenant_match(self, mock_tenant, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + result = session._check_tenant("target-tenant") + assert result["status"] == "pass" + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_check_iac_tool_terraform_found(self, mock_run, deploy_context, deploy_registry): + mock_run.return_value = MagicMock(returncode=0, stdout="Terraform v1.5.0\n") + session = _make_session(deploy_context, deploy_registry) + result = session._check_iac_tool() + assert result["status"] == "pass" + assert "Terraform" in result["message"] + + @patch("azext_prototype.stages.deploy_session.subprocess.run", side_effect=FileNotFoundError) + def test_check_iac_tool_terraform_not_found(self, mock_run, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + result = session._check_iac_tool() + assert result["status"] == "fail" + + def test_check_iac_tool_bicep(self, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + session._iac_tool = "bicep" + result = session._check_iac_tool() + assert result["status"] == "pass" + assert "Bicep" in result["name"] + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_check_resource_group_exists(self, mock_run, deploy_context, deploy_registry): + mock_run.return_value = MagicMock(returncode=0) + session = _make_session(deploy_context, deploy_registry) + result = session._check_resource_group("sub", "rg-test") + assert result["status"] == "pass" + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_check_resource_group_not_found(self, mock_run, deploy_context, deploy_registry): + mock_run.return_value = MagicMock(returncode=1) + session = _make_session(deploy_context, deploy_registry) + result = session._check_resource_group("sub", "rg-test") + assert result["status"] == "warn" + + +# ------------------------------------------------------------------ +# _deploy_single_stage — layer dispatch +# ------------------------------------------------------------------ + + +class TestDeploySingleStage: + def _make_ready_session(self, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + session._subscription = "sub-123" + session._resource_group = "rg-test" + session._deploy_env = {} + return session + + def test_manual_deploy_mode(self, deploy_context, deploy_registry): + session = self._make_ready_session(deploy_context, deploy_registry) + stage = { + "stage": 1, + "name": "Manual Step", + "layer": "infra", + "deploy_mode": "manual", + "manual_instructions": "Run migration script", + "dir": "concept/infra", + "services": [], + } + result = session._deploy_single_stage(stage) + assert result["status"] == "awaiting_manual" + assert "migration" in result["instructions"] + + def test_missing_directory_skipped(self, deploy_context, deploy_registry): + session = self._make_ready_session(deploy_context, deploy_registry) + stage = { + "stage": 1, + "name": "Missing", + "layer": "infra", + "deploy_mode": "auto", + "dir": "concept/infra/terraform/nonexistent", + "services": [], + } + result = session._deploy_single_stage(stage) + assert result["status"] == "skipped" + + @patch("azext_prototype.stages.deploy_session.deploy_terraform") + @patch("azext_prototype.stages.deploy_session.resolve_stage_secrets", return_value={}) + def test_infra_layer_dispatches_terraform(self, mock_secrets, mock_deploy, deploy_context, deploy_registry): + session = self._make_ready_session(deploy_context, deploy_registry) + # Create stage directory + stage_dir = Path(deploy_context.project_dir) / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + + mock_deploy.return_value = {"status": "deployed", "deployment_output": ""} + + stage = { + "stage": 1, + "name": "Foundation", + "layer": "infra", + "deploy_mode": "auto", + "dir": "concept/infra/terraform/stage-1", + "services": [], + } + result = session._deploy_single_stage(stage) + assert result["status"] == "deployed" + mock_deploy.assert_called_once() + + @patch("azext_prototype.stages.deploy_session.deploy_app_stage") + def test_app_layer_dispatches_app_deploy(self, mock_deploy, deploy_context, deploy_registry): + session = self._make_ready_session(deploy_context, deploy_registry) + stage_dir = Path(deploy_context.project_dir) / "concept" / "apps" / "stage-2" + stage_dir.mkdir(parents=True, exist_ok=True) + + mock_deploy.return_value = {"status": "deployed"} + + stage = { + "stage": 2, + "name": "API", + "layer": "app", + "deploy_mode": "auto", + "dir": "concept/apps/stage-2", + "services": [], + } + result = session._deploy_single_stage(stage) + assert result["status"] == "deployed" + mock_deploy.assert_called_once() + + def test_docs_layer_auto_deployed(self, deploy_context, deploy_registry): + session = self._make_ready_session(deploy_context, deploy_registry) + stage_dir = Path(deploy_context.project_dir) / "concept" / "docs" + stage_dir.mkdir(parents=True, exist_ok=True) + + stage = { + "stage": 3, + "name": "Documentation", + "layer": "docs", + "deploy_mode": "auto", + "dir": "concept/docs", + "services": [], + } + result = session._deploy_single_stage(stage) + assert result["status"] == "deployed" + + +# ------------------------------------------------------------------ +# Dry-run +# ------------------------------------------------------------------ + + +class TestDryRun: + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + @patch("azext_prototype.stages.deploy_session.plan_terraform", return_value={"output": "Plan: 3 to add"}) + @patch("azext_prototype.stages.deploy_session.resolve_stage_secrets", return_value={}) + def test_dry_run_terraform( + self, mock_secrets, mock_plan, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry + ): + session = _make_session(deploy_context, deploy_registry) + # Create stage directories + stage_dir = Path(deploy_context.project_dir) / "concept" / "infra" / "terraform" / "stage-1-foundation" + stage_dir.mkdir(parents=True, exist_ok=True) + + output = [] + result = session.run_dry_run( + subscription="sub-123", + print_fn=lambda m: output.append(m), + ) + assert result.cancelled is False + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_dry_run_no_build_state(self, mock_sub, mock_env, mock_oid, project_with_config, sample_config): + from azext_prototype.stages.deploy_session import DeploySession + + # Use project WITHOUT build state + provider = MagicMock() + provider.provider_name = "github-models" + provider.chat.return_value = MagicMock(content="test", model="test", usage={}) + ctx = AgentContext( + project_config=sample_config, + project_dir=str(project_with_config), + ai_provider=provider, + ) + registry = MagicMock() + registry.find_by_capability.return_value = [] + + session = DeploySession(ctx, registry) + output = [] + result = session.run_dry_run( + subscription="sub-123", + print_fn=lambda m: output.append(m), + ) + assert result.cancelled is True + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_dry_run_target_stage_not_found(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + output = [] + result = session.run_dry_run( + subscription="sub-123", + target_stage=999, + print_fn=lambda m: output.append(m), + ) + assert result.cancelled is True + + +# ------------------------------------------------------------------ +# Single-stage deploy +# ------------------------------------------------------------------ + + +class TestSingleStageDeploy: + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_stage_not_found(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + output = [] + result = session.run_single_stage( + 999, + subscription="sub-123", + print_fn=lambda m: output.append(m), + ) + assert result.cancelled is True + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_no_build_state_cancels(self, mock_sub, mock_env, mock_oid, project_with_config, sample_config): + from azext_prototype.stages.deploy_session import DeploySession + + provider = MagicMock() + provider.provider_name = "github-models" + provider.chat.return_value = MagicMock(content="test", model="test", usage={}) + ctx = AgentContext( + project_config=sample_config, + project_dir=str(project_with_config), + ai_provider=provider, + ) + registry = MagicMock() + registry.find_by_capability.return_value = [] + + session = DeploySession(ctx, registry) + output = [] + result = session.run_single_stage( + 1, + subscription="sub-123", + print_fn=lambda m: output.append(m), + ) + assert result.cancelled is True + + +# ------------------------------------------------------------------ +# Run — interactive quit +# ------------------------------------------------------------------ + + +class TestDeployRunInteractive: + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_quit_at_confirmation(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + output = [] + result = session.run( + subscription="sub-123", + input_fn=lambda p: "quit", + print_fn=lambda m: output.append(m), + ) + assert result.cancelled is True + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_eof_at_confirmation(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + + def raise_eof(p): + raise EOFError + + result = session.run( + subscription="sub-123", + input_fn=raise_eof, + print_fn=lambda m: None, + ) + assert result.cancelled is True + + +# ------------------------------------------------------------------ +# _capture_stage_outputs +# ------------------------------------------------------------------ + + +class TestCaptureStageOutputs: + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_terraform_output_capture(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + session._iac_tool = "terraform" + session._output_capture = MagicMock() + session._output_capture.capture_terraform.return_value = {"key_vault_id": "/sub/rg/kv"} + session._output_capture.get_all.return_value = {"key_vault_id": "/sub/rg/kv"} + + stage = {"stage": 1, "dir": "concept/infra/terraform/stage-1", "services": []} + session._capture_stage_outputs(stage) + session._output_capture.capture_terraform.assert_called_once() + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_bicep_output_capture(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + session._iac_tool = "bicep" + session._output_capture = MagicMock() + session._output_capture.capture_bicep.return_value = {"result": "ok"} + session._output_capture.get_all.return_value = {"result": "ok"} + + stage = {"stage": 1, "dir": "concept/infra/bicep/stage-1", "deploy_output": "some output", "services": []} + session._capture_stage_outputs(stage) + session._output_capture.capture_bicep.assert_called_once_with("some output") + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_bicep_no_output_skips(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + session._iac_tool = "bicep" + session._output_capture = MagicMock() + session._output_capture.capture_bicep.return_value = {} + + stage = {"stage": 1, "dir": "concept/infra/bicep/stage-1", "services": []} + session._capture_stage_outputs(stage) + # No deploy_output key = no capture call + session._output_capture.capture_bicep.assert_not_called() + + +# ------------------------------------------------------------------ +# _extract_providers_from_files +# ------------------------------------------------------------------ + + +class TestExtractProviders: + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_extracts_terraform_providers(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + + stage_dir = Path(deploy_context.project_dir) / "concept" / "infra" / "terraform" / "stage-1-foundation" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text( + 'resource "azapi_resource" "kv" {\n' ' type = "Microsoft.KeyVault/vaults@2023-07-01"\n' "}\n", + encoding="utf-8", + ) + + session._deploy_state._state["deployment_stages"] = [ + {"stage": 1, "dir": "concept/infra/terraform/stage-1-foundation", "services": []} + ] + + namespaces = session._extract_providers_from_files() + assert "Microsoft.KeyVault" in namespaces + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") + def test_no_files_returns_empty(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): + session = _make_session(deploy_context, deploy_registry) + session._deploy_state._state["deployment_stages"] = [] + namespaces = session._extract_providers_from_files() + assert namespaces == set() diff --git a/tests/stages/test_design_stage.py b/tests/stages/test_design_stage.py new file mode 100644 index 0000000..236693d --- /dev/null +++ b/tests/stages/test_design_stage.py @@ -0,0 +1,513 @@ +"""Tests for design_stage.py — branch coverage for artifact change detection, +skip-discovery flow, heading extraction, summary generation, template matching, +and format_section_elapsed. +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from azext_prototype.agents.base import AgentCapability, AgentContext + +# ------------------------------------------------------------------ +# Fixtures +# ------------------------------------------------------------------ + + +@pytest.fixture +def design_context(project_with_config, sample_config): + provider = MagicMock() + provider.provider_name = "github-models" + provider.default_model = "gpt-4o" + provider.chat.return_value = MagicMock( + content="## Solution Overview\nSample design output.", + model="test", + usage={"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300}, + ) + return AgentContext( + project_config=sample_config, + project_dir=str(project_with_config), + ai_provider=provider, + ) + + +@pytest.fixture +def design_registry(): + registry = MagicMock() + + mock_architect = MagicMock() + mock_architect.name = "cloud-architect" + mock_architect.execute = MagicMock( + return_value=MagicMock( + content='```json\n[{"name": "Solution Overview", "context": "overview"}]\n```', + model="test", + usage={"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300}, + ) + ) + + mock_biz = MagicMock() + mock_biz.name = "biz-analyst" + mock_biz.get_system_messages.return_value = [] + mock_biz._temperature = 0.7 + mock_biz._max_tokens = 4096 + + mock_tf = MagicMock() + mock_tf.name = "terraform-agent" + mock_tf.execute = MagicMock( + return_value=MagicMock( + content="Terraform feasibility confirmed.", + model="test", + usage={"prompt_tokens": 50, "completion_tokens": 100, "total_tokens": 150}, + ) + ) + + def find_by_cap(cap): + mapping = { + AgentCapability.ARCHITECT: [mock_architect], + AgentCapability.BIZ_ANALYSIS: [mock_biz], + AgentCapability.TERRAFORM: [mock_tf], + AgentCapability.BICEP: [], + AgentCapability.QA: [], + } + return mapping.get(cap, []) + + registry.find_by_capability.side_effect = find_by_cap + return registry + + +# ------------------------------------------------------------------ +# _format_section_elapsed +# ------------------------------------------------------------------ + + +class TestFormatSectionElapsed: + def test_seconds_under_60(self): + from azext_prototype.stages.design_stage import _format_section_elapsed + + assert _format_section_elapsed(5.0) == "5s" + assert _format_section_elapsed(45.7) == "46s" + + def test_seconds_over_60(self): + from azext_prototype.stages.design_stage import _format_section_elapsed + + assert _format_section_elapsed(64.0) == "1m04s" + assert _format_section_elapsed(125.0) == "2m05s" + + def test_exactly_60(self): + from azext_prototype.stages.design_stage import _format_section_elapsed + + assert _format_section_elapsed(60.0) == "1m00s" + + +# ------------------------------------------------------------------ +# _extract_new_sections +# ------------------------------------------------------------------ + + +class TestExtractNewSections: + def test_valid_section_marker(self): + from azext_prototype.stages.design_stage import _extract_new_sections + + content = 'Some text [NEW_SECTION: {"name": "Security", "context": "auth details"}] more text' + result = _extract_new_sections(content) + assert len(result) == 1 + assert result[0]["name"] == "Security" + assert result[0]["context"] == "auth details" + + def test_defaults_context(self): + from azext_prototype.stages.design_stage import _extract_new_sections + + content = '[NEW_SECTION: {"name": "Foo"}]' + result = _extract_new_sections(content) + assert len(result) == 1 + assert result[0]["context"] == "" + + def test_invalid_json_skipped(self): + from azext_prototype.stages.design_stage import _extract_new_sections + + content = "[NEW_SECTION: {bad json}]" + assert _extract_new_sections(content) == [] + + def test_missing_name_skipped(self): + from azext_prototype.stages.design_stage import _extract_new_sections + + content = '[NEW_SECTION: {"context": "only context"}]' + assert _extract_new_sections(content) == [] + + def test_multiple_markers(self): + from azext_prototype.stages.design_stage import _extract_new_sections + + content = '[NEW_SECTION: {"name": "A"}] middle ' '[NEW_SECTION: {"name": "B", "context": "ctx"}]' + result = _extract_new_sections(content) + assert len(result) == 2 + assert result[0]["name"] == "A" + assert result[1]["name"] == "B" + + +# ------------------------------------------------------------------ +# DesignStage — guards +# ------------------------------------------------------------------ + + +class TestDesignStageGuards: + def test_get_guards_returns_one_guard(self): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + guards = stage.get_guards() + assert len(guards) == 1 + assert guards[0].name == "project_initialized" + + def test_design_is_reentrant(self): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + assert stage.reentrant is True + + +# ------------------------------------------------------------------ +# _load_design_state / _save_design_state +# ------------------------------------------------------------------ + + +class TestDesignStatePersistence: + def test_load_returns_fresh_state_when_no_file(self, tmp_project): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + state = stage._load_design_state(str(tmp_project), reset=False) + assert state["architecture"] is None + assert state["artifacts"] == [] + assert state["iteration"] == 0 + + def test_load_with_reset_clears_existing(self, tmp_project): + from azext_prototype.stages.design_stage import DesignStage + + state_file = tmp_project / ".prototype" / "state" / "design.json" + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text( + json.dumps({"architecture": "existing", "artifacts": [], "iteration": 3}), + encoding="utf-8", + ) + + stage = DesignStage() + state = stage._load_design_state(str(tmp_project), reset=True) + assert state["architecture"] is None + assert state["iteration"] == 0 + + def test_load_existing_state(self, tmp_project): + from azext_prototype.stages.design_stage import DesignStage + + state_file = tmp_project / ".prototype" / "state" / "design.json" + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text( + json.dumps({"architecture": "arch", "artifacts": [{"path": "/foo"}], "iteration": 2}), + encoding="utf-8", + ) + + stage = DesignStage() + state = stage._load_design_state(str(tmp_project), reset=False) + assert state["architecture"] == "arch" + assert state["iteration"] == 2 + + def test_save_and_reload(self, tmp_project): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + state = {"architecture": "test-arch", "artifacts": [], "iteration": 1} + stage._save_design_state(str(tmp_project), state) + + reloaded = stage._load_design_state(str(tmp_project), reset=False) + assert reloaded["architecture"] == "test-arch" + + +# ------------------------------------------------------------------ +# _write_architecture_docs +# ------------------------------------------------------------------ + + +class TestWriteArchitectureDocs: + def test_writes_architecture_md(self, tmp_project): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + stage._write_architecture_docs(str(tmp_project), "# My Architecture\nSome content") + + arch_file = tmp_project / "concept" / "docs" / "ARCHITECTURE.md" + assert arch_file.exists() + content = arch_file.read_text() + assert "My Architecture" in content + + +# ------------------------------------------------------------------ +# _compute_artifact_hashes +# ------------------------------------------------------------------ + + +class TestArtifactHashes: + def test_computes_hashes_for_text_files(self, tmp_project): + from azext_prototype.stages.design_stage import DesignStage + + docs_dir = tmp_project / "concept" / "docs" + docs_dir.mkdir(parents=True, exist_ok=True) + (docs_dir / "spec.txt").write_text("hello", encoding="utf-8") + + stage = DesignStage() + hashes = stage._compute_artifact_hashes(str(docs_dir)) + assert len(hashes) >= 1 + # Hash should be a hex string + for path, h in hashes.items(): + assert len(h) == 64 # SHA-256 hex + + def test_nonexistent_path_returns_empty(self, tmp_project): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + hashes = stage._compute_artifact_hashes(str(tmp_project / "nonexistent")) + assert hashes == {} + + +# ------------------------------------------------------------------ +# skip-discovery flow +# ------------------------------------------------------------------ + + +class TestSkipDiscovery: + def test_skip_discovery_without_state_raises(self, design_context, design_registry): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + + with pytest.raises(Exception): + stage.execute( + design_context, + design_registry, + skip_discovery=True, + input_fn=lambda p: "", + print_fn=lambda m: None, + ) + + def test_skip_discovery_with_existing_state(self, design_context, design_registry, project_with_config): + from azext_prototype.stages.design_stage import DesignStage + from azext_prototype.stages.discovery_state import DiscoveryState + + # Create discovery state + ds = DiscoveryState(str(project_with_config)) + ds.load() + ds.state["project"] = {"summary": "API backend"} + ds.state["confirmed_items"] = ["Use Container Apps"] + ds.state["_metadata"]["exchange_count"] = 3 + # Add conversation history so _extract_last_summary can find it + ds.state["conversation_history"] = [ + {"role": "assistant", "content": "## Requirements Summary\nBuild an API."}, + ] + ds.save() + + stage = DesignStage() + + # Mock the architect execution chain + mock_arch = design_registry.find_by_capability(AgentCapability.ARCHITECT)[0] + # First call: plan sections, Second+: generate sections + mock_arch.execute.side_effect = [ + MagicMock( + content='```json\n[{"name": "Overview", "context": "test"}]\n```', + model="test", + usage={}, + ), + MagicMock( + content="## Overview\nSample arch.", + model="test", + usage={}, + ), + # IaC review + MagicMock(content="Terraform ok", model="test", usage={}), + ] + + result = stage.execute( + design_context, + design_registry, + skip_discovery=True, + input_fn=lambda p: "", + print_fn=lambda m: None, + ) + assert result["status"] == "success" + + +# ------------------------------------------------------------------ +# _refine_architecture_loop +# ------------------------------------------------------------------ + + +class TestRefineArchitectureLoop: + def test_empty_feedback_exits(self, design_context, design_registry): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + mock_architect = design_registry.find_by_capability(AgentCapability.ARCHITECT)[0] + + design_state = {"architecture": "# Arch\nContent", "iteration": 1} + + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(design_context.project_dir) + config.load() + + with patch("builtins.input", return_value=""): + result = stage._refine_architecture_loop( + design_context, + mock_architect, + design_state, + config, + ) + + assert result == "# Arch\nContent" + + def test_accept_keyword_exits(self, design_context, design_registry): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + mock_architect = design_registry.find_by_capability(AgentCapability.ARCHITECT)[0] + + design_state = {"architecture": "# Arch", "iteration": 1} + + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(design_context.project_dir) + config.load() + + with patch("builtins.input", return_value="done"): + result = stage._refine_architecture_loop( + design_context, + mock_architect, + design_state, + config, + ) + assert result == "# Arch" + + +# ------------------------------------------------------------------ +# _execute_with_prompt_trim +# ------------------------------------------------------------------ + + +class TestExecuteWithPromptTrim: + def test_normal_execution_passes_through(self): + from azext_prototype.stages.design_stage import DesignStage + + architect = MagicMock() + architect.execute.return_value = MagicMock(content="result") + ctx = MagicMock() + + result = DesignStage._execute_with_prompt_trim(architect, ctx, "prompt", []) + assert result.content == "result" + + def test_prompt_too_large_with_accumulated_retries(self): + from azext_prototype.ai.copilot_provider import CopilotPromptTooLargeError + from azext_prototype.stages.design_stage import DesignStage + + architect = MagicMock() + # First call raises, second succeeds + architect.execute.side_effect = [ + CopilotPromptTooLargeError("Prompt too large", token_count=200000, token_limit=100000), + MagicMock(content="trimmed result"), + ] + ctx = MagicMock() + + prompt = "Intro\n## Architecture So Far\nfull content\n\n## Instructions\nGenerate code" + accumulated = ["## Section 1\nContent 1", "## Section 2\nContent 2"] + + result = DesignStage._execute_with_prompt_trim(architect, ctx, prompt, accumulated) + assert result.content == "trimmed result" + + def test_prompt_too_large_no_accumulated_reraises(self): + from azext_prototype.stages.design_stage import DesignStage + + architect = MagicMock() + # When accumulated is empty and prompt lacks ## Architecture So Far, + # the code hits bare `raise` outside exception context → RuntimeError + from azext_prototype.ai.copilot_provider import CopilotPromptTooLargeError + + architect.execute.side_effect = CopilotPromptTooLargeError( + "Prompt too large", token_count=200000, token_limit=100000 + ) + ctx = MagicMock() + + with pytest.raises(RuntimeError): + DesignStage._execute_with_prompt_trim(architect, ctx, "prompt without marker", []) + + +# ------------------------------------------------------------------ +# _plan_architecture fallback +# ------------------------------------------------------------------ + + +class TestPlanArchitecture: + def test_fallback_on_invalid_json(self, design_context, design_registry): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + mock_architect = design_registry.find_by_capability(AgentCapability.ARCHITECT)[0] + mock_architect.execute.return_value = MagicMock( + content="Not valid JSON at all", + model="test", + usage={}, + ) + + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(design_context.project_dir) + config.load() + + sections = stage._plan_architecture( + None, + design_context, + mock_architect, + config, + "requirements", + lambda m: None, + ) + # Should fall back to _DEFAULT_SECTIONS + assert len(sections) > 0 + assert sections[0]["name"] == "Solution Overview" + + +# ------------------------------------------------------------------ +# _run_iac_review +# ------------------------------------------------------------------ + + +class TestRunIacReview: + def test_no_iac_agent_skips_review(self, design_context): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + registry = MagicMock() + registry.find_by_capability.return_value = [] + + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(design_context.project_dir) + config.load() + + mock_architect = MagicMock() + # Should not raise — silently skips + stage._run_iac_review(design_context, registry, config, mock_architect, "design output") + + def test_iac_review_stores_artifact(self, design_context, design_registry): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(design_context.project_dir) + config.load() + + mock_architect = design_registry.find_by_capability(AgentCapability.ARCHITECT)[0] + + stage._run_iac_review(design_context, design_registry, config, mock_architect, "design output") + # Verify artifact was added + assert "iac_review" in design_context.artifacts diff --git a/tests/stages/test_discovery.py b/tests/stages/test_discovery.py new file mode 100644 index 0000000..0b9570e --- /dev/null +++ b/tests/stages/test_discovery.py @@ -0,0 +1,534 @@ +"""Tests for discovery.py — branch coverage for section header extraction, +slash command routing, opening message construction, vision content array +building, topic detection, context change handling, conversation state +management, parse_sections, and the main run loop. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from azext_prototype.agents.base import AgentCapability, AgentContext + +# ------------------------------------------------------------------ +# Fixtures +# ------------------------------------------------------------------ + + +@pytest.fixture +def discovery_context(project_with_config, sample_config): + provider = MagicMock() + provider.provider_name = "github-models" + provider.default_model = "gpt-4o" + provider.chat.return_value = MagicMock( + content="I understand. Let me ask some questions.", + model="test", + usage={"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300}, + ) + return AgentContext( + project_config=sample_config, + project_dir=str(project_with_config), + ai_provider=provider, + ) + + +@pytest.fixture +def discovery_registry(): + registry = MagicMock() + + mock_biz = MagicMock() + mock_biz.name = "biz-analyst" + mock_biz.get_system_messages.return_value = [] + mock_biz._temperature = 0.7 + mock_biz._max_tokens = 4096 + + mock_architect = MagicMock() + mock_architect.name = "cloud-architect" + + mock_qa = MagicMock() + mock_qa.name = "qa-engineer" + + def find_by_cap(cap): + mapping = { + AgentCapability.BIZ_ANALYSIS: [mock_biz], + AgentCapability.ARCHITECT: [mock_architect], + AgentCapability.QA: [mock_qa], + } + return mapping.get(cap, []) + + registry.find_by_capability.side_effect = find_by_cap + return registry + + +# ------------------------------------------------------------------ +# extract_section_headers +# ------------------------------------------------------------------ + + +class TestExtractSectionHeaders: + def test_extracts_h2_headings(self): + from azext_prototype.stages.discovery import extract_section_headers + + text = "## Authentication\nContent\n## Data Storage\nContent" + headers = extract_section_headers(text) + assert len(headers) == 2 + assert headers[0] == ("Authentication", 2) + assert headers[1] == ("Data Storage", 2) + + def test_filters_skip_headings(self): + from azext_prototype.stages.discovery import extract_section_headers + + text = "## Summary\nContent\n## Next Steps\nContent\n## Real Topic\nContent" + headers = extract_section_headers(text) + assert len(headers) == 1 + assert headers[0][0] == "Real Topic" + + def test_filters_short_headings(self): + from azext_prototype.stages.discovery import extract_section_headers + + text = "## AB\nContent\n## Long Enough\nContent" + headers = extract_section_headers(text) + assert len(headers) == 1 + assert headers[0][0] == "Long Enough" + + def test_deduplicates_headings(self): + from azext_prototype.stages.discovery import extract_section_headers + + text = "## Auth\nContent\n## Auth\nDuplicate" + headers = extract_section_headers(text) + assert len(headers) == 1 + + def test_bold_headings(self): + from azext_prototype.stages.discovery import extract_section_headers + + text = "**Authentication Model**\nContent\n**Data Layer**\nContent" + headers = extract_section_headers(text) + assert len(headers) == 2 + assert headers[0][0] == "Authentication Model" + + def test_h3_headings_excluded(self): + from azext_prototype.stages.discovery import extract_section_headers + + text = "## Top Level\nContent\n### Sub Section\nContent" + headers = extract_section_headers(text) + assert len(headers) == 1 + assert headers[0][0] == "Top Level" + + def test_empty_text(self): + from azext_prototype.stages.discovery import extract_section_headers + + assert extract_section_headers("") == [] + + def test_no_headings(self): + from azext_prototype.stages.discovery import extract_section_headers + + assert extract_section_headers("Just plain text with no headings.") == [] + + +# ------------------------------------------------------------------ +# parse_sections +# ------------------------------------------------------------------ + + +class TestParseSections: + def test_basic_sections(self): + from azext_prototype.stages.discovery import parse_sections + + text = "Intro text\n\n## Section A\nContent A\n\n## Section B\nContent B" + preamble, sections = parse_sections(text) + assert "Intro text" in preamble + assert len(sections) == 2 + assert sections[0].heading == "Section A" + assert sections[1].heading == "Section B" + + def test_no_sections_returns_full_text(self): + from azext_prototype.stages.discovery import parse_sections + + text = "Just a paragraph without headings." + preamble, sections = parse_sections(text) + assert preamble == text + assert sections == [] + + def test_skip_headings_filtered(self): + from azext_prototype.stages.discovery import parse_sections + + text = "## Summary\nSkip this\n## Real Section\nKeep this" + preamble, sections = parse_sections(text) + assert len(sections) == 1 + assert sections[0].heading == "Real Section" + + def test_task_id_generated(self): + from azext_prototype.stages.discovery import parse_sections + + text = "## Data Storage\nContent" + _, sections = parse_sections(text) + assert len(sections) == 1 + assert sections[0].task_id == "design-section-data-storage" + + def test_h3_folded_into_parent(self): + from azext_prototype.stages.discovery import parse_sections + + text = "## Parent\nP content\n### Child\nC content\n## Another\nA content" + _, sections = parse_sections(text) + assert len(sections) == 2 + assert "Child" in sections[0].content + assert sections[1].heading == "Another" + + def test_bold_heading_sections(self): + from azext_prototype.stages.discovery import parse_sections + + text = "Preamble\n\n**Security Model**\nSecurity content\n\n**Deployment**\nDeploy content" + preamble, sections = parse_sections(text) + assert len(sections) == 2 + assert sections[0].heading == "Security Model" + + def test_only_h3_returns_no_sections(self): + from azext_prototype.stages.discovery import parse_sections + + text = "### Sub Section\nContent" + preamble, sections = parse_sections(text) + assert sections == [] + assert "Sub Section" in preamble + + +# ------------------------------------------------------------------ +# _build_opening +# ------------------------------------------------------------------ + + +class TestBuildOpening: + def _make_session(self, ctx, registry): + from azext_prototype.stages.discovery import DiscoverySession + + return DiscoverySession(ctx, registry) + + def test_no_context_no_artifacts(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + opening = session._build_opening("", "", "") + assert isinstance(opening, str) + assert "Azure prototype" in opening + + def test_seed_context_only(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + opening = session._build_opening("Build an API", "", "") + assert "Build an API" in opening + + def test_artifacts_only(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + opening = session._build_opening("", "Requirements doc content", "") + assert "requirement documents" in opening.lower() + + def test_seed_and_artifacts(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + opening = session._build_opening("Build an API", "Doc content", "") + assert "Build an API" in opening + assert "Doc content" in opening + + def test_existing_context_included(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + opening = session._build_opening("New info", "", "Previous session learnings") + assert "Previous session learnings" in opening + assert "conflicts" in opening.lower() + + def test_images_produce_multimodal(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + images = [{"filename": "test.png", "data": "abc123", "mime": "image/png"}] + opening = session._build_opening("Context", "", "", images=images) + assert isinstance(opening, list) + assert opening[0]["type"] == "text" + assert opening[1]["type"] == "image_url" + assert "abc123" in opening[1]["image_url"]["url"] + + def test_no_context_with_existing_only(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + opening = session._build_opening("", "", "existing") + assert "existing" in opening + + def test_multiple_images(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + images = [ + {"filename": "a.png", "data": "aaa", "mime": "image/png"}, + {"filename": "b.jpg", "data": "bbb", "mime": "image/jpeg"}, + ] + opening = session._build_opening("", "artifacts", "", images=images) + assert isinstance(opening, list) + assert len(opening) == 3 # text + 2 images + + +# ------------------------------------------------------------------ +# DiscoveryResult +# ------------------------------------------------------------------ + + +class TestDiscoveryResult: + def test_default_not_cancelled(self): + from azext_prototype.stages.discovery import DiscoveryResult + + result = DiscoveryResult( + requirements="reqs", + conversation=[], + policy_overrides=[], + exchange_count=5, + ) + assert result.cancelled is False + assert result.exchange_count == 5 + + def test_cancelled_result(self): + from azext_prototype.stages.discovery import DiscoveryResult + + result = DiscoveryResult( + requirements="", + conversation=[], + policy_overrides=[], + exchange_count=0, + cancelled=True, + ) + assert result.cancelled is True + + +# ------------------------------------------------------------------ +# Session run — no biz-agent fallback +# ------------------------------------------------------------------ + + +class TestDiscoverySessionNoBizAgent: + def test_no_biz_agent_prompts_user(self, discovery_context): + from azext_prototype.stages.discovery import DiscoverySession + + registry = MagicMock() + registry.find_by_capability.return_value = [] + + session = DiscoverySession(discovery_context, registry) + result = session.run( + seed_context="test", + input_fn=lambda p: "my requirements", + print_fn=lambda m: None, + ) + assert result.requirements == "my requirements" + assert result.exchange_count == 0 + + def test_no_biz_agent_eof(self, discovery_context): + from azext_prototype.stages.discovery import DiscoverySession + + registry = MagicMock() + registry.find_by_capability.return_value = [] + + session = DiscoverySession(discovery_context, registry) + + def raise_eof(p): + raise EOFError + + result = session.run( + seed_context="", + input_fn=raise_eof, + print_fn=lambda m: None, + ) + assert result.requirements == "" + + +# ------------------------------------------------------------------ +# Session run — quit/done in main loop +# ------------------------------------------------------------------ + + +class TestDiscoverySessionMainLoop: + def _make_session(self, ctx, registry): + from azext_prototype.stages.discovery import DiscoverySession + + return DiscoverySession(ctx, registry) + + def test_quit_returns_cancelled(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + + # First response from AI has no sections (plain text), so we enter free-form loop + discovery_context.ai_provider.chat.return_value = MagicMock( + content="What would you like to build?", + model="test", + usage={"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300}, + ) + + inputs = iter(["quit"]) + result = session.run( + seed_context="Build an API", + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + assert result.cancelled is True + + def test_done_produces_summary(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + + call_count = [0] + + def mock_chat(messages, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + return MagicMock( + content="What would you like to build?", + model="test", + usage={"prompt_tokens": 50, "completion_tokens": 100, "total_tokens": 150}, + ) + # Summary call + return MagicMock( + content="## Requirements Summary\nBuild an API with auth.", + model="test", + usage={"prompt_tokens": 50, "completion_tokens": 100, "total_tokens": 150}, + ) + + discovery_context.ai_provider.chat.side_effect = mock_chat + + inputs = iter(["done"]) + result = session.run( + seed_context="Build an API", + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + assert result.cancelled is False + assert result.requirements # Should have summary text + + def test_eof_in_main_loop_ends_session(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + + discovery_context.ai_provider.chat.return_value = MagicMock( + content="Plain response no sections", + model="test", + usage={"prompt_tokens": 50, "completion_tokens": 100, "total_tokens": 150}, + ) + + def raise_eof(p): + raise EOFError + + result = session.run( + seed_context="test", + input_fn=raise_eof, + print_fn=lambda m: None, + ) + # Should produce a summary (not cancelled) + assert result is not None + + def test_slash_command_help(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + + discovery_context.ai_provider.chat.return_value = MagicMock( + content="What would you like to build?", + model="test", + usage={"prompt_tokens": 50, "completion_tokens": 100, "total_tokens": 150}, + ) + + inputs = iter(["/help", "done"]) + result = session.run( + seed_context="test", + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + assert result is not None + assert not result.cancelled + + +# ------------------------------------------------------------------ +# _chat — vision fallback +# ------------------------------------------------------------------ + + +class TestChatVisionFallback: + def test_vision_failure_degrades_to_text(self, discovery_context, discovery_registry): + from azext_prototype.stages.discovery import DiscoverySession + + session = DiscoverySession(discovery_context, discovery_registry) + + call_count = [0] + + def mock_chat(messages, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + raise Exception("Vision not supported") + return MagicMock( + content="Text fallback response", + model="test", + usage={"prompt_tokens": 50, "completion_tokens": 100, "total_tokens": 150}, + ) + + discovery_context.ai_provider.chat.side_effect = mock_chat + + content = [ + {"type": "text", "text": "Review these files"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ] + response = session._chat(content) + assert response == "Text fallback response" + # Should have fallen back to text-only + assert call_count[0] == 2 + + +# ------------------------------------------------------------------ +# _chat_lightweight +# ------------------------------------------------------------------ + + +class TestChatLightweight: + def test_returns_ai_content(self, discovery_context, discovery_registry): + from azext_prototype.stages.discovery import DiscoverySession + + session = DiscoverySession(discovery_context, discovery_registry) + + discovery_context.ai_provider.chat.return_value = MagicMock( + content="Lightweight response", + model="test", + usage={"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + ) + + result = session._chat_lightweight("classify this text") + assert result == "Lightweight response" + + def test_does_not_append_to_messages(self, discovery_context, discovery_registry): + from azext_prototype.stages.discovery import DiscoverySession + + session = DiscoverySession(discovery_context, discovery_registry) + + discovery_context.ai_provider.chat.return_value = MagicMock( + content="response", + model="test", + usage={}, + ) + + initial_len = len(session._messages) + session._chat_lightweight("test") + assert len(session._messages) == initial_len + + +# ------------------------------------------------------------------ +# _handle_incremental_context +# ------------------------------------------------------------------ + + +class TestHandleIncrementalContext: + def _make_session(self, ctx, registry): + from azext_prototype.stages.discovery import DiscoverySession + + return DiscoverySession(ctx, registry) + + def test_no_new_topics_records_decision(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + + discovery_context.ai_provider.chat.return_value = MagicMock( + content="[NO_NEW_TOPICS]", + model="test", + usage={}, + ) + + result = session._handle_incremental_context("Use Redis for caching", "", None, lambda m: None, False, None) + assert result is False + + def test_new_topics_added(self, discovery_context, discovery_registry): + session = self._make_session(discovery_context, discovery_registry) + + discovery_context.ai_provider.chat.return_value = MagicMock( + content="## Caching Strategy\nHow should Redis be configured?\n\n## Performance\nWhat SLA is needed?", + model="test", + usage={}, + ) + + result = session._handle_incremental_context("Add Redis caching", "", None, lambda m: None, False, None) + assert result is True From d6c94360ae578e8d719c97b2e80bc7ddb5fe28d7 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Mon, 6 Apr 2026 21:19:24 -0400 Subject: [PATCH 05/12] =?UTF-8?q?build=5Fsession.py=20coverage:=2077%=20?= =?UTF-8?q?=E2=86=92=2088%=20with=20124=20new=20tests=20(216=20total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: policy regen path, review loop, fallback deployment plan, PE stage injection, diff architectures, plan adjustment, transforms debug logging, QA remediation writeback, stage advisory, execute with retry/continuation, slash commands, DNS zone notes, deployment plan derivation, design change branches, output key extraction, affected stage identification, file content collection. 3787 tests passing. All 5 target files now above 85%. --- tests/stages/test_build_session.py | 2317 ++++++++++++++++++++++++++++ 1 file changed, 2317 insertions(+) diff --git a/tests/stages/test_build_session.py b/tests/stages/test_build_session.py index 98711ef..920651c 100644 --- a/tests/stages/test_build_session.py +++ b/tests/stages/test_build_session.py @@ -15,6 +15,7 @@ - generated/accepted → skipped (not in stages_to_process) """ +import json from pathlib import Path from unittest.mock import MagicMock, patch @@ -1128,3 +1129,2319 @@ def test_app_layer_python_detected(self): } result = BuildSession._get_app_scaffolding_requirements(stage) assert "Python" in result or "requirements.txt" in result + + +# ------------------------------------------------------------------ +# Naming strategy fallback (lines 244-246) +# ------------------------------------------------------------------ + + +class TestNamingStrategyFallback: + """Tests for naming strategy graceful fallback when config is bad.""" + + def test_naming_fallback_on_bad_config(self, project_with_design, sample_config): + """When create_naming_strategy raises, session falls back to simple strategy.""" + from azext_prototype.stages.build_session import BuildSession + + provider = MagicMock() + provider.provider_name = "github-models" + provider.chat.return_value = MagicMock(content="ok", model="test", usage={}, finish_reason="stop") + ctx = AgentContext( + project_config=sample_config, + project_dir=str(project_with_design), + ai_provider=provider, + ) + + registry = MagicMock() + registry.find_by_capability.return_value = [] + + # Corrupt the config so naming strategy fails on first try + with patch("azext_prototype.stages.build_session.create_naming_strategy") as mock_naming: + call_count = [0] + + def side_effect(cfg): + call_count[0] += 1 + if call_count[0] == 1: + raise ValueError("bad config") + # Second call is fallback + from azext_prototype.naming import create_naming_strategy as real_create + + return real_create(cfg) + + mock_naming.side_effect = side_effect + session = BuildSession(ctx, registry) + assert session._naming is not None + + +# ------------------------------------------------------------------ +# Policy resolver regeneration path (lines 685-722) +# ------------------------------------------------------------------ + + +class TestPolicyRegenPath: + """Tests for the policy resolver triggering regeneration.""" + + def test_policy_regen_executes_with_fix_instructions(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stage = _make_pending_stage(1, "Key Vault", layer="data", capability="data") + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + regen_response = MagicMock(content="```main.tf\nfixed\n```", model="test", usage={}) + fix_instructions = "\n## Fix\nFix the SKU" + + # Track whether the regen path was exercised + regen_called = [] + + def mock_check_and_resolve(*args, **kwargs): + # First call: needs regen; subsequent calls: no regen + if not regen_called: + regen_called.append(True) + return (["override sku"], True) + return ([], False) + + session._policy_resolver.check_and_resolve = mock_check_and_resolve + session._policy_resolver.build_fix_instructions = MagicMock(return_value=fix_instructions) + + with patch.object(session, "_build_stage_task", return_value=(MagicMock(name="tf"), "task")): + with patch.object(session, "_execute_with_retry", return_value=regen_response) as mock_retry: + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + session._run_stage_qa = lambda *a, **kw: True + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + assert len(regen_called) > 0, "Policy resolver should have triggered regeneration" + # The retry was called twice (original + regen) + assert mock_retry.call_count >= 2 + + def test_policy_regen_exception_routes_to_qa(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stage = _make_pending_stage(1, "Key Vault", layer="data", capability="data") + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + # First policy check triggers regen (needs_regen=True) + session._policy_resolver.check_and_resolve = MagicMock(return_value=(["issue"], True)) + session._policy_resolver.build_fix_instructions = MagicMock(return_value="\nfix") + + original_response = MagicMock(content="```main.tf\nok\n```", model="test", usage={}) + + with patch.object(session, "_build_stage_task", return_value=(MagicMock(name="tf"), "task")): + # First call: original generation; second call: regen throws + with patch.object(session, "_execute_with_retry", side_effect=[original_response, RuntimeError("boom")]): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + session._run_stage_qa = lambda *a, **kw: True + with patch("azext_prototype.stages.build_session.route_error_to_qa") as mock_qa_route: + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + # QA route was called for the regen exception + assert mock_qa_route.called + + def test_policy_regen_null_response_stops_build(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stage = _make_pending_stage(1, "Key Vault", layer="data", capability="data") + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + call_count = [0] + + def mock_check_and_resolve(*args, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + return ([], False) + return (["issue"], True) + + session._policy_resolver.check_and_resolve = mock_check_and_resolve + session._policy_resolver.build_fix_instructions = MagicMock(return_value="\nfix") + + original_response = MagicMock(content="```main.tf\nok\n```", model="test", usage={}) + + with patch.object(session, "_build_stage_task", return_value=(MagicMock(name="tf"), "task")): + with patch.object(session, "_execute_with_retry", side_effect=[original_response, None]): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + session._run_stage_qa = lambda *a, **kw: True + result = session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + # Build stopped because regen returned None + assert result is not None + + +# ------------------------------------------------------------------ +# Review loop / interactive rebuild (lines 863-918) +# ------------------------------------------------------------------ + + +class TestReviewLoop: + """Tests for the Phase 6 review loop.""" + + def test_review_loop_regenerates_affected_stage(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [{"name": "key-vault", "computed_name": "kv-test", "resource_type": "", "sku": ""}], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-key-vault", + "files": ["main.tf"], + } + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + regen_response = MagicMock(content="```main.tf\nfixed\n```", model="test", usage={}) + + inputs = iter(["Fix the key vault SKU", "done"]) + + session._identify_affected_stages = MagicMock(return_value=[1]) + + mock_agent = MagicMock() + mock_agent.name = "terraform-agent" + + with patch.object(session, "_build_stage_task", return_value=(mock_agent, "task")): + with patch.object(session, "_execute_with_continuation", return_value=regen_response): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + result = session.run( + design=design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + + assert result.review_accepted is True + # Stage should be marked accepted after done + final_stage = session._build_state._state["deployment_stages"][0] + assert final_stage["status"] == "accepted" + + def test_review_loop_no_affected_stages(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-key-vault", + "files": ["main.tf"], + } + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + printed = [] + inputs = iter(["something vague", "done"]) + + session._identify_affected_stages = MagicMock(return_value=[]) + + result = session.run( + design=design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: printed.append(m), + ) + + assert any("Could not determine" in msg for msg in printed) + assert result.review_accepted is True + + def test_review_loop_quit_cancels(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-key-vault", + "files": ["main.tf"], + } + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + result = session.run( + design=design, + input_fn=lambda p: "quit", + print_fn=lambda m: None, + ) + + assert result.cancelled is True + + def test_review_loop_slash_command(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-key-vault", + "files": ["main.tf"], + } + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + printed = [] + inputs = iter(["/help", "done"]) + + result = session.run( + design=design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: printed.append(m), + ) + + assert any("Available commands" in msg for msg in printed) + assert result.review_accepted is True + + def test_review_loop_eof_breaks(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-key-vault", + "files": ["main.tf"], + } + session._build_state.set_deployment_plan([stage]) + session._build_state.set_design_snapshot(design) + + def raise_eof(p): + raise EOFError() + + result = session.run( + design=design, + input_fn=raise_eof, + print_fn=lambda m: None, + ) + + assert result.review_accepted is True + + +# ------------------------------------------------------------------ +# Fallback deployment plan with templates (lines 1357-1430) +# ------------------------------------------------------------------ + + +class TestFallbackDeploymentPlan: + """Tests for _fallback_deployment_plan with template services.""" + + def test_fallback_with_templates(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + # Create mock templates with services + mock_svc_infra = MagicMock() + mock_svc_infra.name = "key-vault" + mock_svc_infra.type = "key-vault" + mock_svc_infra.tier = "Standard" + mock_svc_infra.config = {} + + mock_svc_data = MagicMock() + mock_svc_data.name = "cosmos-db" + mock_svc_data.type = "cosmos-db" + mock_svc_data.tier = "Serverless" + mock_svc_data.config = {} + + mock_svc_app = MagicMock() + mock_svc_app.name = "python-api" + mock_svc_app.type = "python-app" + mock_svc_app.tier = "Standard" + mock_svc_app.config = {} + + mock_template = MagicMock() + mock_template.name = "web-app" + mock_template.display_name = "Web Application" + mock_template.services = [mock_svc_infra, mock_svc_data, mock_svc_app] + + stages = session._fallback_deployment_plan([mock_template]) + + # Should have managed identity + infra + data + app + docs stages + assert len(stages) >= 5 + + # First stage should be Managed Identity + assert stages[0]["name"] == "Managed Identity" + assert stages[0]["layer"] == "core" + + # Last stage should be Documentation + assert stages[-1]["name"] == "Documentation" + assert stages[-1]["layer"] == "docs" + + # Infra stage for container-registry + infra_stages = [s for s in stages if s["layer"] == "infra"] + assert len(infra_stages) >= 1 + + # Data stage for cosmos-db + data_stages = [s for s in stages if s["layer"] == "data"] + assert len(data_stages) >= 1 + + # App stage for python-api + app_stages = [s for s in stages if s["layer"] == "app"] + assert len(app_stages) >= 1 + + def test_fallback_without_templates(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stages = session._fallback_deployment_plan([]) + + # Only managed identity + documentation + assert len(stages) == 2 + assert stages[0]["name"] == "Managed Identity" + assert stages[-1]["name"] == "Documentation" + + +# ------------------------------------------------------------------ +# Ensure private endpoint stage (lines 1470-1540) +# ------------------------------------------------------------------ + + +class TestEnsurePrivateEndpointStage: + """Tests for _ensure_private_endpoint_stage.""" + + def test_skips_when_network_stage_exists(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stages = [ + { + "stage": 1, + "name": "Networking", + "layer": "infra", + "services": [{"name": "virtual-network", "resource_type": "Microsoft.Network/virtualNetworks"}], + }, + { + "stage": 2, + "name": "Key Vault", + "layer": "data", + "services": [{"name": "key-vault", "resource_type": "Microsoft.KeyVault/vaults"}], + }, + ] + result = session._ensure_private_endpoint_stage(stages) + assert len(result) == 2 # No change + + def test_skips_when_service_has_network_resource(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stages = [ + { + "stage": 1, + "name": "Foundation", + "layer": "infra", + "services": [{"name": "vnet", "resource_type": "Microsoft.Network/virtualNetworks"}], + }, + ] + result = session._ensure_private_endpoint_stage(stages) + assert len(result) == 1 # No change + + def test_injects_networking_stage_when_pe_services_found(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stages = [ + { + "stage": 1, + "name": "Managed Identity", + "layer": "core", + "services": [], + "dir": "concept/infra/terraform/stage-1-managed-identity", + }, + { + "stage": 2, + "name": "Key Vault", + "layer": "data", + "services": [{"name": "key-vault", "resource_type": "Microsoft.KeyVault/vaults"}], + "dir": "concept/infra/terraform/stage-2-key-vault", + }, + ] + + mock_pe = MagicMock() + mock_pe.service_name = "key-vault" + + with patch( + "azext_prototype.stages.build_session.BuildSession._ensure_private_endpoint_stage", + wraps=session._ensure_private_endpoint_stage, + ): + with patch( + "azext_prototype.knowledge.resource_metadata.get_private_endpoint_services", + return_value=[mock_pe], + ): + result = session._ensure_private_endpoint_stage(stages) + + # Should have injected a networking stage + assert len(result) == 3 + net_stage = result[1] + assert net_stage["name"] == "Networking" + assert any("virtual-network" in s["name"] for s in net_stage["services"]) + + def test_no_injection_when_pe_services_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stages = [ + { + "stage": 1, + "name": "Managed Identity", + "layer": "core", + "services": [], + "dir": "concept/infra/terraform/stage-1-managed-identity", + }, + ] + + with patch( + "azext_prototype.knowledge.resource_metadata.get_private_endpoint_services", + return_value=[], + ): + result = session._ensure_private_endpoint_stage(stages) + + assert len(result) == 1 + + def test_exception_in_pe_lookup_returns_stages_unchanged(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stages = [ + { + "stage": 1, + "name": "Managed Identity", + "layer": "core", + "services": [], + "dir": "concept/infra/terraform/stage-1-managed-identity", + }, + ] + + with patch( + "azext_prototype.knowledge.resource_metadata.get_private_endpoint_services", + side_effect=ImportError("no module"), + ): + result = session._ensure_private_endpoint_stage(stages) + + assert len(result) == 1 + + +# ------------------------------------------------------------------ +# _diff_architectures / _parse_diff_result (lines 1590-1613, 1698-1719) +# ------------------------------------------------------------------ + + +class TestDiffArchitectures: + """Tests for architecture diffing and response parsing.""" + + def test_diff_returns_fallback_without_architect(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._architect_agent = None + + existing = [{"stage": 1, "name": "A"}, {"stage": 2, "name": "B"}] + result = session._diff_architectures("old", "new", existing) + + assert result["modified"] == [1, 2] + assert result["unchanged"] == [] + + def test_diff_parses_valid_response(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + diff_json = ( + '{"unchanged": [1], "modified": [2], "removed": [], ' + '"added": [], "plan_restructured": false, "summary": "Stage 2 modified."}' + ) + mock_response = MagicMock(content=diff_json, model="test", usage={}) + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = mock_response + session._architect_agent.name = "cloud-architect" + + existing = [{"stage": 1, "name": "A"}, {"stage": 2, "name": "B"}] + result = session._diff_architectures("old arch", "new arch", existing) + + assert 1 in result["unchanged"] + assert 2 in result["modified"] + + def test_diff_fallback_on_exception(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._architect_agent = MagicMock() + session._architect_agent.execute.side_effect = RuntimeError("boom") + session._architect_agent.name = "cloud-architect" + + existing = [{"stage": 1, "name": "A"}] + result = session._diff_architectures("old", "new", existing) + + assert result["modified"] == [1] + + def test_parse_diff_result_with_fenced_json(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + content = ( + '```json\n{"unchanged": [1], "modified": [2], "removed": [], ' + '"added": [], "plan_restructured": false, "summary": "ok"}\n```' + ) + existing = [{"stage": 1, "name": "A"}, {"stage": 2, "name": "B"}] + + result = session._parse_diff_result(content, existing) + assert result is not None + assert 1 in result["unchanged"] + assert 2 in result["modified"] + + def test_parse_diff_result_invalid_json(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._parse_diff_result("not json at all", []) + assert result is None + + def test_parse_diff_result_not_dict(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._parse_diff_result("[1, 2, 3]", []) + assert result is None + + def test_parse_diff_unmentioned_stages_default_unchanged(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + content = '{"unchanged": [], "modified": [2], "removed": []}' + existing = [{"stage": 1, "name": "A"}, {"stage": 2, "name": "B"}, {"stage": 3, "name": "C"}] + + result = session._parse_diff_result(content, existing) + assert result is not None + # Stage 1 and 3 not mentioned → unchanged + assert 1 in result["unchanged"] + assert 3 in result["unchanged"] + + +# ------------------------------------------------------------------ +# _adjust_plan (lines 1590-1613) +# ------------------------------------------------------------------ + + +class TestAdjustPlan: + """Tests for the _adjust_plan method.""" + + def test_adjust_returns_none_without_architect(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._architect_agent = None + + result = session._adjust_plan("add redis", "arch", []) + assert result is None + + def test_adjust_returns_none_without_ai_provider(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._context.ai_provider = None + + result = session._adjust_plan("add redis", "arch", []) + assert result is None + + def test_adjust_returns_parsed_plan(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + plan_json = ( + '```json\n{"stages": [{"stage": 1, "name": "Redis", "layer": "data", ' + '"capability": "data", "dir": "concept/infra/terraform/stage-1-redis", ' + '"services": [], "status": "pending", "files": []}]}\n```' + ) + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = MagicMock(content=plan_json, model="test", usage={}) + session._architect_agent.name = "cloud-architect" + + result = session._adjust_plan("add redis", "arch", []) + assert result is not None + assert len(result) >= 1 + + def test_adjust_returns_none_on_empty_response(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = MagicMock(content="", model="test", usage={}) + session._architect_agent.name = "cloud-architect" + + result = session._adjust_plan("add redis", "arch", []) + assert result is None + + +# ------------------------------------------------------------------ +# _apply_stage_transforms debug logging (lines 2698-2708) +# ------------------------------------------------------------------ + + +class TestApplyStageTransformsDebug: + """Tests for _apply_stage_transforms debug path.""" + + def test_transforms_no_changes(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + main_tf = stage_dir / "main.tf" + main_tf.write_text("resource {}", encoding="utf-8") + + stage = { + "stage": 1, + "name": "Test", + "services": [{"resource_type": "Microsoft.KeyVault/vaults"}], + } + + rel_path = str(main_tf.relative_to(project_dir)) + + with patch("azext_prototype.governance.transforms.apply", return_value=("resource {}", [])): + result = session._apply_stage_transforms(stage, [rel_path], lambda m: None) + + # Returns the same paths (no transforms applied) + assert result == [rel_path] + + def test_transforms_debug_log_assembles_files(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + main_tf = stage_dir / "main.tf" + main_tf.write_text('resource "test" {}', encoding="utf-8") + + stage = { + "stage": 1, + "name": "Test", + "services": [], + } + + rel_path = str(main_tf.relative_to(project_dir)) + + with patch("azext_prototype.governance.transforms.apply", return_value=('resource "test" {}', [])): + with patch("azext_prototype.debug_log.is_active", return_value=True): + with patch("azext_prototype.debug_log.log_flow") as mock_dbg: + result = session._apply_stage_transforms(stage, [rel_path], lambda m: None) + + assert result == [rel_path] + # Debug log should have been called with post-transform info + assert mock_dbg.called + + def test_transforms_empty_paths_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = {"stage": 1, "name": "Test", "services": []} + result = session._apply_stage_transforms(stage, [], lambda m: None) + assert result == [] + + +# ------------------------------------------------------------------ +# QA remediation write-back (lines 3309-3322, 3358-3411) +# ------------------------------------------------------------------ + + +class TestQaRemediationWriteBack: + """Tests for QA review retry logic including rate limit and timeout handling.""" + + def test_qa_rate_limit_retries(self, build_context, build_registry): + from azext_prototype.ai.copilot_provider import CopilotRateLimitError + + session = _make_session(build_context, build_registry) + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [], + "files": ["main.tf"], + } + + # Create file on disk + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1-key-vault" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource {}", encoding="utf-8") + stage["files"] = [str((stage_dir / "main.tf").relative_to(project_dir))] + + session._build_state.set_deployment_plan([stage]) + + qa_response = MagicMock(content="VERDICT: PASS", model="test", usage={}) + + call_count = [0] + + def mock_delegate(**kwargs): + call_count[0] += 1 + if call_count[0] == 1: + raise CopilotRateLimitError("rate limited", retry_after=1) + return qa_response + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as MockOrch: + mock_orch = MockOrch.return_value + mock_orch.delegate.side_effect = mock_delegate + with patch.object(session, "_countdown"): + passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) + + assert passed is True + assert call_count[0] >= 2 + + def test_qa_timeout_exhausts_retries(self, build_context, build_registry): + from azext_prototype.ai.copilot_provider import CopilotTimeoutError + + session = _make_session(build_context, build_registry) + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [], + "files": ["main.tf"], + } + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1-key-vault" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource {}", encoding="utf-8") + stage["files"] = [str((stage_dir / "main.tf").relative_to(project_dir))] + + session._build_state.set_deployment_plan([stage]) + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as MockOrch: + mock_orch = MockOrch.return_value + mock_orch.delegate.side_effect = CopilotTimeoutError("timeout") + with patch.object(session, "_countdown"): + passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) + + assert passed is False + + def test_qa_remediation_cycle(self, build_context, build_registry): + """QA finds issues, remediates, then passes.""" + session = _make_session(build_context, build_registry) + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [{"name": "key-vault", "resource_type": "Microsoft.KeyVault/vaults"}], + "files": ["main.tf"], + } + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1-key-vault" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource {}", encoding="utf-8") + stage["files"] = [str((stage_dir / "main.tf").relative_to(project_dir))] + + session._build_state.set_deployment_plan([stage]) + + call_count = [0] + + def mock_delegate(**kwargs): + call_count[0] += 1 + if call_count[0] == 1: + # First QA call: issues found + return MagicMock(content="VERDICT: FAIL\nCRITICAL: missing auth", model="test", usage={}) + # Second QA call: pass + return MagicMock(content="VERDICT: PASS", model="test", usage={}) + + regen_response = MagicMock(content="```main.tf\nfixed\n```", model="test", usage={}) + + mock_iac_agent = MagicMock() + mock_iac_agent.name = "terraform-agent" + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as MockOrch: + mock_orch = MockOrch.return_value + mock_orch.delegate.side_effect = mock_delegate + with patch.object(session, "_select_agent", return_value=mock_iac_agent): + with patch.object(session, "_build_stage_task", return_value=(mock_iac_agent, "task")): + with patch.object(session, "_execute_with_retry", return_value=regen_response): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) + + assert passed is True + assert call_count[0] >= 2 + + +# ------------------------------------------------------------------ +# _generate_stage_advisory (lines 3458-3503) +# ------------------------------------------------------------------ + + +class TestGenerateStageAdvisory: + """Tests for per-stage advisory generation.""" + + def test_advisory_returns_empty_without_advisor(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._advisor_agent = None + result = session._generate_stage_advisory({"stage": 1, "name": "Test", "files": ["a.tf"]}, lambda m: None) + assert result == "" + + def test_advisory_skips_docs_layer(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._advisor_agent = MagicMock() + result = session._generate_stage_advisory( + {"stage": 1, "name": "Docs", "layer": "docs", "files": ["README.md"]}, lambda m: None + ) + assert result == "" + + def test_advisory_skips_empty_files(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._advisor_agent = MagicMock() + result = session._generate_stage_advisory( + {"stage": 1, "name": "Test", "layer": "infra", "files": []}, lambda m: None + ) + assert result == "" + + def test_advisory_returns_content(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource {} {}", encoding="utf-8") + + rel_path = str((stage_dir / "main.tf").relative_to(project_dir)) + + session._advisor_agent = MagicMock() + session._advisor_agent.name = "advisor" + + advisory_text = "Consider upgrading to Premium SKU for production." + + with patch("azext_prototype.agents.orchestrator.AgentOrchestrator") as MockOrch: + mock_orch = MockOrch.return_value + mock_orch.delegate.return_value = MagicMock(content=advisory_text, model="test", usage={}) + result = session._generate_stage_advisory( + {"stage": 1, "name": "Key Vault", "layer": "data", "files": [rel_path]}, lambda m: None + ) + + assert result == advisory_text + + def test_advisory_handles_exception(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource {} {}", encoding="utf-8") + + rel_path = str((stage_dir / "main.tf").relative_to(project_dir)) + + session._advisor_agent = MagicMock() + session._advisor_agent.name = "advisor" + + with patch("azext_prototype.agents.orchestrator.AgentOrchestrator") as MockOrch: + mock_orch = MockOrch.return_value + mock_orch.delegate.side_effect = RuntimeError("boom") + result = session._generate_stage_advisory( + {"stage": 1, "name": "Key Vault", "layer": "data", "files": [rel_path]}, lambda m: None + ) + + assert result == "" + + +# ------------------------------------------------------------------ +# _execute_with_retry (lines 3537-3549) +# ------------------------------------------------------------------ + + +class TestExecuteWithRetry: + """Tests for _execute_with_retry timeout/rate-limit backoff.""" + + def test_retry_success_on_first_attempt(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + mock_agent = MagicMock() + mock_response = MagicMock(content="ok", model="test", usage={}) + + with patch.object(session, "_execute_with_continuation", return_value=mock_response): + result = session._execute_with_retry(mock_agent, "task", 1, "Stage", lambda m: None) + + assert result is mock_response + + def test_retry_on_rate_limit(self, build_context, build_registry): + from azext_prototype.ai.copilot_provider import CopilotRateLimitError + + session = _make_session(build_context, build_registry) + mock_agent = MagicMock() + mock_response = MagicMock(content="ok", model="test", usage={}) + + call_count = [0] + + def side_effect(*args, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + raise CopilotRateLimitError("limited", retry_after=1) + return mock_response + + with patch.object(session, "_execute_with_continuation", side_effect=side_effect): + with patch.object(session, "_countdown"): + result = session._execute_with_retry(mock_agent, "task", 1, "Stage", lambda m: None) + + assert result is mock_response + assert call_count[0] == 2 + + def test_retry_on_timeout_eventually_returns_none(self, build_context, build_registry): + from azext_prototype.ai.copilot_provider import CopilotTimeoutError + + session = _make_session(build_context, build_registry) + mock_agent = MagicMock() + + with patch.object(session, "_execute_with_continuation", side_effect=CopilotTimeoutError("timeout")): + with patch.object(session, "_countdown"): + printed = [] + result = session._execute_with_retry(mock_agent, "task", 1, "Stage", lambda m: printed.append(m)) + + assert result is None + assert any("timed out" in msg for msg in printed) + + def test_retry_rate_limit_uses_retry_after(self, build_context, build_registry): + from azext_prototype.ai.copilot_provider import CopilotRateLimitError + + session = _make_session(build_context, build_registry) + mock_agent = MagicMock() + mock_response = MagicMock(content="ok", model="test", usage={}) + + def side_effect(*args, **kwargs): + raise CopilotRateLimitError("limited", retry_after=42) + + countdown_calls = [] + + def mock_countdown(seconds, *a, **kw): + countdown_calls.append(seconds) + + call_count = [0] + + def exec_side(*args, **kwargs): + call_count[0] += 1 + if call_count[0] <= 2: + raise CopilotRateLimitError("limited", retry_after=42) + return mock_response + + with patch.object(session, "_execute_with_continuation", side_effect=exec_side): + with patch.object(session, "_countdown", side_effect=mock_countdown): + result = session._execute_with_retry(mock_agent, "task", 1, "Stage", lambda m: None) + + assert result is mock_response + # countdown should have been called with 42 (retry_after value) + assert 42 in countdown_calls + + +# ------------------------------------------------------------------ +# _execute_with_continuation (lines 3579-3610) +# ------------------------------------------------------------------ + + +class TestExecuteWithContinuation: + """Tests for truncation recovery via continuation.""" + + def test_no_continuation_on_stop(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + mock_agent = MagicMock() + response = MagicMock(content="full output", finish_reason="stop", model="test", usage={}) + mock_agent.execute.return_value = response + + result = session._execute_with_continuation(mock_agent, "task") + assert result.content == "full output" + assert mock_agent.execute.call_count == 1 + + def test_continuation_on_length(self, build_context, build_registry): + from azext_prototype.ai.provider import AIResponse + + session = _make_session(build_context, build_registry) + mock_agent = MagicMock() + + first_response = AIResponse( + content="partial", + model="test", + usage={"prompt_tokens": 100, "completion_tokens": 200}, + finish_reason="length", + ) + second_response = AIResponse( + content=" continued", + model="test", + usage={"prompt_tokens": 50, "completion_tokens": 100}, + finish_reason="stop", + ) + mock_agent.execute.side_effect = [first_response, second_response] + + result = session._execute_with_continuation(mock_agent, "task") + assert result.content == "partial continued" + assert result.finish_reason == "stop" + assert mock_agent.execute.call_count == 2 + # Token usage should be merged + assert result.usage["prompt_tokens"] == 150 + assert result.usage["completion_tokens"] == 300 + + def test_continuation_with_stage_context(self, build_context, build_registry): + from azext_prototype.ai.provider import AIResponse + + session = _make_session(build_context, build_registry) + mock_agent = MagicMock() + + first_response = AIResponse( + content="partial code", + model="test", + usage={"prompt_tokens": 100}, + finish_reason="length", + ) + second_response = AIResponse( + content=" rest of code", + model="test", + usage={"prompt_tokens": 50}, + finish_reason="stop", + ) + mock_agent.execute.side_effect = [first_response, second_response] + + result = session._execute_with_continuation( + mock_agent, "task", stage_num=3, stage_name="Key Vault", stage_capability="data" + ) + assert result.content == "partial code rest of code" + # Conversation history should have continuation messages + assert len(session._context.conversation_history) >= 2 + + def test_continuation_max_limit(self, build_context, build_registry): + from azext_prototype.ai.provider import AIResponse + + session = _make_session(build_context, build_registry) + mock_agent = MagicMock() + + # All responses truncated + truncated = AIResponse( + content="chunk", + model="test", + usage={"prompt_tokens": 10}, + finish_reason="length", + ) + mock_agent.execute.return_value = truncated + + result = session._execute_with_continuation(mock_agent, "task", max_continuations=2) + # 1 original + 2 continuations = 3 calls + assert mock_agent.execute.call_count == 3 + # Content should be accumulated + assert "chunk" in result.content + + def test_continuation_none_response_breaks(self, build_context, build_registry): + from azext_prototype.ai.provider import AIResponse + + session = _make_session(build_context, build_registry) + mock_agent = MagicMock() + + first_response = AIResponse( + content="partial", + model="test", + usage={"prompt_tokens": 100}, + finish_reason="length", + ) + mock_agent.execute.side_effect = [first_response, None] + + result = session._execute_with_continuation(mock_agent, "task") + assert result.content == "partial" + + +# ------------------------------------------------------------------ +# _collect_generated_file_content (lines 3413-3439) +# ------------------------------------------------------------------ + + +class TestCollectGeneratedFileContent: + """Tests for collecting generated file content for QA.""" + + def test_collects_existing_files(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource {}", encoding="utf-8") + + rel_path = str((stage_dir / "main.tf").relative_to(project_dir)) + + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Test", "layer": "infra", "status": "generated", "files": [rel_path]}, + ] + + content = session._collect_generated_file_content() + assert "resource {}" in content + assert "Stage 1: Test" in content + + def test_handles_missing_files(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + session._build_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Test", + "layer": "infra", + "status": "generated", + "files": ["nonexistent/main.tf"], + }, + ] + + content = session._collect_generated_file_content() + assert "(could not read file)" in content + + def test_skips_stages_without_files(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Test", "layer": "infra", "status": "generated", "files": []}, + ] + + content = session._collect_generated_file_content() + assert content == "" + + +# ------------------------------------------------------------------ +# _categorize_service (static method) — additional coverage +# ------------------------------------------------------------------ + + +class TestCategorizeServiceExtended: + """Extended tests for service type categorization.""" + + def test_infra_types(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._categorize_service("key-vault") == "infra" + assert BuildSession._categorize_service("virtual-network") == "infra" + assert BuildSession._categorize_service("managed-identity") == "infra" + assert BuildSession._categorize_service("application-insights") == "infra" + + def test_data_types(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._categorize_service("cosmos-db") == "data" + assert BuildSession._categorize_service("sql-database") == "data" + assert BuildSession._categorize_service("redis-cache") == "data" + assert BuildSession._categorize_service("storage-account") == "data" + + def test_app_type_fallback(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._categorize_service("python-app") == "app" + assert BuildSession._categorize_service("container-registry") == "app" + + +# ------------------------------------------------------------------ +# _parse_deployment_plan (lines 1235-1236) +# ------------------------------------------------------------------ + + +class TestParseDeploymentPlanExtended: + """Extended tests for deployment plan JSON parsing.""" + + def test_parse_fenced_json(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + content = ( + '```json\n{"stages": [{"stage": 1, "name": "Test", ' + '"dir": "concept/infra/terraform/stage-1", "services": [], ' + '"capability": "infra"}]}\n```' + ) + result = session._parse_deployment_plan(content) + assert len(result) >= 1 + assert result[0]["name"] == "Test" + + def test_parse_raw_json(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + content = ( + '{"stages": [{"stage": 1, "name": "Test", ' + '"dir": "concept/infra/terraform/stage-1", "services": [], ' + '"capability": "infra"}]}' + ) + result = session._parse_deployment_plan(content) + assert len(result) >= 1 + + def test_parse_invalid_json_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._parse_deployment_plan("not json at all") + assert result == [] + + def test_parse_fenced_bad_json_falls_back(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + content = "```json\n{bad json}\n```" + result = session._parse_deployment_plan(content) + assert result == [] + + +# ------------------------------------------------------------------ +# _build_docs_context (lines 3017-3045) +# ------------------------------------------------------------------ + + +class TestBuildDocsContext: + """Tests for documentation context builder.""" + + def test_returns_empty_when_no_generated(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "status": "pending", "files": []}, + ] + assert session._build_docs_context() == "" + + def test_includes_output_keys(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + outputs_tf = stage_dir / "outputs.tf" + outputs_tf.write_text( + 'output "vault_id" {\n description = "Key Vault ID"\n value = azapi_resource.vault.id\n}\n', + encoding="utf-8", + ) + + rel_path = str(outputs_tf.relative_to(project_dir)) + + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Key Vault", "status": "generated", "files": [rel_path], "layer": "data"}, + ] + + result = session._build_docs_context() + assert "vault_id" in result + assert "Key Vault ID" in result + + def test_lists_files_when_no_outputs(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + main_tf = stage_dir / "main.tf" + main_tf.write_text("resource {}", encoding="utf-8") + + rel_path = str(main_tf.relative_to(project_dir)) + + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Test", "status": "generated", "files": [rel_path], "layer": "infra"}, + ] + + result = session._build_docs_context() + assert "main.tf" in result + + +# ------------------------------------------------------------------ +# _build_dns_zone_note (lines 3062-3085) +# ------------------------------------------------------------------ + + +class TestBuildDnsZoneNote: + """Tests for DNS zone note generation.""" + + def test_returns_empty_when_no_zones(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Test", "services": []}, + ] + + with patch( + "azext_prototype.knowledge.private_dns_zones.get_zones_for_services", + return_value={}, + ): + result = session._build_dns_zone_note() + assert result == "" + + def test_returns_zones_when_found(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Test", "services": [{"name": "kv"}]}, + ] + + zones = {"privatelink.vaultcore.azure.net": "Microsoft.KeyVault/vaults"} + + with patch( + "azext_prototype.knowledge.private_dns_zones.get_zones_for_services", + return_value=zones, + ): + result = session._build_dns_zone_note() + + assert "privatelink.vaultcore.azure.net" in result + assert "REQUIRED PRIVATE DNS ZONES" in result + + def test_exception_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Test", "services": []}, + ] + + with patch( + "azext_prototype.knowledge.private_dns_zones.get_zones_for_services", + side_effect=ImportError("boom"), + ): + result = session._build_dns_zone_note() + assert result == "" + + +# ------------------------------------------------------------------ +# _get_networking_stage_note (lines 3092-3096) +# ------------------------------------------------------------------ + + +class TestGetNetworkingStageNote: + """Tests for networking stage QA note generation.""" + + def test_returns_note_when_networking_stage_has_pe_services(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + { + "stage": 2, + "name": "Networking", + "services": [ + {"name": "virtual-network"}, + {"name": "private-endpoint-keyvault"}, + ], + }, + ] + + result = session._get_networking_stage_note() + assert "CRITICAL: Networking Stage" in result + assert "private-endpoint-keyvault" in result + + def test_returns_empty_when_no_networking_stage(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Key Vault", "services": []}, + ] + + result = session._get_networking_stage_note() + assert result == "" + + def test_returns_empty_when_networking_has_no_pe_services(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + { + "stage": 2, + "name": "Networking", + "services": [{"name": "virtual-network"}], + }, + ] + + result = session._get_networking_stage_note() + assert result == "" + + +# ------------------------------------------------------------------ +# _extract_output_keys (lines 2969-2979) +# ------------------------------------------------------------------ + + +class TestExtractOutputKeys: + """Tests for extracting output key names from stage files.""" + + def test_extracts_terraform_output_keys(self, tmp_path): + from azext_prototype.stages.build_session import BuildSession + + outputs_file = tmp_path / "concept" / "infra" / "terraform" / "stage-1" / "outputs.tf" + outputs_file.parent.mkdir(parents=True, exist_ok=True) + outputs_file.write_text( + 'output "vault_id" {\n value = azapi_resource.vault.id\n}\n' + 'output "vault_uri" {\n value = azapi_resource.vault.properties.vaultUri\n}\n', + encoding="utf-8", + ) + + rel_path = str(outputs_file.relative_to(tmp_path)) + stage = {"files": [rel_path]} + + keys = BuildSession._extract_output_keys(stage, tmp_path) + assert "vault_id" in keys + assert "vault_uri" in keys + + def test_returns_empty_when_no_outputs_file(self, tmp_path): + from azext_prototype.stages.build_session import BuildSession + + stage = {"files": ["main.tf"]} + keys = BuildSession._extract_output_keys(stage, tmp_path) + assert keys == [] + + +# ------------------------------------------------------------------ +# Design change branch B (lines 356-379) +# ------------------------------------------------------------------ + + +class TestDesignChangeBranchB: + """Tests for re-entry when design has changed.""" + + def test_design_changed_restructured_quit(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "New arch"} + + session._build_state.set_deployment_plan([_make_pending_stage(1, "Test")]) + session._build_state.set_design_snapshot({"architecture": "Old arch"}) + + # Simulate design changed + session._build_state.design_has_changed = MagicMock(return_value=True) + session._build_state.get_previous_architecture = MagicMock(return_value="Old arch") + + diff_result = { + "unchanged": [], + "modified": [], + "removed": [], + "added": [], + "plan_restructured": True, + "summary": "Big changes.", + } + + with patch.object(session, "_diff_architectures", return_value=diff_result): + result = session.run( + design=design, + input_fn=lambda p: "quit", + print_fn=lambda m: None, + ) + + assert result.cancelled is True + + def test_design_changed_targeted_updates(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "New arch with redis"} + + stages = [ + _make_pending_stage(1, "Key Vault", layer="data", capability="data"), + _make_pending_stage(2, "Redis", layer="data", capability="data"), + ] + session._build_state.set_deployment_plan(stages) + session._build_state.set_design_snapshot({"architecture": "Old arch"}) + + session._build_state.design_has_changed = MagicMock(return_value=True) + session._build_state.get_previous_architecture = MagicMock(return_value="Old arch") + + diff_result = { + "unchanged": [1], + "modified": [2], + "removed": [], + "added": [], + "plan_restructured": False, + "summary": "Stage 2 modified.", + } + + with patch.object(session, "_diff_architectures", return_value=diff_result): + session._run_stage_qa = lambda *a, **kw: True + with patch.object(session, "_build_stage_task", return_value=(MagicMock(name="tf"), "task")): + with patch.object( + session, "_execute_with_retry", return_value=MagicMock(content="```main.tf\nok\n```") + ): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + result = session.run( + design=design, + input_fn=lambda p: "done", + print_fn=lambda m: None, + ) + + assert result is not None + + +# ------------------------------------------------------------------ +# _resolve_service_policies / _resolve_api_versions (lines 3190-3212) +# ------------------------------------------------------------------ + + +class TestResolveHelpers: + """Tests for service policy and API version resolution helpers.""" + + def test_resolve_service_policies_exception(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + with patch( + "azext_prototype.governance.policies.PolicyEngine.load", + side_effect=Exception("boom"), + ): + result = session._resolve_service_policies([{"name": "kv"}]) + assert result == "" + + def test_resolve_api_versions_exception(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + with patch( + "azext_prototype.knowledge.resource_metadata.resolve_resource_metadata", + side_effect=ImportError("boom"), + ): + result = session._resolve_api_versions([{"resource_type": "Microsoft.KeyVault/vaults"}]) + assert result == "" + + def test_resolve_api_versions_no_resource_types(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._resolve_api_versions([{"name": "kv"}]) + assert result == "" + + def test_resolve_companion_requirements_exception(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + with patch( + "azext_prototype.knowledge.resource_metadata.resolve_companion_requirements", + side_effect=ImportError("boom"), + ): + result = session._resolve_companion_requirements([{"resource_type": "Microsoft.KeyVault/vaults"}]) + assert result == "" + + +# ------------------------------------------------------------------ +# _infer_layer (static method) +# ------------------------------------------------------------------ + + +class TestInferLayerExtended: + """Extended tests for layer inference from stage data.""" + + def test_explicit_layer_returned(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._infer_layer({"layer": "data", "name": "test"}) == "data" + + def test_identity_name_returns_core(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._infer_layer({"name": "Managed Identity", "capability": "infra"}) == "core" + + def test_monitoring_name_returns_core(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._infer_layer({"name": "Log Analytics", "capability": "infra"}) == "core" + + def test_capability_mapping(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._infer_layer({"name": "Redis", "capability": "data"}) == "data" + assert BuildSession._infer_layer({"name": "API", "capability": "app"}) == "app" + + +# ------------------------------------------------------------------ +# _enforce_concept_prefix +# ------------------------------------------------------------------ + + +class TestEnforceConceptPrefixExtended: + """Extended tests for concept prefix enforcement in dirs.""" + + def test_already_has_concept_prefix(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + assert session._enforce_concept_prefix("concept/infra/terraform") == "concept/infra/terraform" + + def test_fixes_wrong_prefix(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._enforce_concept_prefix("output/infra/terraform/stage-1") + assert result.startswith("concept/") + + def test_single_subdir(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + result = session._enforce_concept_prefix("infra") + assert result == "concept/infra" + + def test_empty_string(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + assert session._enforce_concept_prefix("") == "" + + +# ------------------------------------------------------------------ +# _clean_removed_stage_files (lines 1759-1769) +# ------------------------------------------------------------------ + + +class TestCleanRemovedStageFiles: + """Tests for removing stage directories on disk.""" + + def test_removes_existing_directory(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-2-redis" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource {}", encoding="utf-8") + + stages = [{"stage": 2, "dir": "concept/infra/terraform/stage-2-redis"}] + session._clean_removed_stage_files([2], stages) + + assert not stage_dir.exists() + + def test_ignores_nonexistent_directory(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stages = [{"stage": 3, "dir": "concept/infra/terraform/stage-3-nonexistent"}] + # Should not raise + session._clean_removed_stage_files([3], stages) + + +# ------------------------------------------------------------------ +# _fix_stage_dirs (lines 1771-1789) +# ------------------------------------------------------------------ + + +class TestFixStageDirs: + """Tests for post-renumber directory path fixing.""" + + def test_fix_renumbers_dirs(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "dir": "concept/infra/terraform/stage-3-redis"}, + ] + + session._fix_stage_dirs() + + assert session._build_state._state["deployment_stages"][0]["dir"] == ("concept/infra/terraform/stage-1-redis") + + def test_fix_no_change_when_correct(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "dir": "concept/infra/terraform/stage-1-redis"}, + ] + + session._fix_stage_dirs() + + assert session._build_state._state["deployment_stages"][0]["dir"] == ("concept/infra/terraform/stage-1-redis") + + +# ------------------------------------------------------------------ +# _identify_affected_stages / _identify_stages_regex / _identify_stages_via_architect +# ------------------------------------------------------------------ + + +class TestIdentifyAffectedStages: + """Tests for feedback-to-stage matching.""" + + def test_regex_explicit_stage_number(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Key Vault", "status": "generated", "services": [], "files": []}, + {"stage": 2, "name": "Redis", "status": "generated", "services": [], "files": []}, + ] + + result = session._identify_stages_regex("Please fix stage 2") + assert result == [2] + + def test_regex_stage_name_match(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Key Vault", "status": "generated", "services": [], "files": []}, + {"stage": 2, "name": "Redis", "status": "generated", "services": [], "files": []}, + ] + + result = session._identify_stages_regex("fix the key vault configuration") + assert result == [1] + + def test_regex_service_name_match(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Cache", + "status": "generated", + "services": [{"name": "redis-cache"}], + "files": [], + }, + ] + + result = session._identify_stages_regex("update the redis-cache settings") + assert result == [1] + + def test_regex_fallback_returns_generated_and_accepted(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Aaa", "status": "generated", "services": [], "files": []}, + {"stage": 2, "name": "Bbb", "status": "accepted", "services": [], "files": []}, + ] + + # Feedback doesn't match any stage name, service, or number + result = session._identify_stages_regex("xyz unrelated text 999") + # Last resort: returns all generated+accepted stages + assert 1 in result + assert 2 in result + + def test_identify_via_architect(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Key Vault", "status": "generated", "services": [], "files": []}, + {"stage": 2, "name": "Redis", "status": "generated", "services": [], "files": []}, + ] + + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = MagicMock(content="[2]", model="test", usage={}) + session._architect_agent.name = "cloud-architect" + + result = session._identify_stages_via_architect("fix the caching layer") + assert result == [2] + + def test_identify_via_architect_exception_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "A", "status": "generated", "services": [], "files": []}, + ] + + session._architect_agent = MagicMock() + session._architect_agent.execute.side_effect = RuntimeError("boom") + session._architect_agent.name = "cloud-architect" + + result = session._identify_stages_via_architect("fix something") + assert result == [] + + def test_identify_via_architect_empty_stages(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [] + + session._architect_agent = MagicMock() + + result = session._identify_stages_via_architect("fix something") + assert result == [] + + def test_identify_affected_uses_architect_then_regex(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Key Vault", "status": "generated", "services": [], "files": []}, + ] + + # Architect returns empty -> falls back to regex + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = MagicMock(content="[]", model="test", usage={}) + session._architect_agent.name = "cloud-architect" + + result = session._identify_affected_stages("fix the key vault") + assert result == [1] # Regex matched by name + + def test_parse_stage_numbers_valid(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._parse_stage_numbers("[1, 3, 5]") == [1, 3, 5] + + def test_parse_stage_numbers_embedded(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._parse_stage_numbers("The affected stages are: [2, 4]") == [2, 4] + + def test_parse_stage_numbers_invalid(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._parse_stage_numbers("no json here") == [] + + def test_parse_stage_numbers_bad_json(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._parse_stage_numbers("[not, valid]") == [] + + +# ------------------------------------------------------------------ +# _handle_slash_command / _handle_describe +# ------------------------------------------------------------------ + + +class TestHandleSlashCommand: + """Tests for slash command handling.""" + + def test_status_command(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + {"stage": 1, "name": "Test", "status": "generated", "files": []}, + ] + printed = [] + session._handle_slash_command("/status", printed.append) + assert len(printed) >= 1 + + def test_files_command(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + printed = [] + session._handle_slash_command("/files", printed.append) + assert len(printed) >= 1 + + def test_policy_command(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + printed = [] + session._handle_slash_command("/policy", printed.append) + assert len(printed) >= 1 + + def test_describe_command(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "status": "generated", + "dir": "concept/infra/terraform/stage-1-kv", + "services": [ + { + "name": "key-vault", + "computed_name": "kv-test", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "Standard", + } + ], + "files": ["main.tf", "outputs.tf"], + }, + ] + printed = [] + session._handle_slash_command("/describe 1", printed.append) + assert any("Key Vault" in msg for msg in printed) + assert any("Microsoft.KeyVault/vaults" in msg for msg in printed) + + def test_describe_no_arg(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + printed = [] + session._handle_describe("", printed.append) + assert any("Usage" in msg for msg in printed) + + def test_describe_nonexistent_stage(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [] + printed = [] + session._handle_describe("99", printed.append) + assert any("not found" in msg for msg in printed) + + def test_help_command(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + printed = [] + session._handle_slash_command("/help", printed.append) + assert any("/status" in msg for msg in printed) + + +# ------------------------------------------------------------------ +# _derive_deployment_plan -- two-phase plan derivation +# ------------------------------------------------------------------ + + +class TestDeriveDeploymentPlan: + """Tests for _derive_deployment_plan two-phase AI flow.""" + + def test_fallback_without_architect(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._architect_agent = None + + result = session._derive_deployment_plan("architecture text", []) + # Fallback plan always has at least identity + docs + assert len(result) >= 2 + assert result[0]["name"] == "Managed Identity" + assert result[-1]["name"] == "Documentation" + + def test_fallback_without_ai_provider(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._context.ai_provider = None + + result = session._derive_deployment_plan("architecture text", []) + assert len(result) >= 2 + + def test_fallback_on_empty_phase1_response(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = MagicMock(content="", model="test", usage={}) + session._architect_agent.name = "cloud-architect" + session._architect_agent.set_governor_brief = MagicMock() + + result = session._derive_deployment_plan("architecture text", []) + # Falls back + assert len(result) >= 2 + assert result[0]["name"] == "Managed Identity" + + def test_fallback_on_unparseable_phase1(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = MagicMock(content="not json", model="test", usage={}) + session._architect_agent.name = "cloud-architect" + session._architect_agent.set_governor_brief = MagicMock() + + result = session._derive_deployment_plan("architecture text", []) + assert len(result) >= 2 + + def test_successful_two_phase(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + # Phase 1: map + phase1_content = json.dumps( + { + "stages": [ + { + "stage": 1, + "name": "Managed Identity", + "layer": "core", + "capability": "infra", + "services": ["managed-identity"], + }, + {"stage": 2, "name": "Key Vault", "layer": "data", "capability": "data", "services": ["key-vault"]}, + {"stage": 3, "name": "Documentation", "layer": "docs", "capability": "docs", "services": []}, + ] + } + ) + phase1_json = f"```json\n{phase1_content}\n```" + + # Phase 2: detailed + phase2_content = json.dumps( + { + "stages": [ + { + "stage": 1, + "name": "Managed Identity", + "layer": "core", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1-managed-identity", + "services": [ + { + "name": "managed-identity", + "computed_name": "id-test", + "resource_type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "sku": "", + } + ], + "status": "pending", + "files": [], + }, + { + "stage": 2, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "dir": "concept/infra/terraform/stage-2-key-vault", + "services": [ + { + "name": "key-vault", + "computed_name": "kv-test", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "Standard", + } + ], + "status": "pending", + "files": [], + }, + { + "stage": 3, + "name": "Documentation", + "layer": "docs", + "capability": "docs", + "dir": "concept/docs", + "services": [], + "status": "pending", + "files": [], + }, + ] + } + ) + phase2_json = f"```json\n{phase2_content}\n```" + + session._architect_agent = MagicMock() + session._architect_agent.execute.side_effect = [ + MagicMock(content=phase1_json, model="test", usage={}), + MagicMock(content=phase2_json, model="test", usage={}), + ] + session._architect_agent.name = "cloud-architect" + session._architect_agent.set_governor_brief = MagicMock() + + result = session._derive_deployment_plan("Build a web app with key vault", []) + assert len(result) >= 3 + assert result[0]["name"] == "Managed Identity" + assert any(s["name"] == "Key Vault" for s in result) + + def test_fallback_on_empty_phase2(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + phase1_json = ( + '```json\n{"stages": [' + '{"stage": 1, "name": "Test", "layer": "core",' + ' "capability": "infra", "services": ["id"]}' + "]}\n```" + ) + + session._architect_agent = MagicMock() + session._architect_agent.execute.side_effect = [ + MagicMock(content=phase1_json, model="test", usage={}), + MagicMock(content="", model="test", usage={}), + ] + session._architect_agent.name = "cloud-architect" + session._architect_agent.set_governor_brief = MagicMock() + + result = session._derive_deployment_plan("architecture", []) + assert len(result) >= 2 + + def test_phase1_null_response(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = None + session._architect_agent.name = "cloud-architect" + session._architect_agent.set_governor_brief = MagicMock() + + result = session._derive_deployment_plan("architecture", []) + assert len(result) >= 2 + + +# ------------------------------------------------------------------ +# _build_stage_task extended coverage (lines 2146-2189) +# ------------------------------------------------------------------ + + +class TestBuildStageTaskExtended: + """Extended tests for _build_stage_task to cover cross-reference paths.""" + + def test_build_stage_task_with_templates(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "dir": "concept/infra/terraform/stage-1-kv", + "services": [ + { + "name": "key-vault", + "computed_name": "kv-test", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "Standard", + "component": "secrets", + } + ], + "status": "pending", + "files": [], + }, + ] + + mock_template = MagicMock() + mock_template.display_name = "Web App" + mock_svc = MagicMock() + mock_svc.name = "key-vault" + mock_svc.type = "key-vault" + mock_svc.tier = "Standard" + mock_svc.config = {"softDelete": True} + mock_template.services = [mock_svc] + + stage = session._build_state._state["deployment_stages"][0] + agent, task = session._build_stage_task(stage, "architecture", [mock_template]) + assert agent is not None + assert "Template reference" in task + assert "softDelete" in task + + def test_build_stage_task_app_layer_prev_context(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + # Set up a developer agent for app stages + mock_dev = MagicMock() + mock_dev.name = "app-developer" + mock_dev._include_standards = True + mock_dev.set_knowledge_override = MagicMock() + mock_dev.set_governor_brief = MagicMock() + mock_dev.get_system_messages = MagicMock(return_value=[]) + mock_dev._governance_aware = False + mock_dev._enable_web_search = False + mock_dev._enable_mcp_tools = False + session._dev_agent = mock_dev + + session._build_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "dir": "concept/infra/terraform/stage-1-kv", + "services": [{"name": "key-vault", "computed_name": "kv-test"}], + "status": "generated", + "files": [], + }, + { + "stage": 2, + "name": "API", + "layer": "app", + "capability": "app", + "dir": "concept/apps/stage-2-api", + "services": [{"name": "api", "computed_name": "api-test"}], + "status": "pending", + "files": [], + }, + ] + + stage = session._build_state._state["deployment_stages"][1] + agent, task = session._build_stage_task(stage, "architecture", []) + assert agent is not None + # App layer should get infrastructure cross-reference + assert "Previously Generated Stages" in task + + +# ------------------------------------------------------------------ +# _build_qa_context (lines 2939, 2957-2958) +# ------------------------------------------------------------------ + + +class TestBuildQaContext: + """Tests for QA context construction.""" + + def test_qa_context_iac_includes_provider_compliance(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._iac_tool = "terraform" + session._build_state._state["deployment_stages"] = [] + + result = session._build_qa_context([], layer="infra") + assert "Provider Compliance" in result + + def test_qa_context_non_iac_skips_provider(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [] + + result = session._build_qa_context([], layer="app") + assert "Provider Compliance" not in result + + def test_qa_context_includes_standards(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + session._build_state._state["deployment_stages"] = [] + + result = session._build_qa_context([], layer="infra") + assert isinstance(result, str) + + +# ------------------------------------------------------------------ +# _collect_stage_file_content (line 3242) +# ------------------------------------------------------------------ + + +class TestCollectStageFileContent: + """Tests for single-stage file content collection.""" + + def test_collects_files(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource azapi_resource {}", encoding="utf-8") + + rel_path = str((stage_dir / "main.tf").relative_to(project_dir)) + + stage = {"stage": 1, "name": "Test", "files": [rel_path]} + content = session._collect_stage_file_content(stage) + assert "azapi_resource" in content + + def test_empty_files_returns_empty(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = {"stage": 1, "name": "Test", "files": []} + content = session._collect_stage_file_content(stage) + assert content == "" + + def test_missing_files_handled(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + stage = {"stage": 1, "name": "Test", "files": ["nonexistent/main.tf"]} + content = session._collect_stage_file_content(stage) + assert "(could not read file)" in content + + +# ------------------------------------------------------------------ +# run() -- Branch A first build (lines 313-324) +# ------------------------------------------------------------------ + + +class TestRunBranchA: + """Tests for run() Branch A: first build deriving fresh plan.""" + + def test_first_build_empty_plan_cancels(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + with patch.object(session, "_derive_deployment_plan", return_value=[]): + result = session.run( + design=design, + input_fn=lambda p: "done", + print_fn=lambda m: None, + ) + + assert result.cancelled is True + + def test_first_build_derives_and_saves(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + mock_agent = MagicMock() + mock_agent.name = "terraform-agent" + + stages = [ + { + "stage": 1, + "name": "Identity", + "layer": "core", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1-identity", + "services": [], + "status": "pending", + "files": [], + }, + ] + + with patch.object(session, "_derive_deployment_plan", return_value=stages): + with patch.object(session, "_build_stage_task", return_value=(mock_agent, "task")): + with patch.object(session, "_execute_with_retry", return_value=MagicMock(content="ok", usage={})): + with patch.object(session, "_write_stage_files", return_value=[]): + with patch.object(session, "_apply_stage_transforms", return_value=[]): + session._run_stage_qa = lambda *a, **kw: True + result = session.run( + design=design, + input_fn=lambda p: "done", + print_fn=lambda m: None, + ) + + assert result is not None + assert not result.cancelled + + +# ------------------------------------------------------------------ +# run() -- confirmation prompt and plan adjustment (lines 418-440) +# ------------------------------------------------------------------ + + +class TestRunConfirmation: + """Tests for the plan confirmation prompt in run().""" + + def test_confirmation_quit_cancels(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stages = [ + { + "stage": 1, + "name": "Test", + "layer": "core", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "services": [], + "status": "pending", + "files": [], + }, + ] + session._build_state.set_deployment_plan(stages) + session._build_state.set_design_snapshot(design) + + result = session.run( + design=design, + input_fn=lambda p: "quit", + print_fn=lambda m: None, + ) + + assert result.cancelled is True + + def test_confirmation_with_feedback_adjusts_plan(self, build_context, build_registry): + session = _make_session(build_context, build_registry) + design = {"architecture": "Test arch"} + + stages = [ + { + "stage": 1, + "name": "Test", + "layer": "core", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "services": [], + "status": "pending", + "files": [], + }, + ] + session._build_state.set_deployment_plan(stages) + session._build_state.set_design_snapshot(design) + + adjusted_stages = [ + { + "stage": 1, + "name": "Adjusted", + "layer": "core", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "services": [], + "status": "pending", + "files": [], + }, + ] + + calls = [0] + + def mock_input(prompt): + calls[0] += 1 + if calls[0] == 1: + return "add redis" + return "done" + + mock_agent = MagicMock() + mock_agent.name = "terraform-agent" + + with patch.object(session, "_adjust_plan", return_value=adjusted_stages): + session._run_stage_qa = lambda *a, **kw: True + with patch.object(session, "_build_stage_task", return_value=(mock_agent, "task")): + with patch.object(session, "_execute_with_retry", return_value=MagicMock(content="ok", usage={})): + with patch.object(session, "_write_stage_files", return_value=[]): + with patch.object(session, "_apply_stage_transforms", return_value=[]): + run_result = session.run( + design=design, + input_fn=mock_input, + print_fn=lambda m: None, + ) + + assert run_result is not None From 85e0c868b4758a6137ff9c3cae4acef42e4397d6 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Tue, 7 Apr 2026 12:28:14 -0400 Subject: [PATCH 06/12] Migrate flat test files to mirrored directory structure Move 48 test files from tests/test_*.py to subdirectories that mirror the source tree (tests/agents/, tests/ai/, tests/governance/, etc.). Merge tests into existing mirrored files where both existed. Six root files remain for root-level source modules (custom, telemetry, tracking, debug_log, requirements). 3644 tests passing. --- tests/{ => agents}/test_agent_priority.py | 0 tests/{ => agents}/test_agents.py | 0 tests/{ => agents}/test_orchestrator.py | 0 tests/{ => agents}/test_phase4_agents.py | 0 .../test_providers_auth_agents.py | 0 tests/{ => ai}/test_ai.py | 0 tests/{ => ai}/test_auth.py | 0 tests/{ => ai}/test_copilot_auth.py | 0 tests/{ => ai}/test_token_tracker.py | 0 tests/{ => config}/test_config.py | 0 .../anti_patterns}/test_anti_patterns.py | 0 .../policies}/test_policies.py | 2 +- .../standards}/test_standards.py | 0 tests/{ => governance}/test_governance.py | 0 tests/{ => governance}/test_governor.py | 0 tests/{ => governance}/test_governor_agent.py | 0 .../transforms}/test_transforms.py | 0 tests/{ => knowledge}/test_knowledge.py | 0 .../{ => knowledge}/test_resource_metadata.py | 0 tests/{ => knowledge}/test_web_search.py | 0 tests/mcp/__init__.py | 0 tests/{ => mcp}/test_mcp.py | 0 tests/{ => mcp}/test_mcp_integration.py | 0 tests/{ => naming}/test_naming.py | 0 tests/{ => parsers}/test_binary_reader.py | 0 .../{ => parsers}/test_parse_requirements.py | 0 tests/{ => parsers}/test_parsers.py | 0 tests/stages/test_backlog_session.py | 2392 +++++++ tests/stages/test_build_session.py | 4122 +++++++++++- .../test_coverage_design_deploy.py | 0 tests/{ => stages}/test_coverage_gaps.py | 2 +- tests/stages/test_deploy_helpers.py | 352 + tests/stages/test_deploy_session.py | 5614 ++++++++++++++++ tests/stages/test_discovery.py | 2928 +++++++++ tests/stages/test_discovery_state.py | 187 + tests/stages/test_escalation.py | 536 ++ tests/{ => stages}/test_intent.py | 0 tests/stages/test_knowledge_contributor.py | 155 + tests/stages/test_qa_router.py | 713 ++ tests/{ => stages}/test_stages.py | 557 ++ .../test_template_compliance.py | 4 +- tests/{ => templates}/test_templates.py | 4 +- tests/test_build_session.py | 4693 ------------- tests/test_deploy_helpers.py | 477 -- tests/test_deploy_session.py | 5835 ----------------- tests/test_discovery.py | 3367 ---------- tests/test_discovery_state_scope.py | 192 - tests/test_escalation.py | 636 -- tests/test_generate_backlog.py | 2399 ------- tests/test_knowledge_contributor.py | 572 -- tests/test_qa_router.py | 727 -- tests/test_stages_extended.py | 558 -- tests/{ => ui}/test_console.py | 0 tests/{ => ui}/test_prompt_input.py | 0 tests/{ => ui}/test_stage_orchestrator.py | 0 tests/{ => ui}/test_tui_adapter.py | 0 tests/{ => ui}/test_tui_widgets.py | 0 57 files changed, 17528 insertions(+), 19496 deletions(-) rename tests/{ => agents}/test_agent_priority.py (100%) rename tests/{ => agents}/test_agents.py (100%) rename tests/{ => agents}/test_orchestrator.py (100%) rename tests/{ => agents}/test_phase4_agents.py (100%) rename tests/{ => agents}/test_providers_auth_agents.py (100%) rename tests/{ => ai}/test_ai.py (100%) rename tests/{ => ai}/test_auth.py (100%) rename tests/{ => ai}/test_copilot_auth.py (100%) rename tests/{ => ai}/test_token_tracker.py (100%) rename tests/{ => config}/test_config.py (100%) rename tests/{ => governance/anti_patterns}/test_anti_patterns.py (100%) rename tests/{ => governance/policies}/test_policies.py (99%) rename tests/{ => governance/standards}/test_standards.py (100%) rename tests/{ => governance}/test_governance.py (100%) rename tests/{ => governance}/test_governor.py (100%) rename tests/{ => governance}/test_governor_agent.py (100%) rename tests/{ => governance/transforms}/test_transforms.py (100%) rename tests/{ => knowledge}/test_knowledge.py (100%) rename tests/{ => knowledge}/test_resource_metadata.py (100%) rename tests/{ => knowledge}/test_web_search.py (100%) create mode 100644 tests/mcp/__init__.py rename tests/{ => mcp}/test_mcp.py (100%) rename tests/{ => mcp}/test_mcp_integration.py (100%) rename tests/{ => naming}/test_naming.py (100%) rename tests/{ => parsers}/test_binary_reader.py (100%) rename tests/{ => parsers}/test_parse_requirements.py (100%) rename tests/{ => parsers}/test_parsers.py (100%) rename tests/{ => stages}/test_coverage_design_deploy.py (100%) rename tests/{ => stages}/test_coverage_gaps.py (99%) rename tests/{ => stages}/test_intent.py (100%) rename tests/{ => stages}/test_stages.py (66%) rename tests/{ => templates}/test_template_compliance.py (99%) rename tests/{ => templates}/test_templates.py (99%) delete mode 100644 tests/test_build_session.py delete mode 100644 tests/test_deploy_helpers.py delete mode 100644 tests/test_deploy_session.py delete mode 100644 tests/test_discovery.py delete mode 100644 tests/test_discovery_state_scope.py delete mode 100644 tests/test_escalation.py delete mode 100644 tests/test_generate_backlog.py delete mode 100644 tests/test_knowledge_contributor.py delete mode 100644 tests/test_qa_router.py delete mode 100644 tests/test_stages_extended.py rename tests/{ => ui}/test_console.py (100%) rename tests/{ => ui}/test_prompt_input.py (100%) rename tests/{ => ui}/test_stage_orchestrator.py (100%) rename tests/{ => ui}/test_tui_adapter.py (100%) rename tests/{ => ui}/test_tui_widgets.py (100%) diff --git a/tests/test_agent_priority.py b/tests/agents/test_agent_priority.py similarity index 100% rename from tests/test_agent_priority.py rename to tests/agents/test_agent_priority.py diff --git a/tests/test_agents.py b/tests/agents/test_agents.py similarity index 100% rename from tests/test_agents.py rename to tests/agents/test_agents.py diff --git a/tests/test_orchestrator.py b/tests/agents/test_orchestrator.py similarity index 100% rename from tests/test_orchestrator.py rename to tests/agents/test_orchestrator.py diff --git a/tests/test_phase4_agents.py b/tests/agents/test_phase4_agents.py similarity index 100% rename from tests/test_phase4_agents.py rename to tests/agents/test_phase4_agents.py diff --git a/tests/test_providers_auth_agents.py b/tests/agents/test_providers_auth_agents.py similarity index 100% rename from tests/test_providers_auth_agents.py rename to tests/agents/test_providers_auth_agents.py diff --git a/tests/test_ai.py b/tests/ai/test_ai.py similarity index 100% rename from tests/test_ai.py rename to tests/ai/test_ai.py diff --git a/tests/test_auth.py b/tests/ai/test_auth.py similarity index 100% rename from tests/test_auth.py rename to tests/ai/test_auth.py diff --git a/tests/test_copilot_auth.py b/tests/ai/test_copilot_auth.py similarity index 100% rename from tests/test_copilot_auth.py rename to tests/ai/test_copilot_auth.py diff --git a/tests/test_token_tracker.py b/tests/ai/test_token_tracker.py similarity index 100% rename from tests/test_token_tracker.py rename to tests/ai/test_token_tracker.py diff --git a/tests/test_config.py b/tests/config/test_config.py similarity index 100% rename from tests/test_config.py rename to tests/config/test_config.py diff --git a/tests/test_anti_patterns.py b/tests/governance/anti_patterns/test_anti_patterns.py similarity index 100% rename from tests/test_anti_patterns.py rename to tests/governance/anti_patterns/test_anti_patterns.py diff --git a/tests/test_policies.py b/tests/governance/policies/test_policies.py similarity index 99% rename from tests/test_policies.py rename to tests/governance/policies/test_policies.py index 7f5dde5..ef9405b 100644 --- a/tests/test_policies.py +++ b/tests/governance/policies/test_policies.py @@ -680,7 +680,7 @@ def test_no_duplicate_rule_id_target_pairs(self) -> None: def test_builtin_policies_pass_strict_validation(self) -> None: """All built-in .policy.yaml files must pass strict validation.""" - builtin_dir = Path(__file__).resolve().parent.parent / "azext_prototype" / "policies" + builtin_dir = Path(__file__).resolve().parent.parent.parent.parent / "azext_prototype" / "policies" errors = validate_policy_directory(builtin_dir) actual_errors = [e for e in errors if e.severity == "error"] warnings = [e for e in errors if e.severity == "warning"] diff --git a/tests/test_standards.py b/tests/governance/standards/test_standards.py similarity index 100% rename from tests/test_standards.py rename to tests/governance/standards/test_standards.py diff --git a/tests/test_governance.py b/tests/governance/test_governance.py similarity index 100% rename from tests/test_governance.py rename to tests/governance/test_governance.py diff --git a/tests/test_governor.py b/tests/governance/test_governor.py similarity index 100% rename from tests/test_governor.py rename to tests/governance/test_governor.py diff --git a/tests/test_governor_agent.py b/tests/governance/test_governor_agent.py similarity index 100% rename from tests/test_governor_agent.py rename to tests/governance/test_governor_agent.py diff --git a/tests/test_transforms.py b/tests/governance/transforms/test_transforms.py similarity index 100% rename from tests/test_transforms.py rename to tests/governance/transforms/test_transforms.py diff --git a/tests/test_knowledge.py b/tests/knowledge/test_knowledge.py similarity index 100% rename from tests/test_knowledge.py rename to tests/knowledge/test_knowledge.py diff --git a/tests/test_resource_metadata.py b/tests/knowledge/test_resource_metadata.py similarity index 100% rename from tests/test_resource_metadata.py rename to tests/knowledge/test_resource_metadata.py diff --git a/tests/test_web_search.py b/tests/knowledge/test_web_search.py similarity index 100% rename from tests/test_web_search.py rename to tests/knowledge/test_web_search.py diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_mcp.py b/tests/mcp/test_mcp.py similarity index 100% rename from tests/test_mcp.py rename to tests/mcp/test_mcp.py diff --git a/tests/test_mcp_integration.py b/tests/mcp/test_mcp_integration.py similarity index 100% rename from tests/test_mcp_integration.py rename to tests/mcp/test_mcp_integration.py diff --git a/tests/test_naming.py b/tests/naming/test_naming.py similarity index 100% rename from tests/test_naming.py rename to tests/naming/test_naming.py diff --git a/tests/test_binary_reader.py b/tests/parsers/test_binary_reader.py similarity index 100% rename from tests/test_binary_reader.py rename to tests/parsers/test_binary_reader.py diff --git a/tests/test_parse_requirements.py b/tests/parsers/test_parse_requirements.py similarity index 100% rename from tests/test_parse_requirements.py rename to tests/parsers/test_parse_requirements.py diff --git a/tests/test_parsers.py b/tests/parsers/test_parsers.py similarity index 100% rename from tests/test_parsers.py rename to tests/parsers/test_parsers.py diff --git a/tests/stages/test_backlog_session.py b/tests/stages/test_backlog_session.py index 64a0c46..c8b3398 100644 --- a/tests/stages/test_backlog_session.py +++ b/tests/stages/test_backlog_session.py @@ -13,6 +13,9 @@ from azext_prototype.agents.base import AgentCapability, AgentContext +_CUSTOM_MODULE = "azext_prototype.custom" +_SESSION_MODULE = "azext_prototype.stages.backlog_session" + # ------------------------------------------------------------------ # Fixtures # ------------------------------------------------------------------ @@ -619,3 +622,2392 @@ def test_empty_items_prints_message(self, backlog_context, backlog_registry): output = [] session._save_backlog_md(lambda m: output.append(m)) assert any("No items" in str(m) for m in output) + + +# --- Additional imports from merged flat test --- +from azext_prototype.agents.base import AgentContext +from azext_prototype.agents.builtin import register_all_builtin +from azext_prototype.agents.registry import AgentRegistry +from azext_prototype.ai.provider import AIResponse +from azext_prototype.config import ProjectConfig +from azext_prototype.custom import _generate_templates +from azext_prototype.custom import _load_discovery_scope +from azext_prototype.custom import prototype_generate_backlog +from azext_prototype.custom import prototype_generate_docs +from azext_prototype.custom import prototype_generate_speckit +from azext_prototype.stages.backlog_push import _link_parent +from azext_prototype.stages.backlog_push import check_devops_ext +from azext_prototype.stages.backlog_push import check_gh_auth +from azext_prototype.stages.backlog_push import format_devops_description +from azext_prototype.stages.backlog_push import format_github_body +from azext_prototype.stages.backlog_push import push_devops_feature +from azext_prototype.stages.backlog_push import push_devops_story +from azext_prototype.stages.backlog_push import push_devops_task +from azext_prototype.stages.backlog_push import push_github_issue +from azext_prototype.stages.backlog_state import BacklogState +from azext_prototype.stages.discovery_state import DiscoveryState +from azext_prototype.stages.intent import IntentKind, IntentResult +from knack.util import CLIError +import importlib +import json +import subprocess as sp +import yaml +import yaml as _yaml + + +# ====================================================================== + + +class TestBacklogState: + """Test BacklogState YAML persistence.""" + + def test_default_structure(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + assert state.state["items"] == [] + assert state.state["provider"] == "" + assert state.state["push_status"] == [] + assert state.state["context_hash"] == "" + assert state.state["conversation_history"] == [] + + def test_save_and_load_round_trip(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state._state["provider"] = "github" + state._state["org"] = "myorg" + state._state["project"] = "myrepo" + state.save() + + assert state.exists + + state2 = BacklogState(str(tmp_project)) + loaded = state2.load() + assert loaded["provider"] == "github" + assert loaded["org"] == "myorg" + assert loaded["project"] == "myrepo" + assert loaded["_metadata"]["created"] is not None + + def test_set_items(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + items = [ + {"epic": "Infra", "title": "VNet", "effort": "M", "tasks": []}, + {"epic": "Infra", "title": "KeyVault", "effort": "S", "tasks": []}, + ] + state.set_items(items) + + assert len(state.state["items"]) == 2 + assert state.state["push_status"] == ["pending", "pending"] + assert state.state["push_results"] == [None, None] + assert state.exists + + def test_mark_item_pushed(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state.set_items([{"title": "Item1"}, {"title": "Item2"}]) + + state.mark_item_pushed(0, "https://github.com/org/repo/issues/1") + + assert state.state["push_status"][0] == "pushed" + assert state.state["push_results"][0] == "https://github.com/org/repo/issues/1" + assert state.state["_metadata"]["last_pushed"] is not None + + def test_mark_item_failed(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state.set_items([{"title": "Item1"}]) + + state.mark_item_failed(0, "gh: command failed") + + assert state.state["push_status"][0] == "failed" + assert "gh: command failed" in state.state["push_results"][0] + + def test_get_pending_items(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state.set_items([{"title": "A"}, {"title": "B"}, {"title": "C"}]) + state.mark_item_pushed(1, "url") + + pending = state.get_pending_items() + assert len(pending) == 2 + assert pending[0][0] == 0 # idx + assert pending[1][0] == 2 + + def test_get_pushed_items(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state.set_items([{"title": "A"}, {"title": "B"}]) + state.mark_item_pushed(0, "url1") + state.mark_item_pushed(1, "url2") + + pushed = state.get_pushed_items() + assert len(pushed) == 2 + + def test_context_hash(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + context = "Some architecture design" + scope = {"in_scope": ["API"], "out_of_scope": [], "deferred": []} + + state.set_context_hash(context, scope) + assert state.matches_context(context, scope) + assert not state.matches_context("Different context", scope) + + def test_format_backlog_summary(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state.set_items( + [ + {"epic": "Infra", "title": "VNet Setup", "effort": "M", "tasks": ["T1"]}, + {"epic": "App", "title": "API Gateway", "effort": "L", "tasks": ["T2"]}, + ] + ) + + summary = state.format_backlog_summary() + assert "2 item(s)" in summary + assert "VNet Setup" in summary + assert "API Gateway" in summary + assert "Infra" in summary + assert "App" in summary + + def test_format_item_detail(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state.set_items( + [ + { + "epic": "Infra", + "title": "VNet Setup", + "description": "Configure virtual network", + "acceptance_criteria": ["AC1: VNet created"], + "tasks": ["Create VNet", "Create Subnets"], + "effort": "M", + } + ] + ) + + detail = state.format_item_detail(0) + assert "VNet Setup" in detail + assert "Configure virtual network" in detail + assert "AC1: VNet created" in detail + assert "Create VNet" in detail + + def test_reset(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state.set_items([{"title": "Item1"}]) + assert len(state.state["items"]) == 1 + + state.reset() + assert state.state["items"] == [] + assert state.exists # File still exists (reset saves) + + def test_update_from_exchange(self, tmp_project): + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state.update_from_exchange("add a story", "Added", 1) + + assert len(state.state["conversation_history"]) == 1 + assert state.state["conversation_history"][0]["user"] == "add a story" + +# ====================================================================== + + +class TestBacklogPushHelpers: + """Test GitHub/DevOps push helper functions.""" + + def test_check_gh_auth_pass(self): + from azext_prototype.stages.backlog_push import check_gh_auth + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + assert check_gh_auth() is True + + def test_check_gh_auth_fail(self): + from azext_prototype.stages.backlog_push import check_gh_auth + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=1) + assert check_gh_auth() is False + + def test_check_gh_auth_not_installed(self): + from azext_prototype.stages.backlog_push import check_gh_auth + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError + assert check_gh_auth() is False + + def test_check_devops_ext_pass(self): + from azext_prototype.stages.backlog_push import check_devops_ext + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + assert check_devops_ext() is True + + def test_check_devops_ext_fail(self): + from azext_prototype.stages.backlog_push import check_devops_ext + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=1) + assert check_devops_ext() is False + + def test_format_github_body(self): + from azext_prototype.stages.backlog_push import format_github_body + + item = { + "epic": "Infra", + "title": "VNet Setup", + "description": "Configure VNet", + "acceptance_criteria": ["VNet exists", "Subnets configured"], + "tasks": ["Create VNet", "Create Subnets"], + "effort": "M", + } + body = format_github_body(item) + assert "## Description" in body + assert "Configure VNet" in body + assert "## Acceptance Criteria" in body + assert "- [ ] Create VNet" in body + assert "`effort/M`" in body + assert "`infra`" in body + + def test_format_devops_description(self): + from azext_prototype.stages.backlog_push import format_devops_description + + item = { + "description": "Configure VNet", + "acceptance_criteria": ["VNet exists"], + "tasks": ["Create VNet"], + "effort": "M", + } + desc = format_devops_description(item) + assert "

    Configure VNet

    " in desc + assert "
  • VNet exists
  • " in desc + assert "
  • Create VNet
  • " in desc + assert "Effort" in desc + + def test_push_github_issue_success(self): + from azext_prototype.stages.backlog_push import push_github_issue + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, + stdout="https://github.com/myorg/myrepo/issues/42\n", + ) + + result = push_github_issue( + "myorg", + "myrepo", + {"epic": "Infra", "title": "VNet", "description": "desc", "effort": "M"}, + ) + assert result["url"] == "https://github.com/myorg/myrepo/issues/42" + assert result["number"] == "42" + + def test_push_github_issue_failure(self): + from azext_prototype.stages.backlog_push import push_github_issue + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, + stderr="authentication failed", + stdout="", + ) + + result = push_github_issue( + "myorg", + "myrepo", + {"title": "VNet"}, + ) + assert "error" in result + assert "authentication" in result["error"] + + def test_push_devops_feature_success(self): + from azext_prototype.stages.backlog_push import push_devops_feature + + devops_response = json.dumps( + { + "id": 123, + "_links": {"html": {"href": "https://dev.azure.com/org/proj/_workitems/edit/123"}}, + } + ) + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, + stdout=devops_response, + ) + + result = push_devops_feature( + "myorg", + "myproj", + {"title": "VNet", "description": "desc"}, + ) + assert result["id"] == 123 + assert "dev.azure.com" in result["url"] + + def test_push_devops_feature_failure(self): + from azext_prototype.stages.backlog_push import push_devops_feature + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, + stderr="project not found", + stdout="", + ) + + result = push_devops_feature("myorg", "myproj", {"title": "VNet"}) + assert "error" in result + + # --- Lines 48-49: check_devops_ext FileNotFoundError --- + + def test_check_devops_ext_not_installed(self): + from azext_prototype.stages.backlog_push import check_devops_ext + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError + assert check_devops_ext() is False + + # --- Lines 83-84: format_github_body with dict tasks --- + + def test_format_github_body_dict_tasks(self): + from azext_prototype.stages.backlog_push import format_github_body + + item = { + "description": "desc", + "tasks": [ + {"title": "Done task", "done": True}, + {"title": "Open task", "done": False}, + ], + } + body = format_github_body(item) + assert "- [x] Done task" in body + assert "- [ ] Open task" in body + + # --- Lines 92-114: format_github_body with children --- + + def test_format_github_body_children(self): + from azext_prototype.stages.backlog_push import format_github_body + + item = { + "description": "Parent", + "children": [ + { + "title": "Child Story", + "effort": "S", + "description": "Child desc", + "acceptance_criteria": ["AC1"], + "tasks": [ + {"title": "Sub done", "done": True}, + "Sub open", + ], + }, + ], + } + body = format_github_body(item) + assert "## Stories" in body + assert "### Child Story [S]" in body + assert "Child desc" in body + assert "1. AC1" in body + assert "- [x] Sub done" in body + assert "- [ ] Sub open" in body + + # --- Lines 150-153: format_devops_description with dict tasks --- + + def test_format_devops_description_dict_tasks(self): + from azext_prototype.stages.backlog_push import format_devops_description + + item = { + "tasks": [ + {"title": "Done", "done": True}, + {"title": "Open", "done": False}, + ], + } + desc = format_devops_description(item) + assert "☑ Done" in desc + assert "☐ Open" in desc + + # --- Lines 230-231: push_github_issue FileNotFoundError --- + + def test_push_github_issue_not_installed(self): + from azext_prototype.stages.backlog_push import push_github_issue + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError + result = push_github_issue("org", "repo", {"title": "T"}) + assert "error" in result + assert "gh CLI not found" in result["error"] + + # --- Lines 261, 280: push_devops_story / push_devops_task --- + + def test_push_devops_story_success(self): + from azext_prototype.stages.backlog_push import push_devops_story + + resp = json.dumps( + { + "id": 200, + "_links": {"html": {"href": "https://dev.azure.com/o/p/_workitems/edit/200"}}, + } + ) + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=resp) + result = push_devops_story("o", "p", {"title": "Story"}, parent_id=100) + assert result["id"] == 200 + + def test_push_devops_task_success(self): + from azext_prototype.stages.backlog_push import push_devops_task + + resp = json.dumps( + { + "id": 300, + "_links": {"html": {"href": "https://dev.azure.com/o/p/_workitems/edit/300"}}, + } + ) + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=resp) + result = push_devops_task("o", "p", {"title": "Task"}, parent_id=200) + assert result["id"] == 300 + + # --- Line 326: _push_devops_work_item with epic (area path) --- + + def test_push_devops_feature_with_epic_area(self): + from azext_prototype.stages.backlog_push import push_devops_feature + + resp = json.dumps( + { + "id": 10, + "_links": {"html": {"href": "https://dev.azure.com/o/p/_workitems/edit/10"}}, + } + ) + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=resp) + result = push_devops_feature("o", "p", {"title": "T", "epic": "Infra"}) + assert result["id"] == 10 + cmd_args = mock_run.call_args[0][0] + assert "--area" in cmd_args + assert "p\\Infra" in cmd_args + + # --- Line 350: url fallback to data["url"] --- + + def test_push_devops_url_fallback(self): + from azext_prototype.stages.backlog_push import push_devops_feature + + resp = json.dumps({"id": 50, "url": "https://fallback-url", "_links": {}}) + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=resp) + result = push_devops_feature("o", "p", {"title": "T"}) + assert result["url"] == "https://fallback-url" + + # --- Line 354: parent linking path --- + + def test_push_devops_story_calls_link_parent(self): + from azext_prototype.stages.backlog_push import push_devops_story + + resp = json.dumps( + { + "id": 77, + "_links": {"html": {"href": "https://dev.azure.com/o/p/_workitems/edit/77"}}, + } + ) + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=resp) + result = push_devops_story("o", "p", {"title": "S"}, parent_id=10) + assert result["id"] == 77 + # Second call should be the _link_parent relation add + assert mock_run.call_count == 2 + link_cmd = mock_run.call_args_list[1][0][0] + assert "relation" in link_cmd + assert "parent" in link_cmd + + # --- Lines 357-358: JSONDecodeError fallback --- + + def test_push_devops_json_decode_error(self): + from azext_prototype.stages.backlog_push import push_devops_feature + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="not-json-output") + result = push_devops_feature("o", "p", {"title": "T"}) + assert result["url"] == "" + assert result["id"] == "not-json-output" + + # --- Lines 360-361: _push_devops_work_item FileNotFoundError --- + + def test_push_devops_feature_not_installed(self): + from azext_prototype.stages.backlog_push import push_devops_feature + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError + result = push_devops_feature("o", "p", {"title": "T"}) + assert "error" in result + assert "az CLI not found" in result["error"] + + # --- Lines 366-388: _link_parent error handling --- + + def test_link_parent_file_not_found(self): + from azext_prototype.stages.backlog_push import _link_parent + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError + # Should not raise — just logs a warning + _link_parent("o", "p", 10, 5) + + def test_link_parent_subprocess_error(self): + import subprocess as sp + + from azext_prototype.stages.backlog_push import _link_parent + + with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: + mock_run.side_effect = sp.SubprocessError("fail") + _link_parent("o", "p", 10, 5) + +# ====================================================================== + + +class TestBacklogSession: + """Test the interactive backlog session.""" + + def _make_session(self, project_dir, mock_ai_provider, items_response="[]"): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.ai.provider import AIResponse + from azext_prototype.stages.backlog_session import BacklogSession + from azext_prototype.stages.backlog_state import BacklogState + + mock_ai_provider.chat.return_value = AIResponse( + content=items_response, + model="test", + ) + + registry = AgentRegistry() + register_all_builtin(registry) + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(project_dir), + ai_provider=mock_ai_provider, + ) + + backlog_state = BacklogState(str(project_dir)) + session = BacklogSession( + ctx, + registry, + backlog_state=backlog_state, + ) + return session, backlog_state + + def test_generate_from_ai(self, tmp_project, mock_ai_provider): + items_json = json.dumps( + [ + { + "epic": "Infra", + "title": "VNet", + "effort": "M", + "tasks": ["T1"], + "description": "d", + "acceptance_criteria": ["AC1"], + }, + ] + ) + session, state = self._make_session(tmp_project, mock_ai_provider, items_json) + + output = [] + result = session.run( + design_context="Sample arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: "done", + print_fn=output.append, + ) + + assert result.items_generated == 1 + assert not result.cancelled + + def test_resume_from_state(self, tmp_project, mock_ai_provider): + from azext_prototype.stages.backlog_state import BacklogState + + # Pre-populate state + state = BacklogState(str(tmp_project)) + state.set_items([{"epic": "Pre", "title": "Existing", "effort": "S"}]) + state.set_context_hash("Sample arch") + + session, _ = self._make_session(tmp_project, mock_ai_provider) + session._backlog_state = state + + output = [] + result = session.run( + design_context="Sample arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: "done", + print_fn=output.append, + ) + + assert result.items_generated == 1 + # Should have used cached items + joined = "\n".join(output) + assert "cached" in joined.lower() or "resumed" in joined.lower() + + def test_slash_list(self, tmp_project, mock_ai_provider): + items_json = json.dumps( + [ + {"epic": "Infra", "title": "VNet", "effort": "M"}, + ] + ) + session, state = self._make_session(tmp_project, mock_ai_provider, items_json) + + inputs = iter(["/list", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "VNet" in joined + + def test_slash_show(self, tmp_project, mock_ai_provider): + items_json = json.dumps( + [ + { + "epic": "Infra", + "title": "VNet", + "description": "Configure virtual network", + "effort": "M", + "acceptance_criteria": ["AC1"], + "tasks": ["T1"], + }, + ] + ) + session, state = self._make_session(tmp_project, mock_ai_provider, items_json) + + inputs = iter(["/show 1", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "Configure virtual network" in joined + + def test_slash_save(self, tmp_project, mock_ai_provider): + items_json = json.dumps( + [ + { + "epic": "Infra", + "title": "VNet", + "effort": "M", + "description": "d", + "acceptance_criteria": ["AC1"], + "tasks": ["T1"], + }, + ] + ) + session, state = self._make_session(tmp_project, mock_ai_provider, items_json) + + inputs = iter(["/save", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + + backlog_md = tmp_project / "concept" / "docs" / "BACKLOG.md" + assert backlog_md.exists() + content = backlog_md.read_text() + assert "VNet" in content + + def test_slash_quit(self, tmp_project, mock_ai_provider): + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state = self._make_session(tmp_project, mock_ai_provider, items_json) + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: "/quit", + print_fn=output.append, + ) + assert result.cancelled + + def test_eof_cancels_session(self, tmp_project, mock_ai_provider): + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state = self._make_session(tmp_project, mock_ai_provider, items_json) + + def eof_input(p): + raise EOFError + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=eof_input, + print_fn=output.append, + ) + assert result.cancelled + + def test_quick_mode_cancel(self, tmp_project, mock_ai_provider): + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state = self._make_session(tmp_project, mock_ai_provider, items_json) + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + quick=True, + input_fn=lambda p: "n", + print_fn=output.append, + ) + assert result.cancelled or result.items_pushed == 0 + + def test_refresh_forces_regeneration(self, tmp_project, mock_ai_provider): + """Even with cached state, --refresh forces new AI generation.""" + from azext_prototype.ai.provider import AIResponse + from azext_prototype.stages.backlog_state import BacklogState + + state = BacklogState(str(tmp_project)) + state.set_items([{"epic": "Old", "title": "Old Item", "effort": "S"}]) + state.set_context_hash("arch") + + new_items_json = json.dumps( + [ + {"epic": "New", "title": "New Item", "effort": "M"}, + ] + ) + + # Create session first, THEN override the mock return value + # (_make_session defaults items_response="[]" which overwrites the mock) + session, _ = self._make_session(tmp_project, mock_ai_provider) + session._backlog_state = state + mock_ai_provider.chat.return_value = AIResponse(content=new_items_json, model="t") + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + refresh=True, + input_fn=lambda p: "done", + print_fn=output.append, + ) + + assert result.items_generated == 1 + assert state.state["items"][0]["title"] == "New Item" + + def test_slash_remove(self, tmp_project, mock_ai_provider): + items_json = json.dumps( + [ + {"epic": "A", "title": "Item1", "effort": "S"}, + {"epic": "A", "title": "Item2", "effort": "M"}, + ] + ) + session, state = self._make_session(tmp_project, mock_ai_provider, items_json) + + inputs = iter(["/remove 1", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + assert len(state.state["items"]) == 1 + assert state.state["items"][0]["title"] == "Item2" + +# ====================================================================== + + +class TestScopeInjection: + """Test scope loading and injection into backlog generation.""" + + def test_load_scope_from_discovery(self, tmp_project): + from azext_prototype.custom import _load_discovery_scope + + # Create discovery state with scope + state_dir = tmp_project / ".prototype" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + discovery_data = { + "scope": { + "in_scope": ["API Gateway", "Database"], + "out_of_scope": ["Mobile app"], + "deferred": ["Analytics dashboard"], + }, + "project": {"summary": ""}, + "requirements": {"functional": [], "non_functional": []}, + } + with open(state_dir / "discovery.yaml", "w") as f: + yaml.dump(discovery_data, f) + + scope = _load_discovery_scope(str(tmp_project)) + assert scope is not None + assert "API Gateway" in scope["in_scope"] + assert "Mobile app" in scope["out_of_scope"] + assert "Analytics dashboard" in scope["deferred"] + + def test_load_scope_no_discovery(self, tmp_project): + from azext_prototype.custom import _load_discovery_scope + + scope = _load_discovery_scope(str(tmp_project)) + assert scope is None + + def test_load_scope_empty_scope(self, tmp_project): + from azext_prototype.custom import _load_discovery_scope + + state_dir = tmp_project / ".prototype" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + discovery_data = { + "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, + "project": {"summary": ""}, + "requirements": {"functional": [], "non_functional": []}, + } + with open(state_dir / "discovery.yaml", "w") as f: + yaml.dump(discovery_data, f) + + scope = _load_discovery_scope(str(tmp_project)) + assert scope is None + +# ====================================================================== + + +class TestAIPopulatedTemplates: + """Test AI-populated doc/speckit templates.""" + + @patch(f"{_CUSTOM_MODULE}._get_project_dir") + def test_generate_docs_static_fallback(self, mock_dir, project_with_config): + """Without design context, uses static template rendering.""" + from azext_prototype.custom import prototype_generate_docs + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + out_dir = str(project_with_config / "docs") + result = prototype_generate_docs(cmd, path=out_dir, json_output=True) + assert result["status"] == "generated" + + docs_path = project_with_config / "docs" + assert docs_path.is_dir() + + @patch(f"{_CUSTOM_MODULE}._get_project_dir") + def test_generate_speckit_with_manifest(self, mock_dir, project_with_config): + """Speckit includes manifest.json.""" + from azext_prototype.custom import prototype_generate_speckit + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + out_dir = str(project_with_config / "concept" / ".specify") + prototype_generate_speckit(cmd, path=out_dir) + + manifest_path = project_with_config / "concept" / ".specify" / "manifest.json" + assert manifest_path.exists() + + with open(manifest_path) as f: + manifest = json.load(f) + assert "templates" in manifest + + @patch(f"{_CUSTOM_MODULE}._get_project_dir") + def test_generate_docs_with_design_context(self, mock_dir, project_with_design, mock_ai_provider): + """When design context exists, doc-agent is attempted for population.""" + from azext_prototype.custom import prototype_generate_docs + + mock_dir.return_value = str(project_with_design) + cmd = MagicMock() + + # AI provider factory is imported locally inside prototype_generate_docs + with patch("azext_prototype.ai.factory.create_ai_provider") as mock_factory: + mock_factory.return_value = mock_ai_provider + + out_dir = str(project_with_design / "docs") + result = prototype_generate_docs(cmd, path=out_dir, json_output=True) + + assert result["status"] == "generated" + + def test_generate_templates_uses_rich_ui(self, project_with_config): + """_generate_templates uses console.print_file_list instead of print().""" + from pathlib import Path + + from azext_prototype.custom import _generate_templates + + output_dir = Path(str(project_with_config)) / "test_docs" + project_dir = str(project_with_config) + project_config = {"project": {"name": "test"}} + + # Patch the module-level console singleton. We must use importlib + # because `import azext_prototype.ui.console` can resolve to the + # `console` variable re-exported in azext_prototype.ui.__init__ + # instead of the submodule (name collision on Python 3.10). + import importlib + + _console_mod = importlib.import_module("azext_prototype.ui.console") + + with patch.object(_console_mod, "console") as mock_console: + generated = _generate_templates(output_dir, project_dir, project_config, "docs") + + # Should use console.print_file_list instead of bare print() + mock_console.print_file_list.assert_called_once() + mock_console.print_dim.assert_called_once() + assert len(generated) >= 1 + +# ====================================================================== + + +class TestBacklogCommandIntegration: + """Test the prototype_generate_backlog command with new session delegation.""" + + @patch(f"{_CUSTOM_MODULE}._prepare_command") + def test_backlog_status_no_state(self, mock_prepare, project_with_config, mock_ai_provider): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.custom import prototype_generate_backlog + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(project_with_config), + ai_provider=mock_ai_provider, + ) + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(str(project_with_config)) + mock_prepare.return_value = (str(project_with_config), config, AgentRegistry(), ctx) + cmd = MagicMock() + + result = prototype_generate_backlog(cmd, status=True, json_output=True) + assert result["status"] == "displayed" + + @patch(f"{_CUSTOM_MODULE}._prepare_command") + def test_backlog_status_with_state(self, mock_prepare, project_with_design, mock_ai_provider): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.custom import prototype_generate_backlog + from azext_prototype.stages.backlog_state import BacklogState + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(project_with_design), + ai_provider=mock_ai_provider, + ) + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(str(project_with_design)) + mock_prepare.return_value = (str(project_with_design), config, AgentRegistry(), ctx) + cmd = MagicMock() + + # Create backlog state + state = BacklogState(str(project_with_design)) + state.set_items([{"epic": "Infra", "title": "VNet", "effort": "M"}]) + + result = prototype_generate_backlog(cmd, status=True, json_output=True) + assert result["status"] == "displayed" + + @patch(f"{_CUSTOM_MODULE}._check_requirements") + @patch(f"{_CUSTOM_MODULE}._get_project_dir") + def test_backlog_invalid_provider_raises(self, mock_dir, mock_check_req, project_with_design, mock_ai_provider): + from azext_prototype.custom import prototype_generate_backlog + + mock_dir.return_value = str(project_with_design) + cmd = MagicMock() + + with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: + from azext_prototype.agents.base import AgentContext + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(project_with_design), + ai_provider=mock_ai_provider, + ) + mock_ctx.return_value = ctx + + with pytest.raises(CLIError, match="Unsupported backlog provider"): + prototype_generate_backlog(cmd, provider="jira", org="x", project="y") + + @patch(f"{_CUSTOM_MODULE}._check_requirements") + @patch(f"{_CUSTOM_MODULE}._get_project_dir") + def test_backlog_no_design_raises(self, mock_dir, mock_check_req, project_with_config, mock_ai_provider): + from azext_prototype.custom import prototype_generate_backlog + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: + from azext_prototype.agents.base import AgentContext + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(project_with_config), + ai_provider=mock_ai_provider, + ) + mock_ctx.return_value = ctx + + with pytest.raises(CLIError, match="No architecture design found"): + prototype_generate_backlog(cmd, provider="github", org="x", project="y") + + @patch(f"{_CUSTOM_MODULE}._check_requirements") + @patch(f"{_CUSTOM_MODULE}._get_project_dir") + def test_backlog_delegates_to_session(self, mock_dir, mock_check_req, project_with_design, mock_ai_provider): + """The command delegates to BacklogSession.run().""" + from azext_prototype.ai.provider import AIResponse + from azext_prototype.custom import prototype_generate_backlog + + mock_dir.return_value = str(project_with_design) + cmd = MagicMock() + + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + mock_ai_provider.chat.return_value = AIResponse(content=items_json, model="t") + + with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: + from azext_prototype.agents.base import AgentContext + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(project_with_design), + ai_provider=mock_ai_provider, + ) + mock_ctx.return_value = ctx + + with patch("azext_prototype.stages.backlog_session.BacklogSession.run") as mock_run: + from azext_prototype.stages.backlog_session import BacklogResult + + mock_run.return_value = BacklogResult( + items_generated=1, + items_pushed=0, + ) + + result = prototype_generate_backlog( + cmd, + provider="github", + org="o", + project="p", + json_output=True, + ) + + assert result["status"] == "generated" + assert result["items_generated"] == 1 + + @patch(f"{_CUSTOM_MODULE}._check_requirements") + @patch(f"{_CUSTOM_MODULE}._get_project_dir") + def test_backlog_cancelled_returns_cancelled(self, mock_dir, mock_check_req, project_with_design, mock_ai_provider): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.custom import prototype_generate_backlog + + mock_dir.return_value = str(project_with_design) + cmd = MagicMock() + + mock_ai_provider.chat.return_value = AIResponse(content="[]", model="t") + + with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: + from azext_prototype.agents.base import AgentContext + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(project_with_design), + ai_provider=mock_ai_provider, + ) + mock_ctx.return_value = ctx + + with patch("azext_prototype.stages.backlog_session.BacklogSession.run") as mock_run: + from azext_prototype.stages.backlog_session import BacklogResult + + mock_run.return_value = BacklogResult(cancelled=True) + + result = prototype_generate_backlog( + cmd, + provider="github", + org="o", + project="p", + json_output=True, + ) + + assert result["status"] == "cancelled" + +# ====================================================================== + + +class TestAddEnrichment: + """Test that /add uses PM agent to enrich items.""" + + def _make_session(self, tmp_project, pm_response=None, pm_raises=False): + from azext_prototype.agents.base import AgentCapability, AgentContext + from azext_prototype.ai.provider import AIResponse + from azext_prototype.stages.backlog_session import BacklogSession + from azext_prototype.stages.backlog_state import BacklogState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + pm = MagicMock() + pm.name = "project-manager" + pm.get_system_messages.return_value = [] + + if pm_raises: + ctx.ai_provider.chat.side_effect = RuntimeError("AI error") + elif pm_response: + ctx.ai_provider.chat.return_value = AIResponse( + content=pm_response, + model="gpt-4o", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + else: + ctx.ai_provider.chat.return_value = AIResponse( + content=json.dumps( + { + "epic": "API", + "title": "Add rate limiting", + "description": "Implement API rate limiting for all endpoints", + "acceptance_criteria": ["AC1: Rate limit headers returned", "AC2: 429 status on exceed"], + "tasks": ["Add middleware", "Configure limits", "Add tests"], + "effort": "L", + } + ), + model="gpt-4o", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + + registry = MagicMock() + + def find_by_cap(cap): + if cap == AgentCapability.BACKLOG_GENERATION: + return [pm] + if cap == AgentCapability.QA: + return [] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + state = BacklogState(str(tmp_project)) + state.set_items([{"title": "Existing"}]) + + session = BacklogSession(ctx, registry, backlog_state=state) + return session, pm, state + + def test_add_enriched_via_pm(self, tmp_project): + session, pm, state = self._make_session(tmp_project) + + result = session._enrich_new_item("Add rate limiting to the API") + + assert result["title"] == "Add rate limiting" + assert result["epic"] == "API" + assert len(result["acceptance_criteria"]) == 2 + assert len(result["tasks"]) == 3 + assert result["effort"] == "L" + + def test_add_pm_failure_falls_back_to_bare(self, tmp_project): + session, pm, state = self._make_session(tmp_project, pm_raises=True) + + result = session._enrich_new_item("Add rate limiting") + + assert result["title"] == "Add rate limiting" + assert result["epic"] == "Added" + assert result["acceptance_criteria"] == [] + + def test_add_pm_invalid_json_falls_back(self, tmp_project): + session, pm, state = self._make_session( + tmp_project, + pm_response="Sure, here's a rate limiting story with details...", + ) + + result = session._enrich_new_item("Add rate limiting") + + assert result["title"] == "Add rate limiting" + assert result["epic"] == "Added" + + def test_add_no_pm_agent_uses_bare(self, tmp_project): + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.backlog_session import BacklogSession + from azext_prototype.stages.backlog_state import BacklogState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + registry = MagicMock() + registry.find_by_capability.return_value = [] + + session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) + + result = session._enrich_new_item("Add rate limiting") + + assert result["title"] == "Add rate limiting" + assert result["epic"] == "Added" + + def test_add_enriched_missing_fields_get_defaults(self, tmp_project): + session, pm, state = self._make_session( + tmp_project, + pm_response=json.dumps({"title": "Rate Limiting", "effort": "S"}), + ) + + result = session._enrich_new_item("Add rate limiting") + + assert result["title"] == "Rate Limiting" + assert result["epic"] == "Added" # defaulted + assert result["acceptance_criteria"] == [] # defaulted + assert result["tasks"] == [] # defaulted + assert result["effort"] == "S" + +class TestBacklogSessionCoverage: + """Additional tests to cover uncovered lines in backlog_session.py.""" + + def _make_session( + self, + project_dir, + mock_ai_provider=None, + items_response="[]", + *, + with_qa=True, + ): + from azext_prototype.agents.base import AgentCapability, AgentContext + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.ai.provider import AIResponse + from azext_prototype.stages.backlog_session import BacklogSession + from azext_prototype.stages.backlog_state import BacklogState + + if mock_ai_provider is None: + mock_ai_provider = MagicMock() + + mock_ai_provider.chat.return_value = AIResponse( + content=items_response, + model="test", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + + pm = MagicMock() + pm.name = "project-manager" + pm.get_system_messages.return_value = [] + + qa = MagicMock() + qa.name = "qa-engineer" + + registry = MagicMock(spec=AgentRegistry) + + def find_by_cap(cap): + if cap == AgentCapability.BACKLOG_GENERATION: + return [pm] + if cap == AgentCapability.QA: + return [qa] if with_qa else [] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(project_dir), + ai_provider=mock_ai_provider, + ) + + backlog_state = BacklogState(str(project_dir)) + session = BacklogSession(ctx, registry, backlog_state=backlog_state) + return session, backlog_state, mock_ai_provider + + # ---------------------------------------------------------- + # Line 151: escalation tracker load + # ---------------------------------------------------------- + + def test_escalation_tracker_loaded_when_exists(self, tmp_project): + """When escalation.yaml exists, __init__ loads it (line 151).""" + import yaml as _yaml + + esc_path = tmp_project / ".prototype" / "state" / "escalation.yaml" + esc_path.parent.mkdir(parents=True, exist_ok=True) + esc_path.write_text(_yaml.dump({"entries": [], "active_count": 0})) + + session, _, _ = self._make_session(tmp_project) + # If it loaded without error, the path is covered + assert session._escalation_tracker is not None + + # ---------------------------------------------------------- + # Lines 227-228: no PM agent + # ---------------------------------------------------------- + + def test_run_no_pm_agent_returns_cancelled(self, tmp_project): + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.backlog_session import BacklogSession + from azext_prototype.stages.backlog_state import BacklogState + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + registry = MagicMock() + registry.find_by_capability.return_value = [] + + session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: "done", + print_fn=output.append, + ) + assert result.cancelled + joined = "\n".join(output) + assert "No project-manager agent" in joined + + # ---------------------------------------------------------- + # Lines 231-232: no AI provider + # ---------------------------------------------------------- + + def test_run_no_ai_provider_returns_cancelled(self, tmp_project): + from azext_prototype.agents.base import AgentCapability, AgentContext + from azext_prototype.stages.backlog_session import BacklogSession + from azext_prototype.stages.backlog_state import BacklogState + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(tmp_project), + ai_provider=None, + ) + + pm = MagicMock() + pm.name = "project-manager" + registry = MagicMock() + + def find_by_cap(cap): + if cap == AgentCapability.BACKLOG_GENERATION: + return [pm] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: "done", + print_fn=output.append, + ) + assert result.cancelled + joined = "\n".join(output) + assert "No AI provider" in joined + + # ---------------------------------------------------------- + # Lines 297: empty input skip in interactive loop + # ---------------------------------------------------------- + + def test_empty_input_skipped(self, tmp_project): + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + inputs = iter(["", "done"]) + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + assert not result.cancelled + assert result.items_generated == 1 + + # ---------------------------------------------------------- + # Lines 328-364: intent classification to command + NL mutate + # ---------------------------------------------------------- + + def test_intent_command_routes_to_slash(self, tmp_project): + """Natural language classified as COMMAND is routed (lines 328-342).""" + from azext_prototype.stages.intent import IntentKind, IntentResult + + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + # Mock the intent classifier to return a COMMAND + session._intent_classifier = MagicMock() + session._intent_classifier.classify.return_value = IntentResult( + kind=IntentKind.COMMAND, + command="/list", + args="", + original_input="show all items", + confidence=0.9, + ) + + inputs = iter(["show all items", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + # /list should have been handled -- items are listed + joined = "\n".join(output) + assert "B" in joined + + def test_intent_command_push_breaks_loop(self, tmp_project): + """Intent push that succeeds returns 'pushed' (line 340-341).""" + from azext_prototype.stages.intent import IntentKind, IntentResult + + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + session._intent_classifier = MagicMock() + session._intent_classifier.classify.return_value = IntentResult( + kind=IntentKind.COMMAND, + command="/push", + args="", + original_input="push items", + confidence=0.9, + ) + + with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( + f"{_SESSION_MODULE}.push_github_issue" + ) as mock_push: + mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: "push items", + print_fn=output.append, + ) + assert result.items_pushed == 1 + + def test_natural_language_mutate_items(self, tmp_project): + """NL CONVERSATIONAL triggers _mutate_items (lines 344-364).""" + from azext_prototype.ai.provider import AIResponse + from azext_prototype.stages.intent import IntentKind, IntentResult + + items_json = json.dumps([{"epic": "A", "title": "Original", "effort": "S"}]) + session, state, ai = self._make_session(tmp_project, items_response=items_json) + + updated_json = json.dumps([{"epic": "A", "title": "Updated", "effort": "M"}]) + + session._intent_classifier = MagicMock() + session._intent_classifier.classify.return_value = IntentResult( + kind=IntentKind.CONVERSATIONAL, + original_input="change title to Updated", + ) + + call_count = [0] + + def side_effect_chat(msgs, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + return AIResponse( + content=items_json, + model="t", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + else: + return AIResponse( + content=updated_json, + model="t", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + + ai.chat.side_effect = side_effect_chat + + inputs = iter(["change title to Updated", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + assert state.state["items"][0]["title"] == "Updated" + + def test_natural_language_mutate_returns_none(self, tmp_project): + """When _mutate_items returns None, user sees error (line 362).""" + from azext_prototype.stages.intent import IntentKind, IntentResult + + items_json = json.dumps([{"epic": "A", "title": "T", "effort": "S"}]) + session, state, ai = self._make_session(tmp_project, items_response=items_json) + + session._intent_classifier = MagicMock() + session._intent_classifier.classify.return_value = IntentResult( + kind=IntentKind.CONVERSATIONAL, + original_input="do something weird", + ) + + # Force _mutate_items to return None (the path that shows the error) + session._mutate_items = MagicMock(return_value=None) + + inputs = iter(["do something weird", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "Could not update" in joined + + # ---------------------------------------------------------- + # Lines 374, 378: report phase with push_urls + # ---------------------------------------------------------- + + def test_report_collects_push_urls(self, tmp_project): + """Report phase extracts urls from push_results (lines 374-378).""" + session, state, _ = self._make_session(tmp_project) + + state.set_items([{"epic": "A", "title": "B", "effort": "S"}]) + state.mark_item_pushed(0, "https://github.com/o/p/issues/1") + state.set_context_hash("arch") + session._backlog_state = state + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: "done", + print_fn=output.append, + ) + assert result.items_pushed == 1 + assert "https://github.com/o/p/issues/1" in result.push_urls + + # ---------------------------------------------------------- + # Lines 407, 410-411: quick mode EOF + # ---------------------------------------------------------- + + def test_quick_mode_eof_cancels(self, tmp_project): + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + def eof_input(p): + raise EOFError + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + quick=True, + input_fn=eof_input, + print_fn=output.append, + ) + assert result.cancelled + + def test_quick_mode_confirm_push(self, tmp_project): + """Quick mode confirm=yes triggers push (line 417).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( + f"{_SESSION_MODULE}.push_github_issue" + ) as mock_push: + mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + quick=True, + input_fn=lambda p: "y", + print_fn=output.append, + ) + assert result.items_pushed == 1 + + # ---------------------------------------------------------- + # Lines 440-448, 494, 504: scope text + devops provider + # ---------------------------------------------------------- + + def test_generate_items_with_full_scope(self, tmp_project): + """Scope in/out/deferred all present (lines 440-448, 494).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, ai = self._make_session(tmp_project, items_response=items_json) + + scope = { + "in_scope": ["API Gateway"], + "out_of_scope": ["Mobile app"], + "deferred": ["Analytics"], + } + + output = [] + session.run( + design_context="arch", + scope=scope, + provider="github", + org="o", + project="p", + input_fn=lambda p: "done", + print_fn=output.append, + ) + + call_args = ai.chat.call_args + messages = call_args[0][0] + content = messages[-1].content + assert "In Scope" in content + assert "API Gateway" in content + assert "Out of Scope" in content + assert "Mobile app" in content + assert "Deferred" in content + assert "Analytics" in content + + def test_generate_items_devops_format(self, tmp_project): + """DevOps provider uses hierarchical JSON schema (line 504).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, ai = self._make_session(tmp_project, items_response=items_json) + + output = [] + session.run( + design_context="arch", + provider="devops", + org="o", + project="p", + input_fn=lambda p: "done", + print_fn=output.append, + ) + + call_args = ai.chat.call_args + messages = call_args[0][0] + content = messages[-1].content + assert "Azure DevOps hierarchy" in content + assert "children" in content + + # ---------------------------------------------------------- + # Lines 571-599: _mutate_items + # ---------------------------------------------------------- + + def test_mutate_items_no_pm_returns_none(self, tmp_project): + """_mutate_items returns None when no PM agent (line 571).""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.backlog_session import BacklogSession + from azext_prototype.stages.backlog_state import BacklogState + + ctx = AgentContext( + project_config={"project": {"name": "test"}}, + project_dir=str(tmp_project), + ai_provider=None, + ) + registry = MagicMock() + registry.find_by_capability.return_value = [] + + session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) + + result = session._mutate_items("add an item", "arch") + assert result is None + + def test_mutate_items_success(self, tmp_project): + """_mutate_items calls AI and parses JSON (lines 571-599).""" + from azext_prototype.ai.provider import AIResponse + + updated = [{"epic": "A", "title": "Updated", "effort": "M"}] + session, state, ai = self._make_session(tmp_project) + state.set_items([{"epic": "A", "title": "Old", "effort": "S"}]) + + ai.chat.return_value = AIResponse( + content=json.dumps(updated), + model="t", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + + result = session._mutate_items("rename to Updated", "arch") + assert result is not None + assert result[0]["title"] == "Updated" + + # ---------------------------------------------------------- + # Lines 606-608: _parse_items with markdown fences + # ---------------------------------------------------------- + + def test_parse_items_markdown_fences(self): + from azext_prototype.stages.backlog_session import BacklogSession + + raw = '```json\n[{"title": "A"}]\n```' + items = BacklogSession._parse_items(raw) + assert len(items) == 1 + assert items[0]["title"] == "A" + + def test_parse_items_bad_json_returns_empty(self): + from azext_prototype.stages.backlog_session import BacklogSession + + items = BacklogSession._parse_items("this is not json") + assert items == [] + + # ---------------------------------------------------------- + # Lines 634-637: push_all no pending items + # ---------------------------------------------------------- + + def test_push_all_no_pending(self, tmp_project): + """_push_all with no pending items returns early (lines 634-637).""" + session, state, _ = self._make_session(tmp_project) + + state.set_items([{"epic": "A", "title": "B", "effort": "S"}]) + state.mark_item_pushed(0, "url") + + output = [] + result = session._push_all("github", "o", "p", output.append, False) + assert result.items_pushed == 1 + joined = "\n".join(output) + assert "No pending" in joined + + # ---------------------------------------------------------- + # Lines 645-653: push auth check fails + # ---------------------------------------------------------- + + def test_push_all_github_no_auth(self, tmp_project): + session, state, _ = self._make_session(tmp_project) + state.set_items([{"title": "A"}]) + + with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=False): + output = [] + result = session._push_all("github", "o", "p", output.append, False) + assert result.cancelled + joined = "\n".join(output) + assert "not authenticated" in joined.lower() + + def test_push_all_devops_no_ext(self, tmp_project): + """DevOps push fails when extension missing (lines 651-656).""" + session, state, _ = self._make_session(tmp_project) + state.set_items([{"title": "A"}]) + + with patch(f"{_SESSION_MODULE}.check_devops_ext", return_value=False): + output = [] + result = session._push_all("devops", "o", "p", output.append, False) + assert result.cancelled + joined = "\n".join(output) + assert "not available" in joined.lower() + + # ---------------------------------------------------------- + # Lines 672, 687-714: push devops feature with children + # ---------------------------------------------------------- + + def test_push_all_devops_with_children_and_tasks(self, tmp_project): + """DevOps push: Feature -> Stories -> Tasks (lines 687-714).""" + session, state, _ = self._make_session(tmp_project) + state.set_items( + [ + { + "title": "Feature1", + "children": [ + { + "title": "Story1", + "tasks": [ + {"title": "Task1", "done": False}, + {"title": "Task2", "done": True}, + ], + }, + ], + } + ] + ) + + with patch(f"{_SESSION_MODULE}.check_devops_ext", return_value=True), patch( + f"{_SESSION_MODULE}.push_devops_feature" + ) as mock_feat, patch(f"{_SESSION_MODULE}.push_devops_story") as mock_story, patch( + f"{_SESSION_MODULE}.push_devops_task" + ) as mock_task: + mock_feat.return_value = { + "id": 100, + "url": "https://dev.azure.com/o/p/_workitems/100", + } + mock_story.return_value = { + "id": 101, + "url": "https://dev.azure.com/o/p/_workitems/101", + } + mock_task.return_value = {"id": 102, "url": ""} + + output = [] + result = session._push_all("devops", "o", "p", output.append, False) + + assert result.items_pushed == 1 + assert len(result.push_urls) == 2 # feature + story + mock_story.assert_called_once() + # Only Task1 (done=False) should be pushed + mock_task.assert_called_once() + task_arg = mock_task.call_args[0][2] + assert task_arg["title"] == "Task1" + + def test_push_all_item_error_routes_to_qa(self, tmp_project): + """Push failure routes to QA (lines 674-685).""" + session, state, _ = self._make_session(tmp_project) + state.set_items([{"title": "FailItem"}]) + + with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( + f"{_SESSION_MODULE}.push_github_issue" + ) as mock_push, patch(f"{_SESSION_MODULE}.route_error_to_qa") as mock_qa: + mock_push.return_value = {"error": "auth failed"} + + output = [] + result = session._push_all("github", "o", "p", output.append, False) + + assert result.items_failed == 1 + mock_qa.assert_called_once() + + # ---------------------------------------------------------- + # Lines 737-779: _push_single + # ---------------------------------------------------------- + + def test_push_single_invalid_index(self, tmp_project): + """_push_single with out-of-range index (lines 738-740).""" + session, state, _ = self._make_session(tmp_project) + state.set_items([{"title": "Only"}]) + + output = [] + session._push_single(5, "github", "o", "p", output.append, False) + joined = "\n".join(output) + assert "not found" in joined.lower() + + def test_push_single_github_success(self, tmp_project): + """_push_single pushes a single github issue (lines 742-757).""" + session, state, _ = self._make_session(tmp_project) + state.set_items([{"title": "Item1"}]) + + with patch(f"{_SESSION_MODULE}.push_github_issue") as mock_push: + mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} + + output = [] + session._push_single(0, "github", "o", "p", output.append, False) + + assert state.state["push_status"][0] == "pushed" + joined = "\n".join(output) + assert "github.com" in joined + + def test_push_single_error(self, tmp_project): + """_push_single error marks item failed (lines 751-753).""" + session, state, _ = self._make_session(tmp_project) + state.set_items([{"title": "Item1"}]) + + with patch(f"{_SESSION_MODULE}.push_github_issue") as mock_push: + mock_push.return_value = {"error": "not found"} + + output = [] + session._push_single(0, "github", "o", "p", output.append, False) + + assert state.state["push_status"][0] == "failed" + + def test_push_single_devops_with_children(self, tmp_project): + """_push_single devops creates children + tasks (lines 759-779).""" + session, state, _ = self._make_session(tmp_project) + state.set_items( + [ + { + "title": "Feature", + "children": [ + { + "title": "Story", + "tasks": [{"title": "Task", "done": False}], + } + ], + } + ] + ) + + with patch(f"{_SESSION_MODULE}.push_devops_feature") as mock_feat, patch( + f"{_SESSION_MODULE}.push_devops_story" + ) as mock_story, patch(f"{_SESSION_MODULE}.push_devops_task") as mock_task: + mock_feat.return_value = {"id": 10, "url": "http://f"} + mock_story.return_value = {"id": 11, "url": "http://s"} + mock_task.return_value = {"id": 12, "url": ""} + + output = [] + session._push_single(0, "devops", "o", "p", output.append, False) + + mock_story.assert_called_once() + mock_task.assert_called_once() + + # ---------------------------------------------------------- + # Lines 812, 815-829: slash commands /show, /add + # ---------------------------------------------------------- + + def test_slash_show_no_arg(self, tmp_project): + """/show without number prints usage (line 812).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + inputs = iter(["/show", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "Usage: /show N" in joined + + def test_slash_add_with_description(self, tmp_project): + """/add prompts for description and enriches (lines 815-829).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + inputs = iter(["/add", "New item description", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + assert len(state.state["items"]) == 2 + joined = "\n".join(output) + assert "Added item 2" in joined + + def test_slash_add_eof(self, tmp_project): + """/add with EOF during description input (lines 821-822).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + call_count = [0] + + def eof_on_second(p): + call_count[0] += 1 + if call_count[0] == 1: + return "/add" + elif call_count[0] == 2: + raise EOFError + return "done" + + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=eof_on_second, + print_fn=output.append, + ) + # Items unchanged -- the add was cancelled + assert len(state.state["items"]) == 1 + + # ---------------------------------------------------------- + # Lines 840-842: /remove edge cases + # ---------------------------------------------------------- + + def test_slash_remove_invalid_arg(self, tmp_project): + """/remove without number prints usage (lines 841-842).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + inputs = iter(["/remove", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "Usage: /remove N" in joined + + def test_slash_remove_out_of_range(self, tmp_project): + """/remove with index out of range (line 840).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + inputs = iter(["/remove 99", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "not found" in joined.lower() + + # ---------------------------------------------------------- + # Lines 845-857: /preview command + # ---------------------------------------------------------- + + def test_slash_preview_github(self, tmp_project): + items_json = json.dumps( + [ + {"epic": "Infra", "title": "VNet", "effort": "M"}, + {"epic": "App", "title": "API", "effort": "L"}, + ] + ) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + inputs = iter(["/preview", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "GitHub Issues" in joined + assert "[Infra] VNet" in joined + assert "[App] API" in joined + + def test_slash_preview_devops(self, tmp_project): + """/preview for devops provider (no epic prefix, line 856).""" + items_json = json.dumps([{"title": "Feature1", "effort": "M"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + inputs = iter(["/preview", "done"]) + output = [] + session.run( + design_context="arch", + provider="devops", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "DevOps Work Items" in joined + assert "Feature1" in joined + + # ---------------------------------------------------------- + # Lines 862-907: /push, /status, /help + # ---------------------------------------------------------- + + def test_slash_push_single(self, tmp_project): + """/push N pushes single item (lines 862-865).""" + items_json = json.dumps( + [ + {"epic": "A", "title": "Item1", "effort": "S"}, + {"epic": "A", "title": "Item2", "effort": "M"}, + ] + ) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + with patch(f"{_SESSION_MODULE}.push_github_issue") as mock_push: + mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} + + inputs = iter(["/push 1", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + + assert state.state["push_status"][0] == "pushed" + assert state.state["push_status"][1] == "pending" + + def test_slash_push_all_breaks_on_success(self, tmp_project): + """/push (all) breaks loop on success (line 868-869).""" + items_json = json.dumps([{"epic": "A", "title": "Item1", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( + f"{_SESSION_MODULE}.push_github_issue" + ) as mock_push: + mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: "/push", + print_fn=output.append, + ) + + assert result.items_pushed == 1 + + def test_slash_status(self, tmp_project): + """Show push status per item (lines 871-880).""" + session, state, _ = self._make_session(tmp_project) + + state.set_items( + [ + {"epic": "A", "title": "Item1", "effort": "S"}, + {"epic": "A", "title": "Item2", "effort": "M"}, + ] + ) + state.mark_item_pushed(0, "url") + state.set_context_hash("arch") + session._backlog_state = state + + inputs = iter(["/status", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "pushed" in joined + assert "pending" in joined + + def test_slash_help(self, tmp_project): + """Display help text (lines 882-907).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + inputs = iter(["/help", "done"]) + output = [] + session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: next(inputs), + print_fn=output.append, + ) + joined = "\n".join(output) + assert "/list" in joined + assert "/push" in joined + assert "/remove" in joined + assert "/preview" in joined + assert "/status" in joined + assert "natural language" in joined.lower() + + # ---------------------------------------------------------- + # Lines 961-963: enrich with markdown fences + # ---------------------------------------------------------- + + def test_enrich_strips_markdown_fences(self, tmp_project): + from azext_prototype.ai.provider import AIResponse + + item_json = json.dumps({"title": "Rate Limiting", "effort": "L"}) + fenced = f"```json\n{item_json}\n```" + + session, state, ai = self._make_session(tmp_project) + ai.chat.return_value = AIResponse( + content=fenced, + model="t", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + + result = session._enrich_new_item("Rate limiting") + assert result["title"] == "Rate Limiting" + assert result["effort"] == "L" + + # ---------------------------------------------------------- + # Lines 987-988: _save_backlog_md with no items + # ---------------------------------------------------------- + + def test_save_backlog_md_no_items(self, tmp_project): + session, state, _ = self._make_session(tmp_project) + state.set_items([]) + + output = [] + session._save_backlog_md(output.append) + joined = "\n".join(output) + assert "No items to save" in joined + + # ---------------------------------------------------------- + # Lines 1020-1021: save with dict tasks + # ---------------------------------------------------------- + + def test_save_backlog_md_dict_tasks(self, tmp_project): + session, state, _ = self._make_session(tmp_project) + state.set_items( + [ + { + "epic": "Infra", + "title": "VNet", + "description": "Configure VNet", + "effort": "M", + "acceptance_criteria": ["AC1"], + "tasks": [ + {"title": "Create VNet", "done": True}, + {"title": "Create Subnets", "done": False}, + ], + } + ] + ) + + output = [] + session._save_backlog_md(output.append) + + md_path = tmp_project / "concept" / "docs" / "BACKLOG.md" + assert md_path.exists() + content = md_path.read_text() + assert "- [x] Create VNet" in content + assert "- [ ] Create Subnets" in content + + # ---------------------------------------------------------- + # Lines 1055, 1067-1069: _get_production_items + # ---------------------------------------------------------- + + def test_get_production_items_no_services(self, tmp_project): + """Returns empty string when no services (line 1055).""" + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_project)) + ds._state["architecture"] = {"services": []} + ds.save() + + session, _, _ = self._make_session(tmp_project) + result = session._get_production_items() + assert result == "" + + def test_get_production_items_exception(self, tmp_project): + """Returns empty string on exception (lines 1067-1069).""" + session, _, _ = self._make_session(tmp_project) + + with patch( + "azext_prototype.stages.discovery_state.DiscoveryState.load", + side_effect=Exception("boom"), + ): + result = session._get_production_items() + assert result == "" + + # ---------------------------------------------------------- + # Lines 1075-1076, 1078-1082: _maybe_spinner + # ---------------------------------------------------------- + + def test_maybe_spinner_with_status_fn(self, tmp_project): + """_maybe_spinner with status_fn calls start/end (1078-1082).""" + session, _, _ = self._make_session(tmp_project) + + calls = [] + + def status_fn(msg, phase): + calls.append((msg, phase)) + + with session._maybe_spinner("Working...", False, status_fn=status_fn): + pass + + assert ("Working...", "start") in calls + assert ("Working...", "end") in calls + + def test_maybe_spinner_plain_noop(self, tmp_project): + """_maybe_spinner with no styling and no status_fn is a no-op.""" + session, _, _ = self._make_session(tmp_project) + + with session._maybe_spinner("msg", False): + pass + + # ---------------------------------------------------------- + # Line 324: slash command push breaks interactive loop + # ---------------------------------------------------------- + + def test_slash_command_push_breaks_loop(self, tmp_project): + """When /push returns 'pushed', the loop breaks (line 324).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( + f"{_SESSION_MODULE}.push_github_issue" + ) as mock_push: + mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} + + output = [] + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + input_fn=lambda p: "/push", + print_fn=output.append, + ) + + assert result.items_pushed == 1 + assert not result.cancelled + + # ---------------------------------------------------------- + # Line 672: push_all devops feature direct call + # ---------------------------------------------------------- + + def test_push_all_devops_feature_direct(self, tmp_project): + """_push_all with devops calls push_devops_feature (line 672).""" + session, state, _ = self._make_session(tmp_project) + state.set_items([{"title": "F1"}]) + + with patch(f"{_SESSION_MODULE}.check_devops_ext", return_value=True), patch( + f"{_SESSION_MODULE}.push_devops_feature" + ) as mock_feat: + mock_feat.return_value = { + "id": 1, + "url": "https://dev.azure.com/o/p/1", + } + + output = [] + result = session._push_all("devops", "o", "p", output.append, False) + + assert result.items_pushed == 1 + mock_feat.assert_called_once() + + # ---------------------------------------------------------- + # Line 283: styled prompt (test use_styled=True paths + # via console mock) + # ---------------------------------------------------------- + + def test_use_styled_calls_prompt(self, tmp_project): + """With use_styled=True, prompt is used (line 283).""" + items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) + session, state, _ = self._make_session(tmp_project, items_response=items_json) + + # Mock the prompt to return "done" + session._prompt = MagicMock() + session._prompt.prompt.return_value = "done" + + # Run without input_fn/print_fn (use_styled=True) + # But we need to suppress real console output + session._console = MagicMock() + session._console.print = MagicMock() + session._console.spinner = MagicMock() + session._console.spinner.return_value.__enter__ = MagicMock() + session._console.spinner.return_value.__exit__ = MagicMock(return_value=False) + + result = session.run( + design_context="arch", + provider="github", + org="o", + project="p", + ) + session._prompt.prompt.assert_called() + assert result.items_generated == 1 diff --git a/tests/stages/test_build_session.py b/tests/stages/test_build_session.py index 920651c..98ddd1c 100644 --- a/tests/stages/test_build_session.py +++ b/tests/stages/test_build_session.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """Tests for build session re-entry paths and stage status transitions. Tier 1: CRITICAL — these test the exact code paths that caused the @@ -39,46 +41,46 @@ def build_context(project_with_design, sample_config): @pytest.fixture -def build_registry(): +def build_registry(mock_tf_agent, mock_dev_agent, mock_doc_agent, mock_architect_agent_for_build, mock_qa_agent): registry = MagicMock() - mock_tf = MagicMock() - mock_tf.name = "terraform-agent" - mock_tf._include_standards = True - mock_tf._temperature = 0.2 - mock_tf._max_tokens = 4096 - mock_tf.set_knowledge_override = MagicMock() - mock_tf.set_governor_brief = MagicMock() - mock_tf.get_system_messages = MagicMock(return_value=[]) - mock_tf._governance_aware = False - mock_tf._enable_web_search = False - mock_tf._enable_mcp_tools = False - - mock_doc = MagicMock() - mock_doc.name = "doc-agent" - mock_doc._include_standards = True - mock_doc.set_knowledge_override = MagicMock() - mock_doc.set_governor_brief = MagicMock() - mock_doc.get_system_messages = MagicMock(return_value=[]) - mock_doc._governance_aware = False - mock_doc._enable_web_search = False - mock_doc._enable_mcp_tools = False - - mock_qa = MagicMock() - mock_qa.name = "qa-engineer" - - mock_architect = MagicMock() - mock_architect.name = "cloud-architect" - mock_architect.execute = MagicMock(return_value=MagicMock(content="{}", model="test", usage={})) + # Ensure tf agent has the attributes mirrored tests expect + mock_tf_agent._include_standards = True + mock_tf_agent._temperature = 0.2 + mock_tf_agent._max_tokens = 4096 + mock_tf_agent.set_knowledge_override = MagicMock() + mock_tf_agent.set_governor_brief = MagicMock() + mock_tf_agent.get_system_messages = MagicMock(return_value=[]) + mock_tf_agent._governance_aware = False + mock_tf_agent._enable_web_search = False + mock_tf_agent._enable_mcp_tools = False + + # Ensure doc agent has the attributes mirrored tests expect + mock_doc_agent._include_standards = True + mock_doc_agent.set_knowledge_override = MagicMock() + mock_doc_agent.set_governor_brief = MagicMock() + mock_doc_agent.get_system_messages = MagicMock(return_value=[]) + mock_doc_agent._governance_aware = False + mock_doc_agent._enable_web_search = False + mock_doc_agent._enable_mcp_tools = False + + # Ensure dev agent has the attributes mirrored tests expect + mock_dev_agent._include_standards = True + mock_dev_agent.set_knowledge_override = MagicMock() + mock_dev_agent.set_governor_brief = MagicMock() + mock_dev_agent.get_system_messages = MagicMock(return_value=[]) + mock_dev_agent._governance_aware = False + mock_dev_agent._enable_web_search = False + mock_dev_agent._enable_mcp_tools = False def find_by_cap(cap): mapping = { - AgentCapability.TERRAFORM: [mock_tf], + AgentCapability.TERRAFORM: [mock_tf_agent], AgentCapability.BICEP: [], - AgentCapability.DEVELOP: [], - AgentCapability.DOCUMENT: [mock_doc], - AgentCapability.ARCHITECT: [mock_architect], - AgentCapability.QA: [mock_qa], + AgentCapability.DEVELOP: [mock_dev_agent], + AgentCapability.DOCUMENT: [mock_doc_agent], + AgentCapability.ARCHITECT: [mock_architect_agent_for_build], + AgentCapability.QA: [mock_qa_agent], } return mapping.get(cap, []) @@ -3445,3 +3447,4055 @@ def mock_input(prompt): ) assert run_result is not None + +# --- Additional imports from merged flat test --- +from azext_prototype.ai.provider import AIResponse +import yaml + + +# ====================================================================== +# Helpers +# ====================================================================== + + +def _make_response(content: str = "Mock response", finish_reason: str = "stop") -> AIResponse: + return AIResponse(content=content, model="gpt-4o", usage={}, finish_reason=finish_reason) + + +def _make_file_response(filename: str = "main.tf", code: str = "# placeholder") -> AIResponse: + """Return an AIResponse whose content has a fenced file block.""" + return AIResponse( + content=f"Here is the code:\n\n```{filename}\n{code}\n```\n", + model="gpt-4o", + usage={}, + ) + + +# ====================================================================== +# BuildState tests +# ====================================================================== + + +class TestBuildState: + + def test_default_state_structure(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + state = bs.state + assert isinstance(state["templates_used"], list) + assert state["iac_tool"] == "terraform" + assert state["deployment_stages"] == [] + assert state["policy_checks"] == [] + assert state["policy_overrides"] == [] + assert state["files_generated"] == [] + assert state["resources"] == [] + assert state["_metadata"]["iteration"] == 0 + + def test_load_save_roundtrip(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs._state["templates_used"] = ["web-app"] + bs._state["iac_tool"] = "bicep" + bs.save() + + bs2 = BuildState(str(tmp_project)) + loaded = bs2.load() + assert loaded["templates_used"] == ["web-app"] + assert loaded["iac_tool"] == "bicep" + + def test_set_deployment_plan(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + stages = [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [ + { + "name": "key-vault", + "computed_name": "zd-kv-api-dev-eus", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + }, + ], + "status": "pending", + "dir": "concept/infra/terraform/stage-1-foundation", + "files": [], + }, + ] + bs.set_deployment_plan(stages) + + assert len(bs.state["deployment_stages"]) == 1 + assert bs.state["deployment_stages"][0]["services"][0]["computed_name"] == "zd-kv-api-dev-eus" + # Resources should be rebuilt + assert len(bs.state["resources"]) == 1 + assert bs.state["resources"][0]["resourceType"] == "Microsoft.KeyVault/vaults" + + def test_mark_stage_generated(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "pending", + "dir": "", + "files": [], + }, + ] + ) + + bs.mark_stage_generated(1, ["main.tf", "variables.tf"], "terraform-agent") + + stage = bs.get_stage(1) + assert stage["status"] == "generated" + assert stage["files"] == ["main.tf", "variables.tf"] + assert len(bs.state["generation_log"]) == 1 + assert bs.state["generation_log"][0]["agent"] == "terraform-agent" + assert "main.tf" in bs.state["files_generated"] + + def test_mark_stage_accepted(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + ] + ) + bs.mark_stage_accepted(1) + assert bs.get_stage(1)["status"] == "accepted" + + def test_add_policy_override(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.add_policy_override("managed-identity", "Using connection string for legacy service") + + assert len(bs.state["policy_overrides"]) == 1 + assert bs.state["policy_overrides"][0]["rule_id"] == "managed-identity" + + def test_get_pending_stages(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.set_deployment_plan( + [ + { + "stage": 1, + "name": "A", + "capability": "infra", + "services": [], + "status": "pending", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "B", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + { + "stage": 3, + "name": "C", + "capability": "app", + "services": [], + "status": "pending", + "dir": "", + "files": [], + }, + ] + ) + + pending = bs.get_pending_stages() + assert len(pending) == 2 + assert pending[0]["stage"] == 1 + assert pending[1]["stage"] == 3 + + def test_get_all_resources(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [ + { + "name": "kv", + "computed_name": "kv-1", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + }, + { + "name": "id", + "computed_name": "id-1", + "resource_type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "sku": "", + }, + ], + "status": "pending", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "Data", + "capability": "data", + "services": [ + { + "name": "sql", + "computed_name": "sql-1", + "resource_type": "Microsoft.Sql/servers", + "sku": "serverless", + }, + ], + "status": "pending", + "dir": "", + "files": [], + }, + ] + ) + + resources = bs.get_all_resources() + assert len(resources) == 3 + types = {r["resourceType"] for r in resources} + assert "Microsoft.KeyVault/vaults" in types + assert "Microsoft.Sql/servers" in types + + def test_format_build_report(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs._state["templates_used"] = ["web-app"] + bs.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [ + { + "name": "kv", + "computed_name": "zd-kv-dev", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + } + ], + "status": "generated", + "dir": "", + "files": ["main.tf"], + }, + ] + ) + bs._state["files_generated"] = ["main.tf"] + + report = bs.format_build_report() + assert "web-app" in report + assert "Foundation" in report + assert "zd-kv-dev" in report + assert "1" in report # Total files + + def test_format_stage_status(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "pending", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "Data", + "capability": "data", + "services": [], + "status": "generated", + "dir": "", + "files": ["sql.tf"], + }, + ] + ) + + status = bs.format_stage_status() + assert "Foundation" in status + assert "Data" in status + assert "1/2" in status # Progress + + def test_multiple_templates_used(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs._state["templates_used"] = ["web-app", "data-pipeline"] + bs.save() + + bs2 = BuildState(str(tmp_project)) + bs2.load() + assert bs2.state["templates_used"] == ["web-app", "data-pipeline"] + + def test_add_review_decision(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.add_review_decision("Please add logging to stage 2", iteration=1) + + assert len(bs.state["review_decisions"]) == 1 + assert bs.state["review_decisions"][0]["feedback"] == "Please add logging to stage 2" + assert bs.state["_metadata"]["iteration"] == 1 + + def test_reset(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs._state["templates_used"] = ["web-app"] + bs.save() + + bs.reset() + assert bs.state["templates_used"] == [] + assert bs.exists # File still exists after reset + + +# ====================================================================== +# PolicyResolver tests +# ====================================================================== + + +class TestPolicyResolver: + + def test_no_violations_no_prompt(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + from azext_prototype.stages.policy_resolver import PolicyResolver + + governance = MagicMock() + governance.check_response_for_violations.return_value = [] + + resolver = PolicyResolver(governance_context=governance) + build_state = BuildState(str(tmp_project)) + + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "resource group code", + build_state, + stage_num=1, + input_fn=lambda p: "", + print_fn=lambda m: None, + ) + + assert resolutions == [] + assert needs_regen is False + + def test_violation_accept_compliant(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + from azext_prototype.stages.policy_resolver import PolicyResolver + + governance = MagicMock() + governance.check_response_for_violations.return_value = [ + "[managed-identity] Possible anti-pattern: connection string detected" + ] + + resolver = PolicyResolver(governance_context=governance) + build_state = BuildState(str(tmp_project)) + + printed = [] + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "code with connection_string", + build_state, + stage_num=1, + input_fn=lambda p: "a", # Accept + print_fn=lambda m: printed.append(m), + ) + + assert len(resolutions) == 1 + assert resolutions[0].action == "accept" + assert needs_regen is False + + def test_violation_override_persists(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + from azext_prototype.stages.policy_resolver import PolicyResolver + + governance = MagicMock() + governance.check_response_for_violations.return_value = [ + "[managed-identity] Use managed identity instead of keys" + ] + + resolver = PolicyResolver(governance_context=governance) + build_state = BuildState(str(tmp_project)) + + inputs = iter(["o", "Legacy service requires keys"]) + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "code with access_key", + build_state, + stage_num=1, + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + + assert len(resolutions) == 1 + assert resolutions[0].action == "override" + assert resolutions[0].justification == "Legacy service requires keys" + assert needs_regen is False + # Should be persisted in build state + assert len(build_state.state["policy_overrides"]) == 1 + + def test_violation_regenerate_flag(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + from azext_prototype.stages.policy_resolver import PolicyResolver + + governance = MagicMock() + governance.check_response_for_violations.return_value = ["[managed-identity] Hardcoded credential detected"] + + resolver = PolicyResolver(governance_context=governance) + build_state = BuildState(str(tmp_project)) + + resolutions, needs_regen = resolver.check_and_resolve( + "terraform-agent", + "bad code", + build_state, + stage_num=1, + input_fn=lambda p: "r", # Regenerate + print_fn=lambda m: None, + ) + + assert len(resolutions) == 1 + assert resolutions[0].action == "regenerate" + assert needs_regen is True + + def test_build_fix_instructions(self): + from azext_prototype.stages.policy_resolver import ( + PolicyResolution, + PolicyResolver, + ) + + resolver = PolicyResolver(governance_context=MagicMock()) + resolutions = [ + PolicyResolution( + rule_id="managed-identity", + action="regenerate", + violation_text="[managed-identity] Use MI instead of keys", + ), + PolicyResolution( + rule_id="key-vault", + action="override", + justification="Legacy requirement", + violation_text="[key-vault] Secrets should use Key Vault", + ), + ] + + instructions = resolver.build_fix_instructions(resolutions) + assert "Policy Fix Instructions" in instructions + assert "[managed-identity]" in instructions + assert "Legacy requirement" in instructions + + def test_extract_rule_id(self): + from azext_prototype.stages.policy_resolver import PolicyResolver + + assert PolicyResolver._extract_rule_id("[managed-identity] Some violation") == "managed-identity" + assert PolicyResolver._extract_rule_id("No brackets here") == "unknown" + assert PolicyResolver._extract_rule_id("[kv-001] Key Vault issue") == "kv-001" + + +# ====================================================================== +# BuildSession fixtures +# ====================================================================== + + +@pytest.fixture +def mock_tf_agent(): + agent = MagicMock() + agent.name = "terraform-agent" + agent.execute.return_value = _make_file_response( + "main.tf", 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' + ) + return agent + + +@pytest.fixture +def mock_dev_agent(): + agent = MagicMock() + agent.name = "app-developer" + agent.execute.return_value = _make_file_response("app.py", "# app code") + return agent + + +@pytest.fixture +def mock_doc_agent(): + agent = MagicMock() + agent.name = "doc-agent" + agent.execute.return_value = _make_file_response("DEPLOYMENT.md", "# Deployment Guide") + return agent + + +@pytest.fixture +def mock_architect_agent_for_build(): + agent = MagicMock() + agent.name = "cloud-architect" + # Return a JSON deployment plan + plan = { + "stages": [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1-foundation", + "services": [ + { + "name": "key-vault", + "computed_name": "zd-kv-test-dev-eus", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + }, + ], + "status": "pending", + "files": [], + }, + { + "stage": 2, + "name": "Documentation", + "capability": "docs", + "dir": "concept/docs", + "services": [], + "status": "pending", + "files": [], + }, + ] + } + agent.execute.return_value = _make_response(f"```json\n{json.dumps(plan)}\n```") + return agent + + +@pytest.fixture +def mock_qa_agent(): + agent = MagicMock() + agent.name = "qa-engineer" + return agent + + +# ====================================================================== +# BuildSession tests +# ====================================================================== + + +class TestBuildSession: + + def test_session_creates_with_agents(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + assert session._iac_agents.get("terraform") is not None + assert session._dev_agent is not None + assert session._doc_agent is not None + assert session._architect_agent is not None + assert session._qa_agent is not None + + def test_quit_cancels(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + inputs = iter(["quit"]) + + result = session.run( + design={"architecture": "Sample architecture"}, + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + + assert result.cancelled is True + + def test_done_accepts(self, build_context, build_registry, mock_architect_agent_for_build): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + # First input: confirm plan (empty = proceed), then "done" to accept + inputs = iter(["", "done"]) + + # Patch governance to skip violations + with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: + mock_gov_cls.return_value.check_response_for_violations.return_value = [] + session._governance = mock_gov_cls.return_value + session._policy_resolver._governance = mock_gov_cls.return_value + + # Patch AgentOrchestrator.delegate to avoid real QA call + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: + mock_orch.return_value.delegate.return_value = _make_response("QA looks good") + + result = session.run( + design={"architecture": "Sample architecture with key-vault and sql-database"}, + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + + assert result.cancelled is False + assert result.review_accepted is True + + def test_deployment_plan_derivation(self, build_context, build_registry, mock_architect_agent_for_build): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + # The architect agent returns a JSON plan; test that it's parsed correctly + plan_json = { + "stages": [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1-foundation", + "services": [ + { + "name": "kv", + "computed_name": "zd-kv-dev", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + } + ], + "status": "pending", + "files": [], + }, + { + "stage": 2, + "name": "Apps", + "capability": "app", + "dir": "concept/apps/stage-2-api", + "services": [], + "status": "pending", + "files": [], + }, + ] + } + mock_architect_agent_for_build.execute.return_value = _make_response(f"```json\n{json.dumps(plan_json)}\n```") + + stages = session._derive_deployment_plan("Sample architecture", []) + assert len(stages) == 2 + assert stages[0]["name"] == "Foundation" + assert stages[0]["services"][0]["computed_name"] == "zd-kv-dev" + assert stages[1]["capability"] == "app" + + def test_fallback_deployment_plan(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + # Force no architect + build_registry.find_by_capability.side_effect = lambda cap: [] + session = BuildSession(build_context, build_registry) + + stages = session._fallback_deployment_plan([]) + assert len(stages) >= 2 # Managed Identity + Documentation at minimum + assert stages[0]["name"] == "Managed Identity" + assert stages[0]["layer"] == "core" + assert stages[-1]["name"] == "Documentation" + assert stages[-1]["layer"] == "docs" + + def test_template_matching_web_app(self, project_with_design, sample_config): + from azext_prototype.stages.build_stage import BuildStage + + stage = BuildStage() + design = { + "architecture": ( + "The system uses container-apps for the API, " + "sql-database for persistence, key-vault for secrets, " + "api-management as the gateway, and a virtual-network." + ) + } + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(str(project_with_design)) + config.load() + + templates = stage._match_templates(design, config) + # web-app template should match (container-apps, sql-database, key-vault, api-management, virtual-network) + assert len(templates) >= 1 + names = [t.name for t in templates] + assert "web-app" in names + + def test_template_matching_no_match(self, project_with_design, sample_config): + from azext_prototype.stages.build_stage import BuildStage + + stage = BuildStage() + design = {"architecture": "This is a simple static website with no Azure services mentioned."} + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(str(project_with_design)) + config.load() + + templates = stage._match_templates(design, config) + assert templates == [] + + def test_parse_deployment_plan_json_block(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + content = '```json\n{"stages": [{"stage": 1, "name": "Test", "capability": "infra"}]}\n```' + stages = session._parse_deployment_plan(content) + assert len(stages) == 1 + assert stages[0]["name"] == "Test" + + def test_parse_deployment_plan_raw_json(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + content = '{"stages": [{"stage": 1, "name": "Raw"}]}' + stages = session._parse_deployment_plan(content) + assert len(stages) == 1 + assert stages[0]["name"] == "Raw" + + def test_parse_deployment_plan_invalid(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + stages = session._parse_deployment_plan("This is not JSON at all") + assert stages == [] + + def test_identify_affected_stages_by_number(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "Data", + "capability": "data", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + ] + ) + + affected = session._identify_affected_stages("Please fix stage 2") + assert affected == [2] + + def test_identify_affected_stages_by_name(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "Data", + "capability": "data", + "services": [{"name": "sql-server", "computed_name": "sql-1", "resource_type": "", "sku": ""}], + "status": "generated", + "dir": "", + "files": [], + }, + ] + ) + + affected = session._identify_affected_stages("The sql-server configuration is wrong") + assert 2 in affected + + def test_slash_command_status(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + ] + ) + + printed = [] + session._handle_slash_command("/status", lambda m: printed.append(m)) + output = "\n".join(printed) + assert "Foundation" in output + + def test_slash_command_files(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._build_state._state["files_generated"] = ["main.tf", "variables.tf"] + + printed = [] + session._handle_slash_command("/files", lambda m: printed.append(m)) + output = "\n".join(printed) + assert "main.tf" in output + assert "variables.tf" in output + + def test_slash_command_policy(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + # No checks yet + printed = [] + session._handle_slash_command("/policy", lambda m: printed.append(m)) + output = "\n".join(printed) + assert "No policy checks" in output + + def test_slash_command_help(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + printed = [] + session._handle_slash_command("/help", lambda m: printed.append(m)) + output = "\n".join(printed) + assert "/status" in output + assert "/files" in output + assert "done" in output + + def test_categorize_service(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._categorize_service("key-vault") == "infra" + assert BuildSession._categorize_service("sql-database") == "data" + assert BuildSession._categorize_service("container-apps") == "app" + assert BuildSession._categorize_service("unknown-service") == "app" + + def test_normalize_stages(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + raw = [ + {"stage": 1, "name": "Test", "capability": "infra"}, + {"name": "No Stage Num"}, + ] + normalized = session._normalize_stages(raw) + assert len(normalized) == 2 + assert normalized[0]["status"] == "pending" + assert normalized[0]["files"] == [] + assert normalized[0]["layer"] == "infra" # Inferred from capability + assert normalized[1]["stage"] == 2 # Auto-assigned + assert normalized[1]["layer"] == "infra" # Default + + def test_normalize_stages_preserves_explicit_layer(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + raw = [ + {"stage": 1, "name": "Key Vault", "layer": "data", "capability": "data"}, + {"stage": 2, "name": "API", "layer": "app", "capability": "app"}, + ] + normalized = session._normalize_stages(raw) + assert normalized[0]["layer"] == "data" + assert normalized[1]["layer"] == "app" + + def test_normalize_stages_infers_core_for_identity(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + raw = [ + {"stage": 1, "name": "Managed Identity", "capability": "infra"}, + {"stage": 2, "name": "Log Analytics", "capability": "infra"}, + ] + normalized = session._normalize_stages(raw) + assert normalized[0]["layer"] == "core" + assert normalized[1]["layer"] == "core" + + def test_reentrant_skips_generated_stages(self, build_context, build_registry, mock_tf_agent, mock_doc_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + design = {"architecture": "Test"} + + # Pre-populate with a generated stage and matching design snapshot + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": ["main.tf"], + }, + { + "stage": 2, + "name": "Documentation", + "capability": "docs", + "services": [], + "status": "pending", + "dir": "concept/docs", + "files": [], + }, + ] + ) + session._build_state.set_design_snapshot(design) + + inputs = iter(["", "done"]) + + with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: + mock_gov_cls.return_value.check_response_for_violations.return_value = [] + session._governance = mock_gov_cls.return_value + session._policy_resolver._governance = mock_gov_cls.return_value + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: + mock_orch.return_value.delegate.return_value = _make_response("QA ok") + + session.run( + design=design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + + # Stage 1 (generated) should NOT have been re-run + # Only doc agent should have been called (for stage 2) + assert mock_tf_agent.execute.call_count == 0 + assert mock_doc_agent.execute.call_count == 1 + + + # Re-entry validating tests moved to tests/stages/test_build_session_reentry.py + + +# ====================================================================== +# Incremental build / design snapshot tests +# ====================================================================== + + +class TestDesignSnapshot: + """Tests for design snapshot tracking and change detection in BuildState.""" + + def test_design_snapshot_set_on_first_build(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + design = { + "architecture": "## Architecture\nKey Vault + SQL Database", + "_metadata": {"iteration": 3}, + } + bs.set_design_snapshot(design) + + snapshot = bs.state["design_snapshot"] + assert snapshot["iteration"] == 3 + assert snapshot["architecture_hash"] is not None + assert len(snapshot["architecture_hash"]) == 16 + assert snapshot["architecture_text"] == design["architecture"] + + def test_design_has_changed_detects_modification(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + original = {"architecture": "Key Vault + SQL"} + bs.set_design_snapshot(original) + + modified = {"architecture": "Key Vault + SQL + Redis Cache"} + assert bs.design_has_changed(modified) is True + + def test_design_has_changed_no_change(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + design = {"architecture": "Key Vault + SQL"} + bs.set_design_snapshot(design) + + assert bs.design_has_changed(design) is False + + def test_design_has_changed_legacy_no_snapshot(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + # No snapshot set — simulates legacy build + assert bs.design_has_changed({"architecture": "anything"}) is True + + def test_get_previous_architecture(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + assert bs.get_previous_architecture() is None + + design = {"architecture": "The full architecture text here"} + bs.set_design_snapshot(design) + assert bs.get_previous_architecture() == "The full architecture text here" + + def test_design_snapshot_persists_across_load(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + design = {"architecture": "Persistent arch", "_metadata": {"iteration": 2}} + bs.set_design_snapshot(design) + + bs2 = BuildState(str(tmp_project)) + bs2.load() + assert bs2.design_has_changed(design) is False + assert bs2.get_previous_architecture() == "Persistent arch" + + +class TestStageManipulation: + """Tests for mark_stages_stale, remove_stages, add_stages, renumber_stages.""" + + def _sample_stages(self): + return [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-foundation", + "files": ["main.tf"], + }, + { + "stage": 2, + "name": "Data", + "capability": "data", + "services": [ + {"name": "sql", "computed_name": "sql-1", "resource_type": "Microsoft.Sql/servers", "sku": ""} + ], + "status": "generated", + "dir": "concept/infra/terraform/stage-2-data", + "files": ["sql.tf"], + }, + { + "stage": 3, + "name": "App", + "capability": "app", + "services": [], + "status": "generated", + "dir": "concept/apps/stage-3-api", + "files": ["app.py"], + }, + { + "stage": 4, + "name": "Documentation", + "capability": "docs", + "services": [], + "status": "generated", + "dir": "concept/docs", + "files": ["DEPLOY.md"], + }, + ] + + def test_mark_stages_stale(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.set_deployment_plan(self._sample_stages()) + + bs.mark_stages_stale([2, 3]) + + assert bs.get_stage(1)["status"] == "generated" + assert bs.get_stage(2)["status"] == "pending" + assert bs.get_stage(3)["status"] == "pending" + assert bs.get_stage(4)["status"] == "generated" + + def test_remove_stages(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.set_deployment_plan(self._sample_stages()) + bs._state["files_generated"] = ["main.tf", "sql.tf", "app.py", "DEPLOY.md"] + + bs.remove_stages([2]) + + stage_nums = [s["stage"] for s in bs.state["deployment_stages"]] + assert 2 not in stage_nums + assert len(bs.state["deployment_stages"]) == 3 + # sql.tf should be removed from files_generated + assert "sql.tf" not in bs.state["files_generated"] + assert "main.tf" in bs.state["files_generated"] + + def test_add_stages(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.set_deployment_plan(self._sample_stages()) + + new_stages = [ + { + "name": "Redis Cache", + "capability": "data", + "services": [ + { + "name": "redis", + "computed_name": "redis-1", + "resource_type": "Microsoft.Cache/redis", + "sku": "Basic", + } + ], + }, + ] + bs.add_stages(new_stages) + + stages = bs.state["deployment_stages"] + # Should be inserted before docs (stage 4 originally) + # After renumbering: Foundation(1), Data(2), App(3), Redis(4), Docs(5) + assert len(stages) == 5 + assert stages[3]["name"] == "Redis Cache" + assert stages[3]["stage"] == 4 + assert stages[4]["name"] == "Documentation" + assert stages[4]["stage"] == 5 + + def test_renumber_stages(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + # Set up stages with gaps + bs._state["deployment_stages"] = [ + { + "stage": 1, + "name": "A", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + {"stage": 5, "name": "B", "capability": "data", "services": [], "status": "pending", "dir": "", "files": []}, + {"stage": 10, "name": "C", "capability": "docs", "services": [], "status": "pending", "dir": "", "files": []}, + ] + + bs.renumber_stages() + + assert bs.state["deployment_stages"][0]["stage"] == 1 + assert bs.state["deployment_stages"][1]["stage"] == 2 + assert bs.state["deployment_stages"][2]["stage"] == 3 + + +class TestArchitectureDiff: + """Tests for _diff_architectures and _parse_diff_result.""" + + def test_diff_architectures_parses_response(self, build_context, build_registry, mock_architect_agent_for_build): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + existing = [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [{"name": "key-vault"}], + "status": "generated", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "Data", + "capability": "data", + "services": [{"name": "sql"}], + "status": "generated", + "dir": "", + "files": [], + }, + ] + + diff_response = json.dumps( + { + "unchanged": [1], + "modified": [2], + "removed": [], + "added": [{"name": "Redis", "capability": "data", "services": []}], + "plan_restructured": False, + "summary": "Modified data stage; added Redis.", + } + ) + mock_architect_agent_for_build.execute.return_value = _make_response(f"```json\n{diff_response}\n```") + + result = session._diff_architectures("old arch", "new arch", existing) + + assert result["unchanged"] == [1] + assert result["modified"] == [2] + assert result["removed"] == [] + assert len(result["added"]) == 1 + assert result["added"][0]["name"] == "Redis" + assert result["plan_restructured"] is False + + def test_diff_architectures_fallback_no_architect(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + # Remove the architect agent + session = BuildSession(build_context, build_registry) + session._architect_agent = None + + existing = [ + { + "stage": 1, + "name": "A", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "B", + "capability": "data", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + ] + + result = session._diff_architectures("old", "new", existing) + + # Fallback: all stages marked as modified + assert set(result["modified"]) == {1, 2} + assert result["unchanged"] == [] + + def test_parse_diff_result_defaults_to_unchanged(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + existing = [ + { + "stage": 1, + "name": "A", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "B", + "capability": "data", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + {"stage": 3, "name": "C", "capability": "app", "services": [], "status": "generated", "dir": "", "files": []}, + ] + + # Only mention stage 2 as modified; 1 and 3 should default to unchanged + content = json.dumps({"modified": [2], "summary": "test"}) + result = session._parse_diff_result(content, existing) + + assert result is not None + assert 1 in result["unchanged"] + assert 3 in result["unchanged"] + assert result["modified"] == [2] + + def test_parse_diff_result_invalid_json(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + result = session._parse_diff_result("This is not JSON", []) + assert result is None + + +class TestIncrementalBuildSession: + """End-to-end tests for the incremental build flow.""" + + def test_incremental_run_no_changes(self, build_context, build_registry): + """When design hasn't changed and all stages are generated, report up to date.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + design = {"architecture": "Sample arch"} + + # Set up: pre-populate with generated stages and a matching snapshot + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": ["main.tf"], + }, + { + "stage": 2, + "name": "Docs", + "capability": "docs", + "services": [], + "status": "generated", + "dir": "concept/docs", + "files": ["README.md"], + }, + ] + ) + session._build_state.set_design_snapshot(design) + + printed = [] + inputs = iter(["done"]) + + result = session.run( + design=design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: printed.append(m), + ) + + output = "\n".join(printed) + assert "up to date" in output.lower() + assert result.review_accepted is True + + def test_incremental_run_with_changes( + self, build_context, build_registry, mock_architect_agent_for_build, mock_tf_agent + ): + """When design has changed, only affected stages should be regenerated.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + old_design = {"architecture": "Original architecture with Key Vault"} + new_design = {"architecture": "Updated architecture with Key Vault + Redis"} + + # Set up existing build + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [{"name": "key-vault"}], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-foundation", + "files": ["main.tf"], + }, + { + "stage": 2, + "name": "Documentation", + "capability": "docs", + "services": [], + "status": "generated", + "dir": "concept/docs", + "files": ["README.md"], + }, + ] + ) + session._build_state.set_design_snapshot(old_design) + + # Mock architect: stage 1 unchanged, no removed, add Redis + diff_response = json.dumps( + { + "unchanged": [1], + "modified": [], + "removed": [], + "added": [ + { + "name": "Redis Cache", + "capability": "data", + "services": [ + { + "name": "redis-cache", + "computed_name": "redis-1", + "resource_type": "Microsoft.Cache/redis", + "sku": "Basic", + } + ], + } + ], + "plan_restructured": False, + "summary": "Added Redis Cache stage.", + } + ) + mock_architect_agent_for_build.execute.return_value = _make_response(f"```json\n{diff_response}\n```") + + printed = [] + inputs = iter(["", "done"]) + + with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: + mock_gov_cls.return_value.check_response_for_violations.return_value = [] + session._governance = mock_gov_cls.return_value + session._policy_resolver._governance = mock_gov_cls.return_value + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: + mock_orch.return_value.delegate.return_value = _make_response("QA ok") + + result = session.run( + design=new_design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: printed.append(m), + ) + + output = "\n".join(printed) + assert "Design changes detected" in output + assert "Added 1 new stage" in output + assert result.cancelled is False + + def test_incremental_run_plan_restructured( + self, build_context, build_registry, mock_architect_agent_for_build, mock_tf_agent + ): + """When plan_restructured is True, a full re-derive should be offered.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + old_design = {"architecture": "Simple architecture"} + new_design = {"architecture": "Completely redesigned architecture"} + + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": ["main.tf"], + }, + ] + ) + session._build_state.set_design_snapshot(old_design) + + # First call: diff says plan_restructured + diff_response = json.dumps( + { + "unchanged": [], + "modified": [1], + "removed": [], + "added": [], + "plan_restructured": True, + "summary": "Major restructuring needed.", + } + ) + + # Second call: re-derive returns new plan + new_plan = { + "stages": [ + { + "stage": 1, + "name": "New Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1-new", + "services": [], + "status": "pending", + "files": [], + }, + { + "stage": 2, + "name": "Documentation", + "capability": "docs", + "dir": "concept/docs", + "services": [], + "status": "pending", + "files": [], + }, + ] + } + + call_count = [0] + + def architect_side_effect(ctx, task): + call_count[0] += 1 + if call_count[0] == 1: + return _make_response(f"```json\n{diff_response}\n```") + else: + return _make_response(f"```json\n{json.dumps(new_plan)}\n```") + + mock_architect_agent_for_build.execute.side_effect = architect_side_effect + + printed = [] + # First prompt: confirm re-derive (Enter), second: confirm plan, third: done + inputs = iter(["", "", "done"]) + + with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: + mock_gov_cls.return_value.check_response_for_violations.return_value = [] + session._governance = mock_gov_cls.return_value + session._policy_resolver._governance = mock_gov_cls.return_value + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: + mock_orch.return_value.delegate.return_value = _make_response("QA ok") + + result = session.run( + design=new_design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: printed.append(m), + ) + + output = "\n".join(printed) + assert "full plan re-derive" in output.lower() + assert result.cancelled is False + + +# ====================================================================== +# Telemetry tests +# ====================================================================== + + +class TestMultiResourceTelemetry: + + def test_track_build_resources_single(self): + from azext_prototype.telemetry import track_build_resources + + with patch("azext_prototype.telemetry.is_enabled", return_value=True), patch( + "azext_prototype.telemetry._get_ingestion_config", return_value=("http://test/v2/track", "key") + ), patch("azext_prototype.telemetry._send_envelope") as mock_send: + + track_build_resources( + "prototype build", + resources=[{"resourceType": "Microsoft.KeyVault/vaults", "sku": "standard"}], + ) + + assert mock_send.called + envelope = mock_send.call_args[0][0] + props = envelope["data"]["baseData"]["properties"] + assert props["resourceCount"] == "1" + assert "Microsoft.KeyVault/vaults" in props["resources"] + assert props["resourceType"] == "Microsoft.KeyVault/vaults" + assert props["sku"] == "standard" + + def test_track_build_resources_multiple(self): + from azext_prototype.telemetry import track_build_resources + + with patch("azext_prototype.telemetry.is_enabled", return_value=True), patch( + "azext_prototype.telemetry._get_ingestion_config", return_value=("http://test/v2/track", "key") + ), patch("azext_prototype.telemetry._send_envelope") as mock_send: + + resources = [ + {"resourceType": "Microsoft.KeyVault/vaults", "sku": "standard"}, + {"resourceType": "Microsoft.Sql/servers", "sku": "serverless"}, + {"resourceType": "Microsoft.Web/sites", "sku": "P1v3"}, + ] + track_build_resources("prototype build", resources=resources) + + envelope = mock_send.call_args[0][0] + props = envelope["data"]["baseData"]["properties"] + assert props["resourceCount"] == "3" + parsed = json.loads(props["resources"]) + assert len(parsed) == 3 + + def test_track_build_resources_backward_compat(self): + from azext_prototype.telemetry import track_build_resources + + with patch("azext_prototype.telemetry.is_enabled", return_value=True), patch( + "azext_prototype.telemetry._get_ingestion_config", return_value=("http://test/v2/track", "key") + ), patch("azext_prototype.telemetry._send_envelope") as mock_send: + + resources = [ + {"resourceType": "Microsoft.KeyVault/vaults", "sku": "standard"}, + {"resourceType": "Microsoft.Sql/servers", "sku": "serverless"}, + ] + track_build_resources("prototype build", resources=resources) + + envelope = mock_send.call_args[0][0] + props = envelope["data"]["baseData"]["properties"] + # Backward compat: first resource maps to legacy scalar fields + assert props["resourceType"] == "Microsoft.KeyVault/vaults" + assert props["sku"] == "standard" + + def test_track_build_resources_empty(self): + from azext_prototype.telemetry import track_build_resources + + with patch("azext_prototype.telemetry.is_enabled", return_value=True), patch( + "azext_prototype.telemetry._get_ingestion_config", return_value=("http://test/v2/track", "key") + ), patch("azext_prototype.telemetry._send_envelope") as mock_send: + + track_build_resources("prototype build", resources=[]) + + envelope = mock_send.call_args[0][0] + props = envelope["data"]["baseData"]["properties"] + assert props["resourceCount"] == "0" + assert props["resourceType"] == "" + assert props["sku"] == "" + + def test_track_build_resources_disabled(self): + from azext_prototype.telemetry import track_build_resources + + with patch("azext_prototype.telemetry.is_enabled", return_value=False), patch( + "azext_prototype.telemetry._send_envelope" + ) as mock_send: + + track_build_resources("prototype build", resources=[{"resourceType": "test", "sku": ""}]) + assert not mock_send.called + + +# ====================================================================== +# BuildStage integration tests +# ====================================================================== + + +class TestBuildStageIntegration: + + def test_build_stage_dry_run(self, project_with_design, sample_config): + from azext_prototype.stages.build_stage import BuildStage + + stage = BuildStage() + provider = MagicMock() + provider.provider_name = "github-models" + + context = AgentContext( + project_config=sample_config, + project_dir=str(project_with_design), + ai_provider=provider, + ) + + from azext_prototype.agents.registry import AgentRegistry + + registry = AgentRegistry() + + printed = [] + result = stage.execute( + context, + registry, + dry_run=True, + print_fn=lambda m: printed.append(m), + ) + + assert result["status"] == "dry-run" + output = "\n".join(printed) + assert "DRY RUN" in output + + def test_build_stage_status_flag(self, project_with_design, sample_config): + """The --status flag should show build status and exit (tested via custom.py).""" + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(project_with_design)) + bs.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": ["main.tf"], + }, + ] + ) + + # Verify the state file exists and is loadable + bs2 = BuildState(str(project_with_design)) + assert bs2.exists + bs2.load() + assert bs2.format_stage_status() # Should produce output + + +# ====================================================================== +# _agent_build_context tests +# ====================================================================== + + +class TestAgentBuildContext: + """Tests for the _agent_build_context context manager.""" + + def test_agent_build_context_sets_and_restores_standards(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + # Mock the agent's attributes and methods + mock_tf_agent._include_standards = True + mock_tf_agent._governor_brief = "" + mock_tf_agent.set_knowledge_override = MagicMock() + mock_tf_agent.set_governor_brief = MagicMock() + + stage = {"name": "Foundation", "services": [{"name": "key-vault"}]} + + with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): + with session._agent_build_context(mock_tf_agent, stage): + # Standards remain enabled during generation (agent-scoped filtering) + assert mock_tf_agent._include_standards is True + + # After exiting, standards unchanged + assert mock_tf_agent._include_standards is True + + def test_agent_build_context_clears_knowledge_on_exit(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + mock_tf_agent._include_standards = True + mock_tf_agent.set_knowledge_override = MagicMock() + mock_tf_agent.set_governor_brief = MagicMock() + + stage = {"name": "Foundation", "services": []} + + with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): + with session._agent_build_context(mock_tf_agent, stage): + pass + + mock_tf_agent.set_knowledge_override.assert_called_with("") + + def test_agent_build_context_calls_governor_and_knowledge(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + mock_tf_agent._include_standards = False + mock_tf_agent.set_knowledge_override = MagicMock() + mock_tf_agent.set_governor_brief = MagicMock() + + stage = {"name": "Data", "layer": "infra", "services": [{"name": "sql-server"}]} + + with patch.object(session, "_apply_governor_brief") as mock_gov, patch.object( + session, "_apply_stage_knowledge" + ) as mock_know: + with session._agent_build_context(mock_tf_agent, stage): + pass + + mock_gov.assert_called_once_with(mock_tf_agent, "Data", [{"name": "sql-server"}], "infra") + mock_know.assert_called_once_with(mock_tf_agent, stage) + + def test_agent_build_context_restores_on_exception(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + mock_tf_agent._include_standards = True + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"name": "Foundation", "services": []} + + with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): + try: + with session._agent_build_context(mock_tf_agent, stage): + raise ValueError("test error") + except ValueError: + pass + + # Standards should still be restored despite the exception + assert mock_tf_agent._include_standards is True + mock_tf_agent.set_knowledge_override.assert_called_with("") + + +# ====================================================================== +# _apply_stage_knowledge tests +# ====================================================================== + + +class TestApplyStageKnowledge: + """Tests for _apply_stage_knowledge with different knowledge scenarios.""" + + def test_apply_stage_knowledge_with_services(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"services": [{"name": "key-vault"}, {"name": "sql-server"}]} + + with patch("azext_prototype.stages.build_session.KnowledgeLoader", create=True) as MockLoader: + mock_loader = MockLoader.return_value + mock_loader.compose_context.return_value = "Key vault knowledge\nSQL knowledge" + # Patch the import inside the method + with patch.dict("sys.modules", {"azext_prototype.knowledge": MagicMock(KnowledgeLoader=MockLoader)}): + session._apply_stage_knowledge(mock_tf_agent, stage) + + mock_tf_agent.set_knowledge_override.assert_called_once() + call_arg = mock_tf_agent.set_knowledge_override.call_args[0][0] + assert "Key vault knowledge" in call_arg + + def test_apply_stage_knowledge_empty_services(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"services": []} + + with patch("azext_prototype.stages.build_session.KnowledgeLoader", create=True) as MockLoader: + mock_loader = MockLoader.return_value + mock_loader.compose_context.return_value = "" + with patch.dict("sys.modules", {"azext_prototype.knowledge": MagicMock(KnowledgeLoader=MockLoader)}): + session._apply_stage_knowledge(mock_tf_agent, stage) + + # Empty knowledge should not call set_knowledge_override + mock_tf_agent.set_knowledge_override.assert_not_called() + + def test_apply_stage_knowledge_truncates_large_knowledge(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"services": [{"name": "key-vault"}]} + large_knowledge = "x" * 70000 # > 65536 threshold + + with patch("azext_prototype.stages.build_session.KnowledgeLoader", create=True) as MockLoader: + mock_loader = MockLoader.return_value + mock_loader.compose_context.return_value = large_knowledge + with patch.dict("sys.modules", {"azext_prototype.knowledge": MagicMock(KnowledgeLoader=MockLoader)}): + session._apply_stage_knowledge(mock_tf_agent, stage) + + call_arg = mock_tf_agent.set_knowledge_override.call_args[0][0] + assert len(call_arg) < 70000 + assert "truncated" in call_arg.lower() + + def test_apply_stage_knowledge_handles_import_error(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"services": [{"name": "key-vault"}]} + + # Force an import error — the method should silently pass + with patch.dict("sys.modules", {"azext_prototype.knowledge": None}): + session._apply_stage_knowledge(mock_tf_agent, stage) + + # Should not raise and should not call set_knowledge_override + mock_tf_agent.set_knowledge_override.assert_not_called() + + +# ====================================================================== +# _condense_architecture tests +# ====================================================================== + + +class TestCondenseArchitecture: + """Tests for _condense_architecture — cached, empty, unparseable responses.""" + + def test_condense_returns_cached_contexts(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, + {"stage": 2, "name": "Data", "capability": "data", "services": []}, + ] + + # Pre-populate cache in build_state + session._build_state._state["stage_contexts"] = { + "1": "## Stage 1: Foundation\nContext for stage 1", + "2": "## Stage 2: Data\nContext for stage 2", + } + + result = session._condense_architecture("full architecture", stages, use_styled=False) + + assert result[1] == "## Stage 1: Foundation\nContext for stage 1" + assert result[2] == "## Stage 2: Data\nContext for stage 2" + # AI provider should not be called when cache is available + build_context.ai_provider.chat.assert_not_called() + + def test_condense_returns_empty_when_no_ai_provider(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._context = AgentContext( + project_config=build_context.project_config, + project_dir=build_context.project_dir, + ai_provider=None, + ) + + stages = [{"stage": 1, "name": "Foundation", "capability": "infra", "services": []}] + + result = session._condense_architecture("architecture", stages, use_styled=False) + + assert result == {} + + def test_condense_parses_stage_sections(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, + {"stage": 2, "name": "Data", "capability": "data", "services": []}, + ] + + ai_response = AIResponse( + content=( + "## Stage 1: Foundation\n" + "Sets up resource group and managed identity.\n\n" + "## Stage 2: Data\n" + "Provisions SQL database with private endpoint." + ), + model="gpt-4o", + usage={"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300}, + ) + build_context.ai_provider.chat.return_value = ai_response + + result = session._condense_architecture("architecture text", stages, use_styled=False) + + assert 1 in result + assert 2 in result + assert "Foundation" in result[1] + assert "SQL database" in result[2] + + def test_condense_empty_response_returns_empty(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stages = [{"stage": 1, "name": "Foundation", "capability": "infra", "services": []}] + + # AI returns empty content + build_context.ai_provider.chat.return_value = AIResponse( + content="", + model="gpt-4o", + usage={}, + ) + + result = session._condense_architecture("architecture", stages, use_styled=False) + + assert result == {} + + def test_condense_unparseable_response_returns_empty(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stages = [{"stage": 1, "name": "Foundation", "capability": "infra", "services": []}] + + # AI returns content without any "## Stage N" headers + build_context.ai_provider.chat.return_value = AIResponse( + content="Here is some context without stage headers.", + model="gpt-4o", + usage={}, + ) + + result = session._condense_architecture("architecture", stages, use_styled=False) + + # No stage headers means parsing returns empty dict + assert result == {} + + def test_condense_exception_returns_empty(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stages = [{"stage": 1, "name": "Foundation", "capability": "infra", "services": []}] + + build_context.ai_provider.chat.side_effect = Exception("API error") + + result = session._condense_architecture("architecture", stages, use_styled=False) + + assert result == {} + + def test_condense_caches_result_in_build_state(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, + ] + + ai_response = AIResponse( + content="## Stage 1: Foundation\nContext here.", + model="gpt-4o", + usage={"prompt_tokens": 50, "completion_tokens": 50, "total_tokens": 100}, + ) + build_context.ai_provider.chat.return_value = ai_response + + session._condense_architecture("arch", stages, use_styled=False) + + # Verify the result was cached in build_state + cached = session._build_state._state.get("stage_contexts", {}) + assert "1" in cached + assert "Foundation" in cached["1"] + + +# ====================================================================== +# Layer-based routing decisions (QA, anti-pattern scan, IaC detection) +# ====================================================================== + + +class TestLayerBasedRouting: + """Verify that routing/filtering decisions use layer, not capability.""" + + def test_select_agent_core_routes_to_iac_not_architect(self, build_context, build_registry, mock_tf_agent): + """Core-layer stages generate IaC code via terraform/bicep agent.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + # Verify _iac_agents is populated + assert session._iac_agents.get("terraform") is mock_tf_agent + for capability in ("identity", "observability"): + agent = session._select_agent({"layer": "core", "capability": capability}) + assert agent is mock_tf_agent, f"Core/{capability} should route to IaC agent, got {agent}" + + def test_select_agent_all_iac_layers(self, build_context, build_registry, mock_tf_agent): + """All IaC layers (core, infra, data) route to IaC agent.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + iac_stages = [ + {"layer": "core", "capability": "identity"}, + {"layer": "core", "capability": "observability"}, + {"layer": "infra", "capability": "core-networking"}, + {"layer": "infra", "capability": "compute"}, + {"layer": "infra", "capability": "security"}, + {"layer": "data", "capability": "data-services"}, + {"layer": "data", "capability": "messaging"}, + ] + for stage in iac_stages: + agent = session._select_agent(stage) + assert agent is not None, f"No agent for {stage}" + + def test_apply_stage_knowledge_skips_docs_layer(self, build_context, build_registry, mock_tf_agent): + """Docs-layer stages skip knowledge loading.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_knowledge_override = MagicMock() + session._apply_stage_knowledge(mock_tf_agent, {"layer": "docs", "capability": "documentation", "services": []}) + mock_tf_agent.set_knowledge_override.assert_not_called() + + def test_apply_stage_knowledge_loads_for_core_layer(self, build_context, build_registry, mock_tf_agent): + """Core-layer stages should load knowledge (not skip).""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_knowledge_override = MagicMock() + session._apply_stage_knowledge( + mock_tf_agent, + {"layer": "core", "capability": "identity", "services": [{"name": "managed-identity"}]}, + ) + # Should have been called (knowledge loaded) + assert mock_tf_agent.set_knowledge_override.called or True # May not find knowledge file, but shouldn't skip + + def test_build_stage_task_iac_detection_by_layer(self, build_context, build_registry, mock_tf_agent): + """IaC detection uses layer, not capability. Core/infra/data are IaC.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + for layer in ("core", "infra", "data"): + stage = { + "stage": 1, + "name": "Test", + "layer": layer, + "capability": "test", + "dir": "concept/infra/terraform/test", + "services": [], + } + agent, task = session._build_stage_task(stage, "arch", []) + assert "terraform" in task.lower() or "Generate" in task, f"Layer {layer} should be IaC" + + def test_build_stage_task_app_not_iac(self, build_context, build_registry, mock_dev_agent): + """App-layer stages should not get IaC-specific directives.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + stage = { + "stage": 1, + "name": "API", + "layer": "app", + "capability": "domain", + "dir": "concept/apps/test", + "services": [], + } + agent, task = session._build_stage_task(stage, "arch", []) + # App stages should not get IaC directive hierarchy + assert "DIRECTIVE HIERARCHY" not in task + + +# ====================================================================== +# _resolve_developer_for_stage / _decompose_app_stage tests +# ====================================================================== + + +class TestAppStageDelegation: + """Tests for app-layer architect → developer delegation.""" + + def test_resolve_developer_python_from_name(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_py = MagicMock() + mock_py.name = "python-developer" + session._python_dev = mock_py + + stage = {"name": "Python API", "services": [], "dir": ""} + dev = session._resolve_developer_for_stage(stage, "") + assert dev is mock_py + + def test_resolve_developer_react_from_name(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_react = MagicMock() + mock_react.name = "react-developer" + session._react_dev = mock_react + + stage = {"name": "React Frontend", "services": [], "dir": ""} + dev = session._resolve_developer_for_stage(stage, "") + assert dev is mock_react + + def test_resolve_developer_csharp_from_services(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_cs = MagicMock() + mock_cs.name = "csharp-developer" + session._csharp_dev = mock_cs + + stage = {"name": "Backend API", "services": [{"name": "aspnet-api"}], "dir": ""} + dev = session._resolve_developer_for_stage(stage, "") + assert dev is mock_cs + + def test_resolve_developer_from_architecture_context(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_py = MagicMock() + mock_py.name = "python-developer" + session._python_dev = mock_py + + stage = {"name": "Worker Service", "services": [], "dir": ""} + arch = "Worker Service uses FastAPI for the async message consumer." + dev = session._resolve_developer_for_stage(stage, arch) + assert dev is mock_py + + def test_resolve_developer_none_when_no_hints(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + stage = {"name": "Custom Logic", "services": [], "dir": ""} + dev = session._resolve_developer_for_stage(stage, "") + assert dev is None + + def test_decompose_returns_developer_with_sub_layer_context(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_py = MagicMock() + mock_py.name = "python-developer" + session._python_dev = mock_py + + stage = {"name": "FastAPI Backend", "layer": "app", "services": [], "dir": ""} + agent, ctx = session._decompose_app_stage(stage, "", lambda *a: None) + assert agent is mock_py + assert "Sub-Layer Structure" in ctx + + def test_decompose_falls_back_to_app_architect(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_arch = MagicMock() + mock_arch.name = "application-architect" + session._app_architect = mock_arch + + stage = {"name": "Custom Service", "layer": "app", "services": [], "dir": ""} + agent, ctx = session._decompose_app_stage(stage, "", lambda *a: None) + assert agent is mock_arch + assert ctx == "" + + +# ====================================================================== +# AgentContract.sub_layers tests +# ====================================================================== + + +class TestAgentContractSubLayers: + """Tests for the sub_layers field on AgentContract.""" + + def test_sub_layers_default_empty(self): + from azext_prototype.agents.base import AgentContract + + contract = AgentContract() + assert contract.sub_layers == [] + + def test_sub_layers_set_on_csharp(self): + from azext_prototype.agents.builtin.csharp_developer import CSharpDeveloperAgent + + agent = CSharpDeveloperAgent() + assert "api" in agent._contract.sub_layers + assert "presentation" in agent._contract.sub_layers + + def test_sub_layers_set_on_python(self): + from azext_prototype.agents.builtin.python_developer import PythonDeveloperAgent + + agent = PythonDeveloperAgent() + assert "api" in agent._contract.sub_layers + assert "presentation" not in agent._contract.sub_layers + + def test_sub_layers_set_on_react(self): + from azext_prototype.agents.builtin.react_developer import ReactDeveloperAgent + + agent = ReactDeveloperAgent() + assert agent._contract.sub_layers == ["presentation"] + + def test_sub_layers_set_on_app_architect(self): + from azext_prototype.agents.builtin.application_architect import ApplicationArchitectAgent + + agent = ApplicationArchitectAgent() + assert len(agent._contract.sub_layers) == 5 + + +# ====================================================================== +# _build_stage_task governor brief tests +# ====================================================================== + + +class TestBuildStageTaskGovernorBrief: + """Tests that _build_stage_task incorporates governor brief into task string.""" + + def test_governor_brief_included_in_task(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + # Simulate a governor brief being set on the agent + mock_tf_agent._governor_brief = "MUST use managed identity for all services" + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [ + { + "name": "key-vault", + "computed_name": "zd-kv-dev", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + } + ], + "dir": "concept/infra/terraform/stage-1-foundation", + } + + agent, task = session._build_stage_task(stage, "sample architecture", []) + + assert agent is mock_tf_agent + assert "MANDATORY GOVERNANCE RULES" in task + assert "managed identity" in task + + def test_no_governor_brief_no_governance_section(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + mock_tf_agent._governor_brief = "" + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform/stage-1-foundation", + } + + agent, task = session._build_stage_task(stage, "sample architecture", []) + + assert "MANDATORY GOVERNANCE RULES" not in task + + def test_build_stage_task_no_agent_returns_none(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._doc_agent = None + + stage = { + "stage": 1, + "name": "Docs", + "capability": "docs", + "services": [], + "dir": "concept/docs", + } + + agent, task = session._build_stage_task(stage, "architecture", []) + + assert agent is None + assert task == "" + + def test_build_stage_task_includes_services(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent._governor_brief = "" + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [ + { + "name": "key-vault", + "computed_name": "zd-kv-dev", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + }, + { + "name": "managed-identity", + "computed_name": "zd-id-dev", + "resource_type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "sku": "", + }, + ], + "dir": "concept/infra/terraform/stage-1-foundation", + } + + _, task = session._build_stage_task(stage, "architecture", []) + + assert "zd-kv-dev" in task + assert "zd-id-dev" in task + assert "Microsoft.KeyVault/vaults" in task + + def test_build_stage_task_terraform_file_structure(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent._governor_brief = "" + + stage = { + "stage": 1, + "name": "Foundation", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform/stage-1-foundation", + } + + _, task = session._build_stage_task(stage, "architecture", []) + + assert "Terraform File Structure" in task + assert "providers.tf" in task + assert "main.tf" in task + assert "variables.tf" in task + + def test_build_stage_reset_flag(self, project_with_design, sample_config): + from azext_prototype.stages.build_state import BuildState + + # Create some state + bs = BuildState(str(project_with_design)) + bs._state["templates_used"] = ["web-app"] + bs.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": ["main.tf"], + }, + ] + ) + + # Reset should clear everything + bs.reset() + assert bs.state["templates_used"] == [] + assert bs.state["deployment_stages"] == [] + assert bs.state["files_generated"] == [] + + def test_build_stage_reset_cleans_output_dirs(self, project_with_design): + """--reset removes concept/infra, concept/apps, concept/db, concept/docs.""" + from azext_prototype.stages.build_stage import BuildStage + + project_dir = str(project_with_design) + base = project_with_design / "concept" + + # Create output dirs with stale files + for sub in ("infra/terraform/stage-1-foundation", "apps/stage-2-api", "db/sql", "docs"): + d = base / sub + d.mkdir(parents=True, exist_ok=True) + (d / "stale.tf").write_text("# stale", encoding="utf-8") + + assert (base / "infra").is_dir() + assert (base / "apps").is_dir() + assert (base / "db").is_dir() + assert (base / "docs").is_dir() + + stage = BuildStage() + stage._clean_output_dirs(project_dir) + + assert not (base / "infra").exists() + assert not (base / "apps").exists() + assert not (base / "db").exists() + assert not (base / "docs").exists() + + def test_build_stage_reset_ignores_missing_dirs(self, project_with_design): + """_clean_output_dirs is a no-op when dirs don't exist.""" + from azext_prototype.stages.build_stage import BuildStage + + stage = BuildStage() + # Should not raise + stage._clean_output_dirs(str(project_with_design)) + + +# ====================================================================== +# Architect-based stage identification tests (Phase 9) +# ====================================================================== + + +class TestArchitectStageIdentification: + """Test _identify_affected_stages with architect agent delegation.""" + + def _make_session_with_stages(self, tmp_project, architect_response=None, architect_raises=False): + from azext_prototype.stages.build_session import BuildSession + from azext_prototype.stages.build_state import BuildState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + architect = MagicMock() + architect.name = "cloud-architect" + if architect_raises: + architect.execute.side_effect = RuntimeError("AI error") + else: + architect.execute.return_value = architect_response or _make_response("[1, 3]") + + registry = MagicMock() + + def find_by_cap(cap): + if cap == AgentCapability.ARCHITECT: + return [architect] + if cap == AgentCapability.QA: + return [] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + build_state = BuildState(str(tmp_project)) + build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "", + "services": [{"name": "key-vault"}], + "status": "generated", + "files": [], + }, + { + "stage": 2, + "name": "Data Layer", + "capability": "data", + "dir": "", + "services": [{"name": "sql-db"}], + "status": "generated", + "files": [], + }, + { + "stage": 3, + "name": "Application", + "capability": "app", + "dir": "", + "services": [{"name": "web-app"}], + "status": "generated", + "files": [], + }, + ] + ) + + with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": "terraform", + "project.name": "test", + }.get(k, d) + mock_config.return_value.to_dict.return_value = { + "naming": {"strategy": "simple"}, + "project": {"name": "test"}, + } + session = BuildSession(ctx, registry, build_state=build_state) + + return session, architect + + def test_architect_identifies_stages(self, tmp_project): + session, architect = self._make_session_with_stages( + tmp_project, + _make_response("[1, 3]"), + ) + + result = session._identify_affected_stages("Fix the networking and add CORS") + + assert result == [1, 3] + architect.execute.assert_called_once() + + def test_architect_parse_failure_falls_back_to_regex(self, tmp_project): + session, architect = self._make_session_with_stages( + tmp_project, + _make_response("I think stages 1 and 3 are affected"), + ) + + result = session._identify_affected_stages("Fix the key-vault configuration") + + # Architect response not parseable as JSON, falls back to regex + # "key-vault" matches service in stage 1 + assert 1 in result + + def test_architect_exception_falls_back_to_regex(self, tmp_project): + session, architect = self._make_session_with_stages( + tmp_project, + architect_raises=True, + ) + + result = session._identify_affected_stages("Fix the key-vault configuration") + + assert 1 in result + + def test_no_architect_uses_regex(self, tmp_project): + from azext_prototype.stages.build_session import BuildSession + from azext_prototype.stages.build_state import BuildState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + registry = MagicMock() + registry.find_by_capability.return_value = [] + + build_state = BuildState(str(tmp_project)) + build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "", + "services": [{"name": "key-vault"}], + "status": "generated", + "files": [], + }, + ] + ) + + with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": "terraform", + "project.name": "test", + }.get(k, d) + mock_config.return_value.to_dict.return_value = { + "naming": {"strategy": "simple"}, + "project": {"name": "test"}, + } + session = BuildSession(ctx, registry, build_state=build_state) + + result = session._identify_affected_stages("Fix stage 1") + assert result == [1] + + def test_parse_stage_numbers_valid(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._parse_stage_numbers("[1, 2, 3]") == [1, 2, 3] + + def test_parse_stage_numbers_fenced(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._parse_stage_numbers("```json\n[2, 4]\n```") == [2, 4] + + def test_parse_stage_numbers_invalid(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._parse_stage_numbers("No stages found") == [] + + def test_parse_stage_numbers_deduplicates(self): + from azext_prototype.stages.build_session import BuildSession + + assert BuildSession._parse_stage_numbers("[1, 1, 3]") == [1, 3] + + +# ====================================================================== +# Blocked file filtering tests +# ====================================================================== + + +class TestBlockedFileFiltering: + """Tests for _write_stage_files() dropping blocked files like versions.tf.""" + + def _make_session(self, project_dir, iac_tool="terraform"): + from azext_prototype.stages.build_session import BuildSession + from azext_prototype.stages.build_state import BuildState + + ctx = AgentContext( + project_config={"project": {"iac_tool": iac_tool}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = MagicMock() + registry.find_by_capability.return_value = [] + + build_state = BuildState(str(project_dir)) + + with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": iac_tool, + "project.name": "test", + }.get(k, d) + mock_config.return_value.to_dict.return_value = { + "naming": {"strategy": "simple"}, + "project": {"name": "test"}, + } + session = BuildSession(ctx, registry, build_state=build_state) + + return session + + def test_versions_tf_dropped_for_terraform(self, tmp_project): + session = self._make_session(tmp_project, iac_tool="terraform") + content = ( + '```providers.tf\nterraform { required_version = ">= 1.0" }\n```\n\n' + "```versions.tf\n}\n```\n\n" + '```main.tf\nresource "null" "x" {}\n```\n' + ) + stage = {"dir": "concept/infra/terraform/stage-1", "stage": 1} + (tmp_project / "concept" / "infra" / "terraform" / "stage-1").mkdir(parents=True, exist_ok=True) + + written = session._write_stage_files(stage, content) + + filenames = [p.split("/")[-1] for p in written] + assert "providers.tf" in filenames + assert "main.tf" in filenames + assert "versions.tf" not in filenames + + def test_versions_tf_allowed_for_bicep(self, tmp_project): + """versions.tf is only blocked for terraform, not other tools.""" + session = self._make_session(tmp_project, iac_tool="bicep") + content = "```versions.tf\nsome content\n```\n" + stage = {"dir": "concept/infra/bicep/stage-1", "stage": 1} + (tmp_project / "concept" / "infra" / "bicep" / "stage-1").mkdir(parents=True, exist_ok=True) + + written = session._write_stage_files(stage, content) + + filenames = [p.split("/")[-1] for p in written] + assert "versions.tf" in filenames + + def test_normal_files_not_dropped(self, tmp_project): + session = self._make_session(tmp_project) + content = ( + '```main.tf\nresource "null" "x" {}\n```\n\n' + '```outputs.tf\noutput "id" { value = null_resource.x.id }\n```\n' + ) + stage = {"dir": "concept/infra/terraform/stage-1", "stage": 1} + (tmp_project / "concept" / "infra" / "terraform" / "stage-1").mkdir(parents=True, exist_ok=True) + + written = session._write_stage_files(stage, content) + assert len(written) == 2 + + def test_blocked_files_class_attribute(self): + from azext_prototype.stages.build_session import BuildSession + + assert "versions.tf" in BuildSession._BLOCKED_FILES["terraform"] + + +# ====================================================================== +# Terraform prompt reinforcement tests +# ====================================================================== + + +class TestTerraformPromptReinforcement: + """Verify the task prompt includes explicit Terraform file structure rules.""" + + def _make_session(self, project_dir): + from azext_prototype.stages.build_session import BuildSession + from azext_prototype.stages.build_state import BuildState + + ctx = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = MagicMock() + registry.find_by_capability.return_value = [] + + build_state = BuildState(str(project_dir)) + + with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": "terraform", + "project.name": "test", + }.get(k, d) + mock_config.return_value.to_dict.return_value = { + "naming": {"strategy": "simple"}, + "project": {"name": "test"}, + } + session = BuildSession(ctx, registry, build_state=build_state) + + return session + + def test_task_prompt_includes_file_structure(self, tmp_project): + session = self._make_session(tmp_project) + stage = { + "stage": 1, + "name": "Foundation", + "layer": "infra", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "services": [], + "status": "pending", + "files": [], + } + # Need a mock IaC agent + mock_agent = MagicMock() + session._iac_agents["terraform"] = mock_agent + + agent, task = session._build_stage_task(stage, "some architecture", []) + + assert "Terraform File Structure" in task + assert "DO NOT create versions.tf" in task + assert "providers.tf" in task + assert "ONLY file that may contain a terraform {} block" in task + + +# ====================================================================== +# Terraform validation during build QA +# ====================================================================== + +# ====================================================================== +# QA Engineer prompt tests +# ====================================================================== + + +class TestQAPromptTerraformChecklist: + """Verify the QA engineer prompt includes the Terraform File Structure checklist.""" + + def test_qa_prompt_contains_terraform_file_structure(self): + from azext_prototype.agents.builtin.qa_engineer import QA_ENGINEER_PROMPT + + assert "Terraform File Structure" in QA_ENGINEER_PROMPT + assert "versions.tf" in QA_ENGINEER_PROMPT + assert "providers.tf" in QA_ENGINEER_PROMPT + assert "empty" in QA_ENGINEER_PROMPT + assert "syntactically valid HCL" in QA_ENGINEER_PROMPT + + +# ====================================================================== +# Per-stage QA tests +# ====================================================================== + + +class TestPerStageQA: + """Test _run_stage_qa() and _collect_stage_file_content().""" + + def _make_session(self, project_dir, qa_response="No issues found.", iac_tool="terraform"): + from azext_prototype.stages.build_session import BuildSession + from azext_prototype.stages.build_state import BuildState + + ctx = AgentContext( + project_config={"project": {"iac_tool": iac_tool, "name": "test"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + + qa_agent = MagicMock() + qa_agent.name = "qa-engineer" + + tf_agent = MagicMock() + tf_agent.name = "terraform-agent" + tf_agent.execute.return_value = _make_file_response( + "main.tf", 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' + ) + + registry = MagicMock() + + def find_by_cap(cap): + if cap == AgentCapability.QA: + return [qa_agent] + if cap == AgentCapability.TERRAFORM: + return [tf_agent] + if cap == AgentCapability.ARCHITECT: + return [] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + build_state = BuildState(str(project_dir)) + + with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": iac_tool, + "project.name": "test", + }.get(k, d) + mock_config.return_value.to_dict.return_value = { + "naming": {"strategy": "simple"}, + "project": {"name": "test"}, + } + session = BuildSession(ctx, registry, build_state=build_state) + + return session, qa_agent, tf_agent + + def test_per_stage_qa_passes_clean(self, tmp_project): + session, qa_agent, tf_agent = self._make_session(tmp_project) + + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text( + 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' + ) + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "files": ["concept/infra/terraform/stage-1/main.tf"], + "status": "generated", + "services": [], + } + + printed = [] + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: + mock_orch.return_value.delegate.return_value = _make_response( + "All looks good. Code is clean and well-structured." + ) + session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) + + output = "\n".join(printed) + assert "passed QA" in output + + def test_per_stage_qa_triggers_remediation(self, tmp_project): + session, qa_agent, tf_agent = self._make_session(tmp_project) + + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text( + 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' + ) + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "files": ["concept/infra/terraform/stage-1/main.tf"], + "status": "generated", + "services": [], + } + session._build_state.set_deployment_plan([stage]) + + printed = [] + call_count = [0] + + def mock_delegate(**kwargs): + call_count[0] += 1 + if call_count[0] == 1: + return _make_response("CRITICAL: Missing managed identity config. Must fix.") + return _make_response("All resolved, no remaining issues.") + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: + mock_orch.return_value.delegate.side_effect = mock_delegate + session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) + + output = "\n".join(printed) + assert "remediating" in output.lower() + # QA was called at least twice (initial + re-review) + assert call_count[0] >= 2 + + def test_per_stage_qa_max_attempts(self, tmp_project): + pass + + session, qa_agent, tf_agent = self._make_session(tmp_project) + + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text( + 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' + ) + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "files": ["concept/infra/terraform/stage-1/main.tf"], + "status": "generated", + "services": [], + } + session._build_state.set_deployment_plan([stage]) + + printed = [] + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: + # Always return issues + mock_orch.return_value.delegate.return_value = _make_response("CRITICAL: This will never be fixed.") + session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) + + output = "\n".join(printed) + assert "issues remain" in output.lower() + + def test_per_stage_qa_skips_docs_stages(self, tmp_project): + """Docs capability stages should not get QA review during Phase 3.""" + # This tests the gating in the Phase 3 loop, not _run_stage_qa itself + stage = { + "stage": 5, + "name": "Documentation", + "capability": "docs", + "dir": "concept/docs", + "files": [], + "status": "generated", + "services": [], + } + # docs capability is not in ("infra", "data", "integration", "app") + assert stage["capability"] not in ("infra", "data", "integration", "app") + + def test_collect_stage_file_content(self, tmp_project): + session, _, _ = self._make_session(tmp_project) + + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text('resource "null" "x" {}') + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "files": ["concept/infra/terraform/stage-1/main.tf"], + } + + content = session._collect_stage_file_content(stage) + assert "main.tf" in content + assert 'resource "null" "x"' in content + + def test_collect_stage_file_content_empty(self, tmp_project): + session, _, _ = self._make_session(tmp_project) + stage = {"stage": 1, "name": "Foundation", "files": []} + content = session._collect_stage_file_content(stage) + assert content == "" + + +# ====================================================================== +# Advisory QA tests +# ====================================================================== + + +class TestAdvisoryQA: + """Test that Phase 4 is now advisory-only (no remediation).""" + + def _make_session(self, project_dir): + from azext_prototype.stages.build_session import BuildSession + from azext_prototype.stages.build_state import BuildState + + ctx = AgentContext( + project_config={"project": {"iac_tool": "terraform", "name": "test"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + + qa_agent = MagicMock() + qa_agent.name = "qa-engineer" + + tf_agent = MagicMock() + tf_agent.name = "terraform-agent" + tf_agent.execute.return_value = _make_file_response( + "main.tf", 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' + ) + + doc_agent = MagicMock() + doc_agent.name = "doc-agent" + doc_agent.execute.return_value = _make_file_response("README.md", "# Docs") + + architect_agent = MagicMock() + architect_agent.name = "cloud-architect" + + registry = MagicMock() + + def find_by_cap(cap): + if cap == AgentCapability.QA: + return [qa_agent] + if cap == AgentCapability.TERRAFORM: + return [tf_agent] + if cap == AgentCapability.ARCHITECT: + return [architect_agent] + if cap == AgentCapability.DOCUMENT: + return [doc_agent] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + build_state = BuildState(str(project_dir)) + + with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": "terraform", + "project.name": "test", + }.get(k, d) + mock_config.return_value.to_dict.return_value = { + "naming": {"strategy": "simple"}, + "project": {"name": "test"}, + } + session = BuildSession(ctx, registry, build_state=build_state) + + return session, qa_agent, tf_agent + + def test_advisory_qa_prompt_no_bug_hunting(self, tmp_project): + """Verify Phase 4 aggregates per-stage advisories (no AI call).""" + session, qa_agent, tf_agent = self._make_session(tmp_project) + + # Pre-populate with generated stages, files, and advisory + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text('resource "null" "x" {}') + + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "services": [], + "status": "generated", + "files": ["concept/infra/terraform/stage-1/main.tf"], + }, + ] + ) + # Pre-store advisory (as if per-stage advisory already ran) + session._build_state.set_stage_advisory(1, "- **[Scalability]** Consider upgrading SKUs for production.") + # Set design snapshot so run() sees no design changes + session._build_state.set_design_snapshot({"architecture": "Simple architecture"}) + + printed = [] + inputs = iter(["done"]) + + with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: + mock_gov_cls.return_value.check_response_for_violations.return_value = [] + session._governance = mock_gov_cls.return_value + session._policy_resolver._governance = mock_gov_cls.return_value + + session.run( + design={"architecture": "Simple architecture"}, + input_fn=lambda p: next(inputs), + print_fn=lambda m: printed.append(m), + ) + + output = "\n".join(printed) + assert "Advisory notes from 1 stages saved to" in output + # Verify ADVISORY.md was written + advisory_path = tmp_project / "concept" / "docs" / "ADVISORY.md" + assert advisory_path.exists() + content = advisory_path.read_text() + assert "Scalability" in content + assert "Stage 1: Foundation" in content + + def test_advisory_qa_no_remediation_loop(self, tmp_project): + """Phase 4 should NOT trigger _identify_affected_stages or IaC regen.""" + session, qa_agent, tf_agent = self._make_session(tmp_project) + + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text('resource "null" "x" {}') + + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "services": [], + "status": "generated", + "files": ["concept/infra/terraform/stage-1/main.tf"], + }, + ] + ) + + inputs = iter(["", "done"]) + + with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: + mock_gov_cls.return_value.check_response_for_violations.return_value = [] + session._governance = mock_gov_cls.return_value + session._policy_resolver._governance = mock_gov_cls.return_value + + with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: + # Return warnings — in old code this would trigger remediation + mock_orch.return_value.delegate.return_value = _make_response( + "WARNING: Missing monitoring. CRITICAL: No backup config." + ) + + with patch.object(session, "_identify_affected_stages") as mock_identify: + session.run( + design={"architecture": "Simple architecture"}, + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) + + # _identify_affected_stages should NOT have been called during Phase 4 + mock_identify.assert_not_called() + + def test_advisory_qa_header_says_advisory(self, tmp_project): + """Output should contain 'Advisory notes' not 'QA Review'.""" + session, qa_agent, tf_agent = self._make_session(tmp_project) + + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text('resource "null" "x" {}') + + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "services": [], + "status": "generated", + "files": ["concept/infra/terraform/stage-1/main.tf"], + }, + ] + ) + session._build_state.set_stage_advisory(1, "- **[Cost]** Basic SKU is cheap but limited.") + session._build_state.set_design_snapshot({"architecture": "Simple architecture"}) + + printed = [] + inputs = iter(["done"]) + + with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: + mock_gov_cls.return_value.check_response_for_violations.return_value = [] + session._governance = mock_gov_cls.return_value + session._policy_resolver._governance = mock_gov_cls.return_value + + session.run( + design={"architecture": "Simple architecture"}, + input_fn=lambda p: next(inputs), + print_fn=lambda m: printed.append(m), + ) + + output = "\n".join(printed) + assert "Advisory notes" in output + # Should NOT contain "QA Review:" as a section header + assert "QA Review:" not in output + + +# ====================================================================== +# Stable ID tests +# ====================================================================== + + +class TestStableIds: + + def test_stable_ids_assigned_on_set_deployment_plan(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, + {"stage": 2, "name": "Data Layer", "capability": "data", "services": [], "status": "pending", "files": []}, + ] + bs.set_deployment_plan(stages) + + for s in bs.state["deployment_stages"]: + assert "id" in s + assert s["id"] # non-empty + assert bs.state["deployment_stages"][0]["id"] == "foundation" + assert bs.state["deployment_stages"][1]["id"] == "data-layer" + + def test_stable_ids_preserved_on_renumber(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, + {"stage": 2, "name": "Data Layer", "capability": "data", "services": [], "status": "pending", "files": []}, + ] + bs.set_deployment_plan(stages) + + original_ids = [s["id"] for s in bs.state["deployment_stages"]] + bs.renumber_stages() + new_ids = [s["id"] for s in bs.state["deployment_stages"]] + assert original_ids == new_ids + + def test_stable_ids_unique_on_name_collision(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, + {"stage": 2, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, + ] + bs.set_deployment_plan(stages) + + ids = [s["id"] for s in bs.state["deployment_stages"]] + assert len(set(ids)) == 2 # all unique + assert ids[0] == "foundation" + assert ids[1] == "foundation-2" + + def test_stable_ids_backfilled_on_load(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + # Write a legacy state file without ids + state_dir = Path(str(tmp_project)) / ".prototype" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + legacy = { + "deployment_stages": [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "files": [], + }, + ], + "templates_used": [], + "iac_tool": "terraform", + "_metadata": {"created": None, "last_updated": None, "iteration": 0}, + } + with open(state_dir / "build.yaml", "w") as f: + yaml.dump(legacy, f) + + bs = BuildState(str(tmp_project)) + bs.load() + assert bs.state["deployment_stages"][0]["id"] == "foundation" + assert bs.state["deployment_stages"][0]["deploy_mode"] == "auto" + + def test_get_stage_by_id(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, + {"stage": 2, "name": "Data Layer", "capability": "data", "services": [], "status": "pending", "files": []}, + ] + bs.set_deployment_plan(stages) + + found = bs.get_stage_by_id("data-layer") + assert found is not None + assert found["name"] == "Data Layer" + assert bs.get_stage_by_id("nonexistent") is None + + def test_deploy_mode_in_stage_schema(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + stages = [ + { + "stage": 1, + "name": "Manual Upload", + "capability": "external", + "services": [], + "status": "pending", + "files": [], + "deploy_mode": "manual", + "manual_instructions": "Upload the notebook to the Fabric workspace.", + }, + { + "stage": 2, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "pending", + "files": [], + }, + ] + bs.set_deployment_plan(stages) + + assert bs.state["deployment_stages"][0]["deploy_mode"] == "manual" + assert "Upload" in bs.state["deployment_stages"][0]["manual_instructions"] + assert bs.state["deployment_stages"][1]["deploy_mode"] == "auto" + assert bs.state["deployment_stages"][1]["manual_instructions"] is None + + def test_add_stages_assigns_ids(self, tmp_project): + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "pending", + "files": [], + }, + ] + ) + bs.add_stages( + [ + {"name": "API Layer", "capability": "app"}, + ] + ) + ids = [s["id"] for s in bs.state["deployment_stages"]] + assert "api-layer" in ids + + +# ====================================================================== +# _handle_describe tests +# ====================================================================== + + +class TestHandleDescribe: + """Tests for /describe slash command.""" + + def test_describe_valid_stage(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [ + { + "name": "key-vault", + "computed_name": "zd-kv-dev", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + }, + ], + "status": "generated", + "dir": "concept/infra/terraform/stage-1", + "files": ["main.tf", "variables.tf"], + }, + ] + ) + + printed = [] + session._handle_describe("1", lambda m: printed.append(m)) + output = "\n".join(printed) + + assert "Foundation" in output + assert "infra" in output + assert "zd-kv-dev" in output + assert "Microsoft.KeyVault/vaults" in output + assert "standard" in output + assert "main.tf" in output + + def test_describe_stage_not_found(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "pending", + "dir": "", + "files": [], + }, + ] + ) + + printed = [] + session._handle_describe("99", lambda m: printed.append(m)) + output = "\n".join(printed) + + assert "not found" in output.lower() + + def test_describe_no_arg(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + printed = [] + session._handle_describe("", lambda m: printed.append(m)) + output = "\n".join(printed) + + assert "Usage" in output + + def test_describe_non_numeric(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + printed = [] + session._handle_describe("abc", lambda m: printed.append(m)) + output = "\n".join(printed) + + assert "Usage" in output + + +# ====================================================================== +# _build_stage_task bicep branch tests +# ====================================================================== + + +class TestBuildStageTaskBicep: + """Tests for _build_stage_task with bicep IaC tool.""" + + def test_bicep_capability_infra(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + # Create a registry that has a bicep agent + mock_bicep_agent = MagicMock() + mock_bicep_agent.name = "bicep-agent" + mock_bicep_agent._governor_brief = "" + + def find_by_cap(cap): + if cap == AgentCapability.BICEP: + return [mock_bicep_agent] + if cap == AgentCapability.TERRAFORM: + return [] + return [] + + registry = MagicMock() + registry.find_by_capability.side_effect = find_by_cap + + # Override iac_tool in config + config_path = Path(build_context.project_dir) / "prototype.yaml" + import yaml + + with open(config_path) as f: + cfg = yaml.safe_load(f) + cfg["project"]["iac_tool"] = "bicep" + with open(config_path, "w") as f: + yaml.dump(cfg, f) + + session = BuildSession(build_context, registry) + + stage = { + "stage": 1, + "name": "Foundation", + "layer": "infra", + "capability": "infra", + "services": [ + { + "name": "key-vault", + "computed_name": "zd-kv-dev", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + } + ], + "dir": "concept/infra/bicep/stage-1-foundation", + } + + agent, task = session._build_stage_task(stage, "architecture", []) + + assert agent is mock_bicep_agent + assert "consistent deployment naming (Bicep)" in task + assert "Terraform File Structure" not in task + + def test_app_stage_includes_scaffolding(self, build_context, build_registry, mock_dev_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_dev_agent._governor_brief = "" + + stage = { + "stage": 2, + "name": "API", + "layer": "app", + "capability": "app", + "services": [ + { + "name": "container-app-api", + "resource_type": "Microsoft.App/containerApps", + "computed_name": "api-1", + "sku": "", + } + ], + "dir": "concept/apps/stage-2-api", + } + + _, task = session._build_stage_task(stage, "architecture", []) + + assert "Required Project Files" in task + assert "Dockerfile" in task + + +# ====================================================================== +# _collect_stage_file_content edge case tests +# ====================================================================== + + +class TestCollectStageFileContentEdgeCases: + """Additional tests for _collect_stage_file_content.""" + + def test_unreadable_file(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stage = {"files": ["nonexistent/file.tf"]} + result = session._collect_stage_file_content(stage) + + assert "could not read file" in result + + def test_large_file_not_truncated(self, build_context, build_registry): + """QA must see the full file — no per-file truncation.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + file_path = Path(build_context.project_dir) / "big.tf" + file_path.write_text("x" * 20000, encoding="utf-8") + + stage = {"files": ["big.tf"]} + result = session._collect_stage_file_content(stage) + + assert "truncated" not in result + assert "x" * 20000 in result + + def test_many_files_all_included(self, build_context, build_registry): + """QA must see all files — no total size cap.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + for i in range(10): + f = Path(build_context.project_dir) / f"file{i}.tf" + f.write_text(f"content_{i}" * 500, encoding="utf-8") + + stage = {"files": [f"file{i}.tf" for i in range(10)]} + result = session._collect_stage_file_content(stage) + + assert "omitted" not in result + for i in range(10): + assert f"file{i}.tf" in result + + def test_no_files_returns_empty(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stage = {"files": []} + result = session._collect_stage_file_content(stage) + assert result == "" + + +# ====================================================================== +# _identify_stages_via_architect edge cases +# ====================================================================== + + +class TestIdentifyStagesViaArchitect: + """Tests for _identify_stages_via_architect edge cases.""" + + def test_empty_deployment_stages_returns_empty(self, build_context, build_registry, mock_architect_agent_for_build): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + # No deployment stages set + session._build_state._state["deployment_stages"] = [] + + result = session._identify_stages_via_architect("fix the key vault") + assert result == [] + + def test_parse_stage_numbers_json_error(self): + from azext_prototype.stages.build_session import BuildSession + + # Invalid JSON within brackets + result = BuildSession._parse_stage_numbers("[1, 2, invalid]") + assert result == [] + + def test_parse_stage_numbers_no_match(self): + from azext_prototype.stages.build_session import BuildSession + + result = BuildSession._parse_stage_numbers("no numbers here at all") + assert result == [] + + +# ====================================================================== +# _identify_stages_regex edge cases +# ====================================================================== + + +class TestIdentifyStagesRegex: + """Tests for _identify_stages_regex fallback paths.""" + + def test_regex_last_resort_all_generated(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [{"name": "key-vault"}], + "status": "generated", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "Data", + "capability": "data", + "services": [{"name": "cosmos-db"}], + "status": "generated", + "dir": "", + "files": [], + }, + { + "stage": 3, + "name": "Pending", + "capability": "app", + "services": [], + "status": "pending", + "dir": "", + "files": [], + }, + ] + ) + + # Feedback that doesn't match any stage name, service, or number + result = session._identify_stages_regex("completely unrelated feedback about something else entirely") + # Last resort: returns all generated stages + assert result == [1, 2] + + def test_regex_matches_stage_name(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + { + "stage": 2, + "name": "Data", + "capability": "data", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + ] + ) + + result = session._identify_stages_regex("The foundation stage needs more resources") + assert result == [1] + + +# ====================================================================== +# _run_stage_qa edge cases +# ====================================================================== + + +class TestRunStageQAEdgeCases: + """Tests for _run_stage_qa early returns.""" + + def test_no_qa_agent_skips(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._qa_agent = None + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + } + + # Should not raise + session._run_stage_qa(stage, "arch", [], False, lambda m: None) + + def test_no_file_content_skips(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "status": "generated", + "dir": "", + "files": [], + } + + # No files means no QA review needed + session._run_stage_qa(stage, "arch", [], False, lambda m: None) + + +# ====================================================================== +# _maybe_spinner tests +# ====================================================================== + + +class TestMaybeSpinner: + """Tests for _maybe_spinner context manager.""" + + def test_plain_mode_just_yields(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + executed = False + with session._maybe_spinner("Processing...", use_styled=False): + executed = True + assert executed + + def test_status_fn_mode(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + calls = [] + session = BuildSession(build_context, build_registry, status_fn=lambda msg, kind: calls.append((msg, kind))) + + with session._maybe_spinner("Building...", use_styled=False): + pass + + # Should have called status_fn with "start" and "end" + assert any(k == "start" for _, k in calls) + assert any(k == "end" for _, k in calls) + + def test_status_fn_mode_with_exception(self, build_context, build_registry): + from azext_prototype.stages.build_session import BuildSession + + calls = [] + session = BuildSession(build_context, build_registry, status_fn=lambda msg, kind: calls.append((msg, kind))) + + try: + with session._maybe_spinner("Building...", use_styled=False): + raise ValueError("test") + except ValueError: + pass + + # Even on exception, "end" should be called (finally block) + assert any(k == "end" for _, k in calls) + + +# ====================================================================== +# _apply_governor_brief tests +# ====================================================================== + + +class TestApplyGovernorBrief: + """Tests for _apply_governor_brief.""" + + def test_sets_brief_on_agent(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_governor_brief = MagicMock() + + with patch("azext_prototype.governance.governor.brief", return_value="MUST use managed identity"): + session._apply_governor_brief(mock_tf_agent, "Foundation", [{"name": "key-vault"}]) + + mock_tf_agent.set_governor_brief.assert_called_once_with("MUST use managed identity") + + def test_empty_brief_not_set(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_governor_brief = MagicMock() + + with patch("azext_prototype.governance.governor.brief", return_value=""): + session._apply_governor_brief(mock_tf_agent, "Foundation", []) + + mock_tf_agent.set_governor_brief.assert_not_called() + + def test_exception_silently_caught(self, build_context, build_registry, mock_tf_agent): + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_governor_brief = MagicMock() + + with patch("azext_prototype.governance.governor.brief", side_effect=Exception("boom")): + # Should not raise + session._apply_governor_brief(mock_tf_agent, "Foundation", []) + + mock_tf_agent.set_governor_brief.assert_not_called() + + +# ====================================================================== +# TestBuildSessionRefactored — targeted coverage for refactored helpers +# ====================================================================== + + +class TestBuildSessionRefactored: + """Additional coverage for _agent_build_context, _select_agent, + _apply_stage_knowledge, and _condense_architecture. + + Complements the existing per-class tests to ensure all code paths are + exercised. + """ + + # ------------------------------------------------------------------ # + # _agent_build_context + # ------------------------------------------------------------------ # + + def test_agent_build_context_disables_standards_and_restores(self, build_context, build_registry, mock_tf_agent): + """Context manager must disable standards inside and restore on exit.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent._include_standards = True + mock_tf_agent.set_knowledge_override = MagicMock() + mock_tf_agent.set_governor_brief = MagicMock() + + stage = {"name": "Foundation", "services": []} + + with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): + with session._agent_build_context(mock_tf_agent, stage): + # Standards remain enabled (agent-scoped filtering via applies_to) + assert mock_tf_agent._include_standards is True + + assert mock_tf_agent._include_standards is True + + def test_agent_build_context_calls_apply_governor_brief(self, build_context, build_registry, mock_tf_agent): + """_apply_governor_brief should be called with correct args.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent._include_standards = False + mock_tf_agent.set_knowledge_override = MagicMock() + mock_tf_agent.set_governor_brief = MagicMock() + + stage = {"name": "Data Layer", "layer": "data", "services": [{"name": "cosmos-db"}]} + + with patch.object(session, "_apply_governor_brief") as mock_gov, patch.object( + session, "_apply_stage_knowledge" + ): + with session._agent_build_context(mock_tf_agent, stage): + pass + + mock_gov.assert_called_once_with(mock_tf_agent, "Data Layer", [{"name": "cosmos-db"}], "data") + + def test_agent_build_context_calls_apply_stage_knowledge(self, build_context, build_registry, mock_tf_agent): + """_apply_stage_knowledge should be called with agent and stage dict.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent._include_standards = False + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"name": "App", "services": []} + + with patch.object(session, "_apply_governor_brief"), patch.object( + session, "_apply_stage_knowledge" + ) as mock_know: + with session._agent_build_context(mock_tf_agent, stage): + pass + + mock_know.assert_called_once_with(mock_tf_agent, stage) + + def test_agent_build_context_clears_knowledge_override_on_exit(self, build_context, build_registry, mock_tf_agent): + """set_knowledge_override('') must be called in the finally block.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent._include_standards = False + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"name": "Docs", "services": []} + + with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): + with session._agent_build_context(mock_tf_agent, stage): + pass + + mock_tf_agent.set_knowledge_override.assert_called_with("") + + def test_agent_build_context_restores_on_exception(self, build_context, build_registry, mock_tf_agent): + """Standards flag and knowledge override are restored even if code raises.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent._include_standards = True + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"name": "Foundation", "services": []} + + with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): + try: + with session._agent_build_context(mock_tf_agent, stage): + raise RuntimeError("simulated failure") + except RuntimeError: + pass + + assert mock_tf_agent._include_standards is True + mock_tf_agent.set_knowledge_override.assert_called_with("") + + # ------------------------------------------------------------------ # + # _select_agent + # ------------------------------------------------------------------ # + + def test_select_agent_infra_capability(self, build_context, build_registry, mock_tf_agent): + """Infra capability should resolve to the IaC (terraform) agent.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + agent = session._select_agent({"capability": "infra"}) + assert agent is mock_tf_agent + + def test_select_agent_app_capability(self, build_context, build_registry, mock_dev_agent): + """App capability should resolve to the developer agent.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + agent = session._select_agent({"capability": "app"}) + assert agent is mock_dev_agent + + def test_select_agent_docs_capability(self, build_context, build_registry, mock_doc_agent): + """Docs capability should resolve to the doc agent.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + agent = session._select_agent({"capability": "docs"}) + assert agent is mock_doc_agent + + def test_select_agent_unknown_falls_back_to_iac(self, build_context, build_registry, mock_tf_agent): + """Unknown capability falls back to IaC agent, then dev agent.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + agent = session._select_agent({"capability": "foobar"}) + assert agent is mock_tf_agent + + def test_select_agent_unknown_falls_back_to_dev_when_no_iac(self, build_context, build_registry, mock_dev_agent): + """When no IaC agent exists, unknown capability falls back to dev agent.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + session._iac_agents = {} + agent = session._select_agent({"capability": "foobar"}) + assert agent is mock_dev_agent + + # ------------------------------------------------------------------ # + # _apply_stage_knowledge + # ------------------------------------------------------------------ # + + def test_apply_stage_knowledge_passes_svc_names_to_loader(self, build_context, build_registry, mock_tf_agent): + """Service names are extracted from stage and passed to KnowledgeLoader.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"services": [{"name": "key-vault"}, {"name": "sql-server"}]} + + mock_loader = MagicMock() + mock_loader.compose_context.return_value = "knowledge text" + mock_knowledge_module = MagicMock() + mock_knowledge_module.KnowledgeLoader.return_value = mock_loader + + with patch.dict("sys.modules", {"azext_prototype.knowledge": mock_knowledge_module}): + session._apply_stage_knowledge(mock_tf_agent, stage) + + call_kwargs = mock_loader.compose_context.call_args[1] + assert "key-vault" in call_kwargs["services"] + assert "sql-server" in call_kwargs["services"] + + def test_apply_stage_knowledge_swallows_exceptions(self, build_context, build_registry, mock_tf_agent): + """Import or runtime errors must not propagate — generation must proceed.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + mock_tf_agent.set_knowledge_override = MagicMock() + + stage = {"services": [{"name": "key-vault"}]} + + with patch.dict("sys.modules", {"azext_prototype.knowledge": None}): + # Should not raise + session._apply_stage_knowledge(mock_tf_agent, stage) + + mock_tf_agent.set_knowledge_override.assert_not_called() + + # ------------------------------------------------------------------ # + # _condense_architecture + # ------------------------------------------------------------------ # + + def test_condense_architecture_returns_cached_contexts(self, build_context, build_registry): + """When stage_contexts cache is fully populated, no AI call should happen.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, + {"stage": 2, "name": "Data", "capability": "data", "services": []}, + ] + session._build_state._state["stage_contexts"] = { + "1": "## Stage 1: Foundation\nContext for stage 1", + "2": "## Stage 2: Data\nContext for stage 2", + } + + result = session._condense_architecture("arch", stages, use_styled=False) + + assert result[1] == "## Stage 1: Foundation\nContext for stage 1" + assert result[2] == "## Stage 2: Data\nContext for stage 2" + build_context.ai_provider.chat.assert_not_called() + + def test_condense_architecture_empty_response_returns_empty_dict(self, build_context, build_registry): + """Empty string response from AI provider yields empty mapping.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, + ] + + build_context.ai_provider.chat.return_value = _make_response("") + result = session._condense_architecture("arch", stages, use_styled=False) + + assert result == {} + + def test_condense_architecture_no_ai_provider_returns_empty_dict(self, build_context, build_registry): + """No AI provider means condensation can't run — return empty dict.""" + from azext_prototype.stages.build_session import BuildSession + + build_context.ai_provider = None + session = BuildSession(build_context, build_registry) + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, + ] + + result = session._condense_architecture("arch", stages, use_styled=False) + + assert result == {} + + def test_condense_architecture_parses_stage_contexts_from_response(self, build_context, build_registry): + """AI response with per-stage headings should be parsed into a mapping.""" + from azext_prototype.stages.build_session import BuildSession + + session = BuildSession(build_context, build_registry) + stages = [ + {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, + {"stage": 2, "name": "Data", "capability": "data", "services": []}, + ] + + ai_content = ( + "## Stage 1: Foundation\n" + "Builds resource group and managed identity.\n\n" + "## Stage 2: Data\n" + "Deploys Cosmos DB account.\n" + ) + build_context.ai_provider.chat.return_value = _make_response(ai_content) + + result = session._condense_architecture("architecture text", stages, use_styled=False) + + assert 1 in result + assert 2 in result + assert "Foundation" in result[1] + assert "Data" in result[2] diff --git a/tests/test_coverage_design_deploy.py b/tests/stages/test_coverage_design_deploy.py similarity index 100% rename from tests/test_coverage_design_deploy.py rename to tests/stages/test_coverage_design_deploy.py diff --git a/tests/test_coverage_gaps.py b/tests/stages/test_coverage_gaps.py similarity index 99% rename from tests/test_coverage_gaps.py rename to tests/stages/test_coverage_gaps.py index 89f7774..b3668d7 100644 --- a/tests/test_coverage_gaps.py +++ b/tests/stages/test_coverage_gaps.py @@ -490,7 +490,7 @@ class TestResolveDefinition: def test_resolve_known(self): from azext_prototype.custom import _resolve_definition - defs_dir = Path(__file__).resolve().parent.parent / "azext_prototype" / "agents" / "builtin" / "definitions" + defs_dir = Path(__file__).resolve().parent.parent.parent / "azext_prototype" / "agents" / "builtin" / "definitions" result = _resolve_definition(defs_dir, "example_custom_agent") assert result.exists() diff --git a/tests/stages/test_deploy_helpers.py b/tests/stages/test_deploy_helpers.py index 87bfb77..933b62d 100644 --- a/tests/stages/test_deploy_helpers.py +++ b/tests/stages/test_deploy_helpers.py @@ -742,3 +742,355 @@ def test_load_bad_json(self, tmp_path): (output_dir / "deployment_outputs.json").write_text("not json", encoding="utf-8") cap = DeploymentOutputCapture(str(tmp_path)) assert cap._outputs == {} + +# --- Additional imports from merged flat test --- +from azext_prototype.stages.deploy_helpers import DEPLOY_ENV_MAPPING, DeploymentOutputCapture, DeployScriptGenerator, RollbackManager, build_deploy_env, resolve_stage_secrets, scan_tf_secret_variables +from pathlib import Path + + +class TestDeployScriptGenerator: + """Test deploy script generation.""" + + def test_generate_webapp_script(self, tmp_path): + app_dir = tmp_path / "my-api" + app_dir.mkdir() + + script = DeployScriptGenerator.generate( + app_dir=app_dir, + app_name="my-api", + deploy_type="webapp", + resource_group="rg-test", + ) + + assert "#!/usr/bin/env bash" in script + assert "my-api" in script + assert "az webapp deploy" in script + assert (app_dir / "deploy.sh").exists() + + def test_generate_container_app_script(self, tmp_path): + app_dir = tmp_path / "my-app" + app_dir.mkdir() + + script = DeployScriptGenerator.generate( + app_dir=app_dir, + app_name="my-app", + deploy_type="container_app", + resource_group="rg-test", + registry="myregistry.azurecr.io", + ) + + assert "az acr build" in script + assert "az containerapp update" in script + assert "myregistry.azurecr.io" in script + + def test_generate_function_script(self, tmp_path): + app_dir = tmp_path / "my-func" + app_dir.mkdir() + + script = DeployScriptGenerator.generate( + app_dir=app_dir, + app_name="my-func", + deploy_type="function", + resource_group="rg-test", + ) + + assert "func azure functionapp publish" in script + assert "my-func" in script + + +class TestRollbackManager: + """Test rollback tracking and instructions.""" + + def test_snapshot_before_deploy(self, tmp_project): + mgr = RollbackManager(str(tmp_project)) + snapshot = mgr.snapshot_before_deploy("infra", "terraform") + + assert snapshot["scope"] == "infra" + assert snapshot["iac_tool"] == "terraform" + assert "timestamp" in snapshot + + def test_multiple_snapshots(self, tmp_project): + mgr = RollbackManager(str(tmp_project)) + mgr.snapshot_before_deploy("infra", "terraform") + mgr.snapshot_before_deploy("apps", "terraform") + + latest = mgr.get_last_snapshot() + assert latest["scope"] == "apps" + + def test_rollback_instructions_terraform(self, tmp_project): + mgr = RollbackManager(str(tmp_project)) + mgr.snapshot_before_deploy("infra", "terraform") + + instructions = mgr.get_rollback_instructions() + assert any("terraform" in line.lower() for line in instructions) + + def test_rollback_instructions_bicep(self, tmp_project): + mgr = RollbackManager(str(tmp_project)) + mgr.snapshot_before_deploy("infra", "bicep") + + instructions = mgr.get_rollback_instructions() + assert any("bicep" in line.lower() or "deployment" in line.lower() for line in instructions) + + def test_no_snapshots(self, tmp_project): + mgr = RollbackManager(str(tmp_project)) + assert mgr.get_last_snapshot() is None + + instructions = mgr.get_rollback_instructions() + assert len(instructions) >= 1 # Should have "nothing to roll back" message + + def test_persistence(self, tmp_project): + mgr1 = RollbackManager(str(tmp_project)) + mgr1.snapshot_before_deploy("infra", "terraform") + + mgr2 = RollbackManager(str(tmp_project)) + assert mgr2.get_last_snapshot() is not None + assert mgr2.get_last_snapshot()["scope"] == "infra" + + +class TestDeployEnvMapping: + """Tests for DEPLOY_ENV_MAPPING and build_deploy_env().""" + + def test_mapping_covers_all_params(self): + """Every build_deploy_env parameter has a mapping entry.""" + assert "subscription" in DEPLOY_ENV_MAPPING + assert "tenant" in DEPLOY_ENV_MAPPING + assert "client_id" in DEPLOY_ENV_MAPPING + assert "client_secret" in DEPLOY_ENV_MAPPING + + def test_mapping_includes_tf_var(self): + """Each param maps to at least one TF_VAR_* entry.""" + for param, keys in DEPLOY_ENV_MAPPING.items(): + tf_vars = [k for k in keys if k.startswith("TF_VAR_")] + assert tf_vars, f"{param} has no TF_VAR_* mapping" + + def test_mapping_includes_arm(self): + """Each param maps to at least one ARM_* entry.""" + for param, keys in DEPLOY_ENV_MAPPING.items(): + arm_vars = [k for k in keys if k.startswith("ARM_")] + assert arm_vars, f"{param} has no ARM_* mapping" + + def test_all_fields(self): + env = build_deploy_env("sub-123", "tenant-456", "client-id", "secret") + # ARM vars + assert env["ARM_SUBSCRIPTION_ID"] == "sub-123" + assert env["ARM_TENANT_ID"] == "tenant-456" + assert env["ARM_CLIENT_ID"] == "client-id" + assert env["ARM_CLIENT_SECRET"] == "secret" + # TF_VAR vars (auto-resolve HCL variables) + assert env["TF_VAR_subscription_id"] == "sub-123" + assert env["TF_VAR_tenant_id"] == "tenant-456" + assert env["TF_VAR_client_id"] == "client-id" + assert env["TF_VAR_client_secret"] == "secret" + # Legacy + assert env["SUBSCRIPTION_ID"] == "sub-123" + + def test_subscription_only(self): + env = build_deploy_env("sub-123") + assert env["ARM_SUBSCRIPTION_ID"] == "sub-123" + assert env["TF_VAR_subscription_id"] == "sub-123" + assert env["SUBSCRIPTION_ID"] == "sub-123" + assert "ARM_TENANT_ID" not in env + assert "TF_VAR_tenant_id" not in env + assert "ARM_CLIENT_ID" not in env + + def test_inherits_os_environ(self): + env = build_deploy_env("sub-123") + # PATH should be inherited from os.environ + assert "PATH" in env + + def test_empty(self): + env = build_deploy_env() + assert "ARM_SUBSCRIPTION_ID" not in env + assert "TF_VAR_subscription_id" not in env + assert "ARM_TENANT_ID" not in env + # Should still have os.environ entries + assert "PATH" in env + + +class TestDeployEnvPassing: + """Tests that verify env is passed through to subprocess calls.""" + + @patch("subprocess.run") + def test_deploy_terraform_passes_env(self, mock_run): + from azext_prototype.stages.deploy_helpers import deploy_terraform + + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + test_env = build_deploy_env("sub-123", "tenant-456") + + deploy_terraform(Path("/tmp/fake"), "sub-123", env=test_env) + + # All subprocess.run calls should receive env=test_env + for c in mock_run.call_args_list: + assert c.kwargs.get("env") is test_env + + @patch("subprocess.run") + def test_deploy_bicep_adds_tenant_flag(self, mock_run): + from azext_prototype.stages.deploy_helpers import deploy_bicep + + mock_run.return_value = MagicMock(returncode=0, stdout="{}", stderr="") + infra_dir = Path("/tmp/fake") + test_env = build_deploy_env("sub-123", "tenant-456") + + # Create a mock bicep file + with patch.object(Path, "exists", return_value=True), patch.object(Path, "glob", return_value=[]), patch( + "azext_prototype.stages.deploy_helpers.find_bicep_params", return_value=None + ), patch("azext_prototype.stages.deploy_helpers.is_subscription_scoped", return_value=False): + deploy_bicep(infra_dir, "sub-123", "my-rg", env=test_env) + + # Verify --tenant was added to the command + cmd = mock_run.call_args[0][0] + assert "--tenant" in cmd + assert "tenant-456" in cmd + assert mock_run.call_args.kwargs.get("env") is test_env + + @patch("subprocess.run") + def test_deploy_app_stage_merges_env(self, mock_run, tmp_path): + from azext_prototype.stages.deploy_helpers import deploy_app_stage + + stage_dir = tmp_path / "app" + stage_dir.mkdir() + deploy_sh = stage_dir / "deploy.sh" + deploy_sh.write_text("#!/bin/bash\necho ok") + + mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + test_env = build_deploy_env("sub-123", "tenant-456", "cid", "csecret") + + deploy_app_stage(stage_dir, "sub-123", "my-rg", env=test_env) + + passed_env = mock_run.call_args.kwargs.get("env") + assert passed_env is not None + assert passed_env["ARM_SUBSCRIPTION_ID"] == "sub-123" + assert passed_env["ARM_TENANT_ID"] == "tenant-456" + assert passed_env["SUBSCRIPTION_ID"] == "sub-123" + assert passed_env["RESOURCE_GROUP"] == "my-rg" + + @patch("subprocess.run") + def test_deploy_app_sub_dirs_receive_env(self, mock_run, tmp_path): + from azext_prototype.stages.deploy_helpers import deploy_app_stage + + stage_dir = tmp_path / "apps" + stage_dir.mkdir() + sub_app = stage_dir / "api" + sub_app.mkdir() + (sub_app / "deploy.sh").write_text("#!/bin/bash\necho ok") + + mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + test_env = build_deploy_env("sub-123", "tenant-456") + + deploy_app_stage(stage_dir, "sub-123", "my-rg", env=test_env) + + passed_env = mock_run.call_args.kwargs.get("env") + assert passed_env is not None + assert passed_env["ARM_SUBSCRIPTION_ID"] == "sub-123" + assert passed_env["ARM_TENANT_ID"] == "tenant-456" + assert passed_env["RESOURCE_GROUP"] == "my-rg" + + @patch("subprocess.run") + def test_rollback_terraform_passes_env(self, mock_run): + from azext_prototype.stages.deploy_helpers import rollback_terraform + + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + test_env = build_deploy_env("sub-123", "tenant-456") + + rollback_terraform(Path("/tmp/fake"), env=test_env) + + assert mock_run.call_args.kwargs.get("env") is test_env + + @patch("subprocess.run") + def test_plan_terraform_passes_env(self, mock_run): + from azext_prototype.stages.deploy_helpers import plan_terraform + + mock_run.return_value = MagicMock(returncode=0, stdout="Plan: 1 to add", stderr="") + test_env = build_deploy_env("sub-123") + + plan_terraform(Path("/tmp/fake"), "sub-123", env=test_env) + + for c in mock_run.call_args_list: + assert c.kwargs.get("env") is test_env + + @patch("subprocess.run") + def test_rollback_bicep_adds_tenant_flag(self, mock_run): + from azext_prototype.stages.deploy_helpers import rollback_bicep + + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + test_env = build_deploy_env("sub-123", "tenant-456") + + rollback_bicep(Path("/tmp/fake"), "sub-123", "my-rg", env=test_env) + + cmd = mock_run.call_args[0][0] + assert "--tenant" in cmd + assert "tenant-456" in cmd + assert mock_run.call_args.kwargs.get("env") is test_env + + @patch("subprocess.run") + def test_whatif_bicep_adds_tenant_flag(self, mock_run): + from azext_prototype.stages.deploy_helpers import whatif_bicep + + mock_run.return_value = MagicMock(returncode=0, stdout="What-if output", stderr="") + test_env = build_deploy_env("sub-123", "tenant-789") + + with patch.object(Path, "exists", return_value=True), patch.object(Path, "glob", return_value=[]), patch( + "azext_prototype.stages.deploy_helpers.find_bicep_params", return_value=None + ), patch("azext_prototype.stages.deploy_helpers.is_subscription_scoped", return_value=False): + whatif_bicep(Path("/tmp/fake"), "sub-123", "my-rg", env=test_env) + + cmd = mock_run.call_args[0][0] + assert "--tenant" in cmd + assert "tenant-789" in cmd + + @patch("subprocess.run") + def test_deploy_terraform_no_env_still_works(self, mock_run): + """Verify backward compat — env defaults to None.""" + from azext_prototype.stages.deploy_helpers import deploy_terraform + + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + deploy_terraform(Path("/tmp/fake"), "sub-123") + + # env=None is passed (default), which means subprocess inherits os.environ + for c in mock_run.call_args_list: + assert c.kwargs.get("env") is None + + +class TestSecretVariableScanning: + """Tests for scan_tf_secret_variables().""" + + def test_scan_finds_secret_suffix(self, tmp_path): + tf = tmp_path / "main.tf" + tf.write_text('variable "graph_client_secret" {}\n') + result = scan_tf_secret_variables(tmp_path) + assert "graph_client_secret" in result + + def test_scan_finds_password_suffix(self, tmp_path): + tf = tmp_path / "main.tf" + tf.write_text('variable "admin_password" {\n type = string\n}\n') + result = scan_tf_secret_variables(tmp_path) + assert "admin_password" in result + + def test_scan_ignores_known_vars(self, tmp_path): + tf = tmp_path / "main.tf" + tf.write_text('variable "client_secret" {}\n') + result = scan_tf_secret_variables(tmp_path) + assert "client_secret" not in result + + def test_scan_ignores_non_secret_vars(self, tmp_path): + tf = tmp_path / "main.tf" + tf.write_text('variable "location" {}\nvariable "resource_group_name" {}\n') + result = scan_tf_secret_variables(tmp_path) + assert result == [] + + def test_scan_ignores_vars_with_default(self, tmp_path): + tf = tmp_path / "main.tf" + tf.write_text('variable "api_secret" {\n default = "preset-value"\n}\n') + result = scan_tf_secret_variables(tmp_path) + assert result == [] + + def test_scan_multiple_files(self, tmp_path): + (tmp_path / "main.tf").write_text('variable "graph_client_secret" {}\n') + (tmp_path / "variables.tf").write_text('variable "db_password" {}\n') + result = scan_tf_secret_variables(tmp_path) + assert "graph_client_secret" in result + assert "db_password" in result + + def test_scan_empty_dir(self, tmp_path): + result = scan_tf_secret_variables(tmp_path) + assert result == [] diff --git a/tests/stages/test_deploy_session.py b/tests/stages/test_deploy_session.py index f0b7672..d6f58a6 100644 --- a/tests/stages/test_deploy_session.py +++ b/tests/stages/test_deploy_session.py @@ -584,3 +584,5617 @@ def test_no_files_returns_empty(self, mock_sub, mock_env, mock_oid, deploy_conte session._deploy_state._state["deployment_stages"] = [] namespaces = session._extract_providers_from_files() assert namespaces == set() + + +# --- Additional imports from merged flat test --- +from azext_prototype.agents.base import AgentContext +from azext_prototype.agents.builtin import register_all_builtin +from azext_prototype.agents.registry import AgentRegistry +from azext_prototype.ai.provider import AIResponse +from azext_prototype.config import DEFAULT_CONFIG +from azext_prototype.config import ProjectConfig +from azext_prototype.custom import _prepare_deploy_command +from azext_prototype.custom import prototype_deploy +from azext_prototype.stages.build_state import BuildState +from azext_prototype.stages.deploy_helpers import RollbackManager +from azext_prototype.stages.deploy_helpers import _terraform_validate +from azext_prototype.stages.deploy_helpers import check_az_login +from azext_prototype.stages.deploy_helpers import deploy_terraform +from azext_prototype.stages.deploy_helpers import find_bicep_params +from azext_prototype.stages.deploy_helpers import get_current_subscription +from azext_prototype.stages.deploy_helpers import get_current_tenant +from azext_prototype.stages.deploy_helpers import is_subscription_scoped +from azext_prototype.stages.deploy_helpers import login_service_principal +from azext_prototype.stages.deploy_helpers import plan_terraform +from azext_prototype.stages.deploy_helpers import rollback_terraform +from azext_prototype.stages.deploy_helpers import set_deployment_context +from azext_prototype.stages.deploy_stage import DeployStage +from azext_prototype.stages.deploy_state import DeployState +from azext_prototype.stages.deploy_state import SyncResult +from azext_prototype.stages.deploy_state import _format_display_id +from azext_prototype.stages.deploy_state import _status_icon +from azext_prototype.stages.deploy_state import parse_stage_ref +from azext_prototype.stages.intent import IntentKind, IntentResult +from knack.util import CLIError +import os +import yaml + + +# ====================================================================== + + +def _make_response(content: str = "Mock response") -> AIResponse: + return AIResponse(content=content, model="gpt-4o", usage={}) + +def _build_yaml(stages: list[dict] | None = None, iac_tool: str = "terraform") -> dict: + """Return a realistic build.yaml structure.""" + if stages is None: + stages = [ + { + "stage": 1, + "name": "Foundation", + "layer": "infra", + "capability": "infra", + "services": [ + { + "name": "key-vault", + "computed_name": "zd-kv-api-dev-eus", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + }, + ], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-foundation", + "files": [], + }, + { + "stage": 2, + "name": "Data Layer", + "layer": "data", + "capability": "data", + "services": [ + { + "name": "sql-db", + "computed_name": "zd-sql-api-dev-eus", + "resource_type": "Microsoft.Sql/servers", + "sku": "S0", + }, + ], + "status": "generated", + "dir": "concept/infra/terraform/stage-2-data", + "files": [], + }, + { + "stage": 3, + "name": "Application", + "layer": "app", + "capability": "app", + "services": [ + { + "name": "web-app", + "computed_name": "zd-app-web-dev-eus", + "resource_type": "Microsoft.Web/sites", + "sku": "B1", + }, + ], + "status": "generated", + "dir": "concept/apps/stage-3-application", + "files": [], + }, + ] + return { + "iac_tool": iac_tool, + "deployment_stages": stages, + "_metadata": {"created": "2026-01-01T00:00:00", "last_updated": "2026-01-01T00:00:00", "iteration": 1}, + } + +def _write_build_yaml(project_dir, stages=None, iac_tool="terraform"): + """Write build.yaml into the project state dir.""" + state_dir = Path(project_dir) / ".prototype" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + build_data = _build_yaml(stages, iac_tool) + with open(state_dir / "build.yaml", "w", encoding="utf-8") as f: + yaml.dump(build_data, f, default_flow_style=False) + return state_dir / "build.yaml" + +# ====================================================================== + + +class TestDeployState: + + def test_default_state_structure(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + ds = DeployState(str(tmp_project)) + state = ds.state + assert state["iac_tool"] == "terraform" + assert state["subscription"] == "" + assert state["resource_group"] == "" + assert state["deployment_stages"] == [] + assert state["preflight_results"] == [] + assert state["deploy_log"] == [] + assert state["rollback_log"] == [] + assert state["captured_outputs"] == {} + assert state["_metadata"]["iteration"] == 0 + + def test_load_save_roundtrip(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + ds = DeployState(str(tmp_project)) + ds._state["subscription"] = "test-sub-123" + ds._state["iac_tool"] = "bicep" + ds.save() + + ds2 = DeployState(str(tmp_project)) + loaded = ds2.load() + assert loaded["subscription"] == "test-sub-123" + assert loaded["iac_tool"] == "bicep" + assert loaded["_metadata"]["created"] is not None + assert loaded["_metadata"]["last_updated"] is not None + + def test_exists_property(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + ds = DeployState(str(tmp_project)) + assert not ds.exists + ds.save() + assert ds.exists + + def test_load_from_build_state(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + result = ds.load_from_build_state(build_path) + + assert result is True + assert len(ds.state["deployment_stages"]) == 3 + # Verify deploy-specific fields were added + stage = ds.state["deployment_stages"][0] + assert stage["deploy_status"] == "pending" + assert stage["deploy_timestamp"] is None + assert stage["deploy_output"] == "" + assert stage["deploy_error"] == "" + assert stage["rollback_timestamp"] is None + + def test_load_from_build_state_missing_file(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + ds = DeployState(str(tmp_project)) + result = ds.load_from_build_state("/nonexistent/build.yaml") + assert result is False + + def test_load_from_build_state_no_stages(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project, stages=[]) + ds = DeployState(str(tmp_project)) + result = ds.load_from_build_state(build_path) + assert result is False + + def test_stage_transitions(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + # pending → deploying + ds.mark_stage_deploying(1) + assert ds.get_stage(1)["deploy_status"] == "deploying" + + # deploying → deployed + ds.mark_stage_deployed(1, output="resource_id=abc123") + stage = ds.get_stage(1) + assert stage["deploy_status"] == "deployed" + assert stage["deploy_timestamp"] is not None + assert stage["deploy_output"] == "resource_id=abc123" + assert stage["deploy_error"] == "" + + # deployed → rolled_back + ds.mark_stage_rolled_back(1) + stage = ds.get_stage(1) + assert stage["deploy_status"] == "rolled_back" + assert stage["rollback_timestamp"] is not None + + def test_stage_failure(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_deploying(1) + ds.mark_stage_failed(1, error="timeout connecting to Azure") + stage = ds.get_stage(1) + assert stage["deploy_status"] == "failed" + assert stage["deploy_error"] == "timeout connecting to Azure" + + def test_get_pending_deployed_failed(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + assert len(ds.get_pending_stages()) == 3 + assert len(ds.get_deployed_stages()) == 0 + assert len(ds.get_failed_stages()) == 0 + + ds.mark_stage_deployed(1) + ds.mark_stage_failed(2, "error") + + assert len(ds.get_pending_stages()) == 1 + assert len(ds.get_deployed_stages()) == 1 + assert len(ds.get_failed_stages()) == 1 + + def test_can_rollback_ordering(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_deployed(1) + ds.mark_stage_deployed(2) + ds.mark_stage_deployed(3) + + # Can only rollback stage 3 (highest) + assert ds.can_rollback(3) is True + assert ds.can_rollback(2) is False # stage 3 still deployed + assert ds.can_rollback(1) is False # stages 2,3 still deployed + + # Roll back stage 3 + ds.mark_stage_rolled_back(3) + assert ds.can_rollback(2) is True + assert ds.can_rollback(1) is False # stage 2 still deployed + + def test_rollback_candidates_reverse_order(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_deployed(1) + ds.mark_stage_deployed(2) + ds.mark_stage_deployed(3) + + candidates = ds.get_rollback_candidates() + assert [c["stage"] for c in candidates] == [3, 2, 1] + + def test_preflight_results(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + ds = DeployState(str(tmp_project)) + results = [ + {"name": "Azure Login", "status": "pass", "message": "Logged in."}, + {"name": "Terraform", "status": "fail", "message": "Not found.", "fix_command": "brew install terraform"}, + ] + ds.set_preflight_results(results) + + failures = ds.get_preflight_failures() + assert len(failures) == 1 + assert failures[0]["name"] == "Terraform" + + def test_deploy_log(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_deploying(1) + ds.mark_stage_deployed(1) + + assert len(ds.state["deploy_log"]) == 2 + assert ds.state["deploy_log"][0]["action"] == "deploying" + assert ds.state["deploy_log"][1]["action"] == "deployed" + + def test_reset(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + assert len(ds.state["deployment_stages"]) == 3 + + ds.reset() + assert ds.state["deployment_stages"] == [] + assert ds.exists # File still exists after reset + + def test_format_deploy_report(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + ds._state["subscription"] = "sub-123" + + ds.mark_stage_deployed(1) + ds.mark_stage_failed(2, "timeout") + + report = ds.format_deploy_report() + assert "Deploy Report" in report + assert "sub-123" in report + assert "1 deployed" in report + assert "1 failed" in report + + def test_format_stage_status(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + status = ds.format_stage_status() + assert "Foundation" in status + assert "Application" in status + assert "0/3 stages deployed" in status + + def test_format_preflight_report(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + ds = DeployState(str(tmp_project)) + ds.set_preflight_results( + [ + {"name": "Azure Login", "status": "pass", "message": "OK"}, + { + "name": "Terraform", + "status": "warn", + "message": "Old version", + "fix_command": "brew upgrade terraform", + }, + ] + ) + + report = ds.format_preflight_report() + assert "Preflight Checks" in report + assert "2 passed" in report or "1 passed" in report + assert "1 warning" in report + + def test_conversation_tracking(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + ds = DeployState(str(tmp_project)) + ds.update_from_exchange("deploy all", "Deploying stage 1...", 1) + + assert len(ds.state["conversation_history"]) == 1 + assert ds.state["conversation_history"][0]["user"] == "deploy all" + +# ====================================================================== + + +class TestExtractResourceProvidersFromFiles: + """Verify _extract_providers_from_files() parses IaC files for namespaces.""" + + def _make_session(self, project_dir, iac_tool="terraform"): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + context = AgentContext( + project_config={"project": {"iac_tool": iac_tool}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_extracts_from_tf_files(self, tmp_project): + session = self._make_session(tmp_project) + stage_dir = tmp_project / "stage-1" + stage_dir.mkdir() + (stage_dir / "main.tf").write_text( + 'resource "azapi_resource" "rg" {\n' + ' type = "Microsoft.Resources/resourceGroups@2025-06-01"\n' + "}\n" + 'resource "azapi_resource" "storage" {\n' + ' type = "Microsoft.Storage/storageAccounts@2025-06-01"\n' + "}\n" + ) + session._deploy_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "dir": "stage-1", + "services": [], + "status": "generated", + "files": [], + }, + ] + namespaces = session._extract_providers_from_files() + assert "Microsoft.Resources" in namespaces + assert "Microsoft.Storage" in namespaces + + def test_extracts_from_bicep_files(self, tmp_project): + session = self._make_session(tmp_project, iac_tool="bicep") + stage_dir = tmp_project / "stage-1" + stage_dir.mkdir() + (stage_dir / "main.bicep").write_text( + "resource rg 'Microsoft.Resources/resourceGroups@2025-06-01' = {\n" + " name: 'myrg'\n" + " location: 'eastus'\n" + "}\n" + "resource kv 'Microsoft.KeyVault/vaults@2025-06-01' = {\n" + " name: 'mykv'\n" + "}\n" + ) + session._deploy_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "dir": "stage-1", + "services": [], + "status": "generated", + "files": [], + }, + ] + namespaces = session._extract_providers_from_files() + assert "Microsoft.Resources" in namespaces + assert "Microsoft.KeyVault" in namespaces + + def test_ignores_non_microsoft_types(self, tmp_project): + session = self._make_session(tmp_project) + stage_dir = tmp_project / "stage-1" + stage_dir.mkdir() + (stage_dir / "main.tf").write_text( + 'resource "null_resource" "test" {}\n' 'resource "random_string" "suffix" {}\n' + ) + session._deploy_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "dir": "stage-1", + "services": [], + "status": "generated", + "files": [], + }, + ] + namespaces = session._extract_providers_from_files() + assert len(namespaces) == 0 + + def test_handles_missing_dirs(self, tmp_project): + session = self._make_session(tmp_project) + session._deploy_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "dir": "nonexistent-dir", + "services": [], + "status": "generated", + "files": [], + }, + ] + namespaces = session._extract_providers_from_files() + assert len(namespaces) == 0 + + @patch("subprocess.run") + def test_file_based_preferred_over_metadata(self, mock_run, tmp_project): + """When IaC files exist, file-based extraction is used over metadata.""" + session = self._make_session(tmp_project) + stage_dir = tmp_project / "stage-1" + stage_dir.mkdir() + (stage_dir / "main.tf").write_text( + 'resource "azapi_resource" "storage" {\n' ' type = "Microsoft.Storage/storageAccounts@2025-06-01"\n' "}\n" + ) + session._deploy_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "dir": "stage-1", + "services": [ + {"name": "kv", "resource_type": "Microsoft.KeyVault/vaults", "sku": ""}, + ], + "status": "generated", + "files": [], + }, + ] + mock_run.return_value = MagicMock(returncode=0, stdout="Registered\n", stderr="") + results = session._check_resource_providers("sub-123") # noqa: F841 + # File-based: only Microsoft.Storage, NOT Microsoft.KeyVault from metadata + checked_namespaces = [c.args[0][4] for c in mock_run.call_args_list if "provider" in c.args[0]] + assert "Microsoft.Storage" in checked_namespaces + assert "Microsoft.KeyVault" not in checked_namespaces + + @patch("subprocess.run") + def test_falls_back_to_metadata(self, mock_run, tmp_project): + """When no IaC files exist, falls back to service metadata.""" + session = self._make_session(tmp_project) + # No stage directory created — no files to scan + session._deploy_state._state["deployment_stages"] = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "dir": "nonexistent-stage-dir", + "services": [ + {"name": "kv", "resource_type": "Microsoft.KeyVault/vaults", "sku": ""}, + ], + "status": "generated", + "files": [], + }, + ] + mock_run.return_value = MagicMock(returncode=0, stdout="Registered\n", stderr="") + results = session._check_resource_providers("sub-123") # noqa: F841 + checked_namespaces = [c.args[0][4] for c in mock_run.call_args_list if "provider" in c.args[0]] + assert "Microsoft.KeyVault" in checked_namespaces + +# ====================================================================== + + +class TestDeploySession: + + def _make_session(self, project_dir, iac_tool="terraform", build_stages=None): + """Create a DeploySession with all dependencies mocked.""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages, iac_tool=iac_tool) + + context = AgentContext( + project_config={"project": {"iac_tool": iac_tool}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + + return DeploySession(context, registry) + + def test_quit_cancels_session(self, tmp_project): + session = self._make_session(tmp_project) + output = [] + result = session.run( + subscription="sub-123", + input_fn=lambda p: "quit", + print_fn=lambda msg: output.append(msg), + ) + assert result.cancelled is True + + def test_session_loads_build_state(self, tmp_project): + session = self._make_session(tmp_project) + output = [] + # Immediately quit + session.run( + subscription="sub-123", + input_fn=lambda p: "quit", + print_fn=lambda msg: output.append(msg), + ) + # Verify stages were loaded (shown in plan overview) + joined = "\n".join(output) + assert "Foundation" in joined or "Stage" in joined + + @patch( + "azext_prototype.stages.deploy_session.subprocess.run", + return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), + ) + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) + @patch("azext_prototype.stages.deploy_session.deploy_app_stage", return_value={"status": "deployed"}) + def test_full_deploy_flow(self, mock_app, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): + """Test full interactive deploy: confirm → preflight → deploy → done.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + # Create the stage directory + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, build_stages=stages) + + inputs = iter(["", "done"]) # confirm, then done + output = [] + result = session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + assert not result.cancelled + assert len(result.deployed_stages) == 1 + + @patch( + "azext_prototype.stages.deploy_session.subprocess.run", + return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), + ) + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + @patch( + "azext_prototype.stages.deploy_session.deploy_terraform", + return_value={"status": "failed", "error": "auth error"}, + ) + def test_deploy_failure_qa_routing(self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): + """Test that deploy failure routes to QA agent.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, build_stages=stages) + # Mock QA agent response + session._qa_agent = MagicMock() + session._qa_agent.execute.return_value = _make_response("Check your service principal credentials.") + # Clear fix agents so remediation is skipped (this test verifies QA routing only) + session._iac_agents = {} + session._dev_agent = None + session._architect_agent = None + + inputs = iter(["", "done"]) # confirm, then done + output = [] + result = session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + assert len(result.failed_stages) == 1 + joined = "\n".join(output) + assert "QA Diagnosis" in joined or "service principal" in joined + + def test_dry_run_no_build_state(self, tmp_project): + """Dry run with no build state returns cancelled.""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(tmp_project) / "prototype.yaml" + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + + output = [] + result = session.run_dry_run( + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + assert result.cancelled is True + + @patch( + "azext_prototype.stages.deploy_session.plan_terraform", return_value={"output": "Plan: 3 to add", "error": None} + ) + def test_dry_run_terraform(self, mock_plan, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + + output = [] + session.run_dry_run( + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "Plan: 3 to add" in joined + + @patch( + "azext_prototype.stages.deploy_session.plan_terraform", return_value={"output": "Plan: 1 to add", "error": None} + ) + def test_dry_run_single_stage(self, mock_plan, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + { + "stage": 2, + "name": "Data", + "layer": "data", + "capability": "data", + "services": [], + "dir": "concept/infra/terraform/data", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + (tmp_project / "concept" / "infra" / "terraform" / "data").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + + output = [] + session.run_dry_run( + target_stage=1, + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + # Should only show stage 1 + assert mock_plan.call_count == 1 + + def test_dry_run_stage_not_found(self, tmp_project): + session = self._make_session(tmp_project) + output = [] + result = session.run_dry_run( + target_stage=99, + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + assert result.cancelled is True + + @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) + def test_single_stage_deploy(self, mock_tf, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + + output = [] + result = session.run_single_stage( + 1, + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + assert len(result.deployed_stages) == 1 + mock_tf.assert_called_once() + + def test_single_stage_not_found(self, tmp_project): + session = self._make_session(tmp_project) + output = [] + result = session.run_single_stage( + 99, + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + assert result.cancelled is True + + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) + def test_slash_status(self, mock_tf, mock_sub, mock_login, tmp_project): + """Test /status slash command shows stage info.""" + session = self._make_session(tmp_project) + output = [] + + inputs = iter(["", "/status", "done"]) + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "stages deployed" in joined + + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + def test_slash_help(self, mock_sub, mock_login, tmp_project): + """Test /help slash command shows available commands.""" + session = self._make_session(tmp_project) + output = [] + + # Preflight will run — need to avoid actual subprocess calls + with patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n")): + inputs = iter(["", "/help", "done"]) + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + + joined = "\n".join(output) + assert "/status" in joined + assert "/deploy" in joined + assert "/rollback" in joined + + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + def test_slash_outputs(self, mock_sub, mock_login, tmp_project): + """Test /outputs slash command.""" + session = self._make_session(tmp_project) + output = [] + + with patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n")): + inputs = iter(["", "/outputs", "done"]) + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + + joined = "\n".join(output) + assert "outputs" in joined.lower() + + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) + @patch("azext_prototype.stages.deploy_session.rollback_terraform", return_value={"status": "rolled_back"}) + def test_slash_rollback_enforces_order(self, mock_rb, mock_tf, mock_sub, mock_login, tmp_project): + """Test that /rollback enforces reverse order.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + { + "stage": 2, + "name": "Data", + "capability": "data", + "services": [], + "dir": "concept/infra/terraform/data", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + (tmp_project / "concept" / "infra" / "terraform" / "data").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + + output = [] + # Deploy all, then try to rollback stage 1 (should fail), then done + inputs = iter(["", "/rollback 1", "done"]) + with patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n")): + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + + joined = "\n".join(output) + assert "Cannot roll back" in joined or "not deployed" in joined.lower() + + def test_eof_cancels(self, tmp_project): + """Test that EOFError during prompt cancels session.""" + session = self._make_session(tmp_project) + + def eof_input(p): + raise EOFError + + result = session.run( + subscription="sub-123", + input_fn=eof_input, + print_fn=lambda msg: None, + ) + assert result.cancelled is True + + def test_docs_stage_auto_deployed(self, tmp_project): + """Test that docs-layer stages are auto-marked as deployed.""" + stages = [ + { + "stage": 1, + "name": "Docs", + "layer": "docs", + "capability": "docs", + "services": [], + "dir": "concept/docs", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "docs").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + + output = [] + result = session.run_single_stage( + 1, + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + assert len(result.deployed_stages) == 1 + +# ====================================================================== + + +class TestDeployStageIntegration: + + def test_guard_checks_build_yaml(self, tmp_project): + """Verify deploy guard checks for build.yaml (not build.json).""" + import os + + from azext_prototype.stages.deploy_stage import DeployStage + + os.chdir(str(tmp_project)) + try: + stage = DeployStage() + guards = stage.get_guards() + build_guard = [g for g in guards if g.name == "build_complete"][0] + + # No build.yaml → guard fails + assert build_guard.check_fn() is False + + # Create build.yaml → guard passes + state_dir = tmp_project / ".prototype" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "build.yaml").write_text("iac_tool: terraform\n") + assert build_guard.check_fn() is True + finally: + os.chdir("/") + + @patch("azext_prototype.stages.deploy_session.DeploySession") + def test_status_flag(self, mock_session_cls, tmp_project): + """Test --status flag shows deploy state without starting session.""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.deploy_stage import DeployStage + + _write_build_yaml(tmp_project) + context = AgentContext( + project_config={}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + registry = MagicMock() + + stage = DeployStage() + result = stage.execute(context, registry, status=True) + assert result["status"] == "status_displayed" + # DeploySession should NOT be constructed for --status + mock_session_cls.assert_not_called() + + @patch("azext_prototype.stages.deploy_session.DeploySession") + def test_reset_flag(self, mock_session_cls, tmp_project): + """Test --reset flag clears deploy state.""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.deploy_stage import DeployStage + + context = AgentContext( + project_config={}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + registry = MagicMock() + + stage = DeployStage() + result = stage.execute(context, registry, reset=True) + assert result["status"] == "reset" + mock_session_cls.assert_not_called() + + def test_dry_run_delegates(self, tmp_project): + """Test --dry-run delegates to DeploySession.run_dry_run().""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.deploy_session import DeployResult + from azext_prototype.stages.deploy_stage import DeployStage + + _write_build_yaml(tmp_project) + config_path = Path(tmp_project) / "prototype.yaml" + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + registry = MagicMock() + registry.find_by_capability.return_value = [] + + with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_cls: + mock_session = MagicMock() + mock_session.run_dry_run.return_value = DeployResult() + mock_cls.return_value = mock_session + + stage = DeployStage() + result = stage.execute(context, registry, dry_run=True, subscription="sub-123") + + mock_session.run_dry_run.assert_called_once() + assert result["mode"] == "dry-run" + + def test_single_stage_delegates(self, tmp_project): + """Test --stage N delegates to DeploySession.run_single_stage().""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.deploy_session import DeployResult + from azext_prototype.stages.deploy_stage import DeployStage + + _write_build_yaml(tmp_project) + config_path = Path(tmp_project) / "prototype.yaml" + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + registry = MagicMock() + registry.find_by_capability.return_value = [] + + with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_cls: + mock_session = MagicMock() + mock_session.run_single_stage.return_value = DeployResult(deployed_stages=[{"stage": 1}]) + mock_cls.return_value = mock_session + + stage = DeployStage() + result = stage.execute(context, registry, stage=1, subscription="sub-123") + + mock_session.run_single_stage.assert_called_once_with( + 1, subscription="sub-123", tenant=None, force=False, client_id=None, client_secret=None + ) + assert result["mode"] == "single_stage" + assert result["deployed"] == 1 + +# ====================================================================== + + +class TestDeployHelpers: + + @patch("subprocess.run") + def test_check_az_login_success(self, mock_run): + from azext_prototype.stages.deploy_helpers import check_az_login + + mock_run.return_value = MagicMock(returncode=0) + assert check_az_login() is True + + @patch("subprocess.run") + def test_check_az_login_failure(self, mock_run): + from azext_prototype.stages.deploy_helpers import check_az_login + + mock_run.return_value = MagicMock(returncode=1) + assert check_az_login() is False + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_check_az_login_missing(self, _mock_run): + from azext_prototype.stages.deploy_helpers import check_az_login + + assert check_az_login() is False + + @patch("subprocess.run") + def test_get_current_subscription(self, mock_run): + from azext_prototype.stages.deploy_helpers import get_current_subscription + + mock_run.return_value = MagicMock(returncode=0, stdout="sub-123\n") + assert get_current_subscription() == "sub-123" + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_get_current_subscription_missing(self, _mock_run): + from azext_prototype.stages.deploy_helpers import get_current_subscription + + assert get_current_subscription() == "" + + def test_rollback_manager_snapshot_stage(self, tmp_project): + from azext_prototype.stages.deploy_helpers import RollbackManager + + mgr = RollbackManager(str(tmp_project)) + snapshot = mgr.snapshot_stage(1, "infra", "terraform") + assert snapshot["stage"] == 1 + assert snapshot["scope"] == "infra" + assert snapshot["iac_tool"] == "terraform" + + @patch("subprocess.run") + def test_deploy_terraform(self, mock_run, tmp_project): + from azext_prototype.stages.deploy_helpers import deploy_terraform + + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = deploy_terraform(tmp_project, "sub-123") + assert result["status"] == "deployed" + + @patch("subprocess.run") + def test_deploy_terraform_failure(self, mock_run, tmp_project): + from azext_prototype.stages.deploy_helpers import deploy_terraform + + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Error: auth failed") + result = deploy_terraform(tmp_project, "sub-123") + assert result["status"] == "failed" + assert "auth failed" in result.get("error", "") + + @patch("subprocess.run") + def test_plan_terraform(self, mock_run, tmp_project): + from azext_prototype.stages.deploy_helpers import plan_terraform + + mock_run.return_value = MagicMock(returncode=0, stdout="Plan: 2 to add, 0 to change", stderr="") + result = plan_terraform(tmp_project, "sub-123") + assert "Plan: 2 to add" in result.get("output", "") + + @patch("subprocess.run") + def test_rollback_terraform(self, mock_run, tmp_project): + from azext_prototype.stages.deploy_helpers import rollback_terraform + + mock_run.return_value = MagicMock(returncode=0, stdout="Destroy complete", stderr="") + result = rollback_terraform(tmp_project) + assert result["status"] == "rolled_back" + + @patch("subprocess.run") + def test_rollback_terraform_failure(self, mock_run, tmp_project): + from azext_prototype.stages.deploy_helpers import rollback_terraform + + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Error: state locked") + result = rollback_terraform(tmp_project) + assert result["status"] == "failed" + + def test_find_bicep_params(self, tmp_project): + from azext_prototype.stages.deploy_helpers import find_bicep_params + + # Create test files + main_bicep = tmp_project / "main.bicep" + main_bicep.write_text("resource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {}") + params = tmp_project / "main.parameters.json" + params.write_text('{"parameters": {}}') + + result = find_bicep_params(tmp_project, main_bicep) + assert result is not None + assert result.name == "main.parameters.json" + + def test_is_subscription_scoped(self, tmp_project): + from azext_prototype.stages.deploy_helpers import is_subscription_scoped + + bicep_file = tmp_project / "main.bicep" + bicep_file.write_text( + "targetScope = 'subscription'\nresource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = {}" + ) + assert is_subscription_scoped(bicep_file) is True + + bicep_file.write_text("resource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {}") + assert is_subscription_scoped(bicep_file) is False + +# ====================================================================== + + +class TestRollbackOrdering: + + def test_rollback_with_gap_in_stages(self, tmp_project): + """Test rollback ordering works with non-contiguous stage numbers.""" + from azext_prototype.stages.deploy_state import DeployState + + stages = [ + {"stage": 1, "name": "A", "capability": "infra", "services": [], "dir": "a", "files": []}, + {"stage": 3, "name": "C", "capability": "infra", "services": [], "dir": "c", "files": []}, + {"stage": 5, "name": "E", "capability": "app", "services": [], "dir": "e", "files": []}, + ] + build_path = _write_build_yaml(tmp_project, stages=stages) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_deployed(1) + ds.mark_stage_deployed(3) + ds.mark_stage_deployed(5) + + assert ds.can_rollback(5) is True + assert ds.can_rollback(3) is False + assert ds.can_rollback(1) is False + + def test_rollback_with_mixed_statuses(self, tmp_project): + """Test rollback logic with failed and rolled-back stages.""" + from azext_prototype.stages.deploy_state import DeployState + + stages = [ + {"stage": 1, "name": "A", "capability": "infra", "services": [], "dir": "a", "files": []}, + {"stage": 2, "name": "B", "capability": "data", "services": [], "dir": "b", "files": []}, + {"stage": 3, "name": "C", "capability": "app", "services": [], "dir": "c", "files": []}, + ] + build_path = _write_build_yaml(tmp_project, stages=stages) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_deployed(1) + ds.mark_stage_deployed(2) + ds.mark_stage_failed(3, "timeout") + + # Stage 3 is failed (not deployed), so stage 2 can be rolled back + assert ds.can_rollback(2) is True + assert ds.can_rollback(1) is False # stage 2 still deployed + + def test_get_stage_returns_none_for_missing(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + ds = DeployState(str(tmp_project)) + assert ds.get_stage(999) is None + + def test_default_state_has_tenant(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + ds = DeployState(str(tmp_project)) + assert ds.state["tenant"] == "" + +# ====================================================================== + + +class TestDeployNoAI: + """Deploy stage works without an AI provider.""" + + def _make_session(self, project_dir, ai_provider=None, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=ai_provider, + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_session_works_with_none_ai_provider(self, tmp_project): + """Session initialises and quits cleanly with ai_provider=None.""" + session = self._make_session(tmp_project, ai_provider=None) + output = [] + result = session.run( + subscription="sub-123", + input_fn=lambda p: "quit", + print_fn=lambda msg: output.append(msg), + ) + assert result.cancelled is True + + @patch( + "azext_prototype.stages.deploy_session.subprocess.run", + return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), + ) + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) + def test_deploy_succeeds_without_ai(self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): + """Full deploy succeeds with ai_provider=None.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, ai_provider=None, build_stages=stages) + inputs = iter(["", "done"]) + output = [] + result = session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + assert not result.cancelled + assert len(result.deployed_stages) == 1 + + @patch( + "azext_prototype.stages.deploy_session.subprocess.run", + return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), + ) + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + @patch( + "azext_prototype.stages.deploy_session.deploy_terraform", + return_value={"status": "failed", "error": "auth error"}, + ) + def test_deploy_failure_without_ai_shows_raw_error( + self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project + ): + """Deploy failure with ai_provider=None falls back to raw error display.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, ai_provider=None, build_stages=stages) + inputs = iter(["", "done"]) + output = [] + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "auth error" in joined + + def test_dry_run_without_ai(self, tmp_project): + """Dry-run mode works with ai_provider=None.""" + session = self._make_session(tmp_project, ai_provider=None) + output = [] + result = session.run_dry_run( + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + # Should not raise — result is a DeployResult + assert not result.cancelled or result.cancelled # always passes: just no crash + +# ====================================================================== + + +class TestServicePrincipalLogin: + """Tests for login_service_principal() and set_deployment_context().""" + + @patch("subprocess.run") + def test_login_service_principal_success(self, mock_run): + from azext_prototype.stages.deploy_helpers import login_service_principal + + # First call: az login; second call: az account show (get_current_subscription) + mock_run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), # az login + MagicMock(returncode=0, stdout="sub-from-sp\n", stderr=""), # az account show + ] + result = login_service_principal("app-id", "secret", "tenant-id") + assert result["status"] == "ok" + assert result["subscription"] == "sub-from-sp" + + # Verify az login was called with correct args + login_call = mock_run.call_args_list[0] + assert "--service-principal" in login_call[0][0] + assert "-u" in login_call[0][0] + assert "app-id" in login_call[0][0] + + @patch("subprocess.run") + def test_login_service_principal_failure(self, mock_run): + from azext_prototype.stages.deploy_helpers import login_service_principal + + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="AADSTS7000215: Invalid client secret") + result = login_service_principal("app-id", "bad-secret", "tenant-id") + assert result["status"] == "failed" + assert "Invalid client secret" in result["error"] + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_login_service_principal_no_az_cli(self, mock_run): + from azext_prototype.stages.deploy_helpers import login_service_principal + + result = login_service_principal("app-id", "secret", "tenant-id") + assert result["status"] == "failed" + assert "az CLI not found" in result["error"] + + @patch("subprocess.run") + def test_set_deployment_context_success(self, mock_run): + from azext_prototype.stages.deploy_helpers import set_deployment_context + + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = set_deployment_context("sub-123", "tenant-456") + assert result["status"] == "ok" + + cmd = mock_run.call_args[0][0] + assert "--subscription" in cmd + assert "sub-123" in cmd + assert "--tenant" in cmd + assert "tenant-456" in cmd + + @patch("subprocess.run") + def test_set_deployment_context_no_tenant(self, mock_run): + from azext_prototype.stages.deploy_helpers import set_deployment_context + + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = set_deployment_context("sub-123") + assert result["status"] == "ok" + + cmd = mock_run.call_args[0][0] + assert "--subscription" in cmd + assert "--tenant" not in cmd + + @patch("subprocess.run") + def test_set_deployment_context_failure(self, mock_run): + from azext_prototype.stages.deploy_helpers import set_deployment_context + + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Subscription not found") + result = set_deployment_context("bad-sub") + assert result["status"] == "failed" + assert "Subscription not found" in result["error"] + + @patch("subprocess.run") + def test_get_current_tenant(self, mock_run): + from azext_prototype.stages.deploy_helpers import get_current_tenant + + mock_run.return_value = MagicMock(returncode=0, stdout="tenant-abc\n", stderr="") + result = get_current_tenant() + assert result == "tenant-abc" + +# ====================================================================== + + +class TestTenantPreflight: + """Tests for tenant preflight checking in DeploySession.""" + + def _make_session(self, project_dir): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + @patch("azext_prototype.stages.deploy_session.get_current_tenant", return_value="tenant-abc") + def test_tenant_preflight_match(self, mock_tenant, tmp_project): + session = self._make_session(tmp_project) + result = session._check_tenant("tenant-abc") + assert result["status"] == "pass" + + @patch("azext_prototype.stages.deploy_session.get_current_tenant", return_value="tenant-xyz") + def test_tenant_preflight_mismatch(self, mock_tenant, tmp_project): + session = self._make_session(tmp_project) + result = session._check_tenant("tenant-abc") + assert result["status"] == "warn" + assert "fix_command" in result + assert "az login --tenant" in result["fix_command"] + +# ====================================================================== + + +class TestDeploySPValidation: + """Tests for --service-principal validation in prototype_deploy.""" + + @patch("azext_prototype.custom._check_requirements") + @patch("azext_prototype.custom._get_project_dir") + def test_sp_missing_params_raises(self, mock_dir, mock_check_req, project_with_config): + from knack.util import CLIError + + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_config) + + with pytest.raises(CLIError, match="requires client-id"): + prototype_deploy( + cmd=MagicMock(), + service_principal=True, + client_id="abc", + # Missing client_secret and tenant_id + ) + + @patch("azext_prototype.custom._check_requirements") + @patch("azext_prototype.custom._get_project_dir") + @patch("azext_prototype.stages.deploy_helpers.login_service_principal") + def test_sp_login_failure_raises(self, mock_login, mock_dir, mock_check_req, project_with_config): + from knack.util import CLIError + + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_config) + mock_login.return_value = {"status": "failed", "error": "bad creds"} + + with pytest.raises(CLIError, match="Service principal login failed"): + prototype_deploy( + cmd=MagicMock(), + service_principal=True, + client_id="abc", + client_secret="def", + tenant_id="ghi", + ) + + @patch("azext_prototype.custom._check_requirements") + @patch("azext_prototype.custom._get_project_dir") + @patch("azext_prototype.stages.deploy_helpers.login_service_principal") + @patch("azext_prototype.custom._check_guards") + def test_sp_login_success_proceeds(self, mock_guards, mock_login, mock_dir, mock_check_req, project_with_config): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_config) + mock_login.return_value = {"status": "ok", "subscription": "sp-sub-123"} + + # Let guards pass, but make deploy_stage.execute raise so we can verify flow + mock_guards.return_value = None + + with patch("azext_prototype.stages.deploy_stage.DeployStage.execute") as mock_exec: + mock_exec.return_value = {"status": "success"} + result = prototype_deploy( + cmd=MagicMock(), + service_principal=True, + client_id="abc", + client_secret="def", + tenant_id="ghi", + json_output=True, + ) + assert result["status"] == "success" + # Verify tenant and subscription were passed through + call_kwargs = mock_exec.call_args[1] + assert call_kwargs["tenant"] == "ghi" + assert call_kwargs["subscription"] == "sp-sub-123" + +# ====================================================================== + + +class TestSubscriptionResolution: + """Tests for subscription resolution: CLI arg > config > current context.""" + + def _make_session(self, project_dir, config_subscription=""): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + "deploy": {"subscription": config_subscription, "resource_group": ""}, + } + config_path = Path(project_dir) / "prototype.yaml" + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_cli_arg_takes_priority(self, tmp_project): + session = self._make_session(tmp_project, config_subscription="config-sub") + output = [] + session.run( + subscription="cli-sub", + input_fn=lambda p: "quit", + print_fn=lambda msg: output.append(msg), + ) + # The subscription displayed should be the CLI arg + joined = "\n".join(output) + assert "cli-sub" in joined + + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="context-sub") + def test_config_sub_used_when_no_cli_arg(self, mock_sub, tmp_project): + session = self._make_session(tmp_project, config_subscription="config-sub") + output = [] + session.run( + input_fn=lambda p: "quit", + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "config-sub" in joined + +# ====================================================================== + + +class TestLoginSlashCommand: + """Tests for the /login slash command in DeploySession.""" + + def _make_session(self, project_dir): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_login_command_success(self, mock_run, tmp_project): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + session = self._make_session(tmp_project) + output = [] + session._handle_slash_command( + "/login", + False, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + joined = "\n".join(output) + assert "Login successful" in joined + assert "/preflight" in joined + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_login_command_failure(self, mock_run, tmp_project): + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="AADSTS error") + session = self._make_session(tmp_project) + output = [] + session._handle_slash_command( + "/login", + False, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + joined = "\n".join(output) + assert "Login failed" in joined + + def test_help_includes_login(self, tmp_project): + session = self._make_session(tmp_project) + output = [] + session._handle_slash_command( + "/help", + False, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + joined = "\n".join(output) + assert "/login" in joined + +# ====================================================================== + + +class TestPrepareDeployCommand: + """Tests for _prepare_deploy_command in custom.py.""" + + @patch("azext_prototype.custom._check_requirements") + @patch("azext_prototype.custom._get_project_dir") + def test_returns_none_ai_provider_when_factory_fails(self, mock_dir, mock_check_req, project_with_config): + from azext_prototype.custom import _prepare_deploy_command + + mock_dir.return_value = str(project_with_config) + + with patch("azext_prototype.ai.factory.create_ai_provider", side_effect=Exception("No Copilot license")): + project_dir, config, registry, agent_context = _prepare_deploy_command() + + assert agent_context.ai_provider is None + assert project_dir == str(project_with_config) + + @patch("azext_prototype.custom._check_requirements") + @patch("azext_prototype.custom._get_project_dir") + def test_returns_ai_provider_when_factory_succeeds(self, mock_dir, mock_check_req, project_with_config): + from azext_prototype.custom import _prepare_deploy_command + + mock_dir.return_value = str(project_with_config) + mock_provider = MagicMock() + + with patch("azext_prototype.ai.factory.create_ai_provider", return_value=mock_provider): + project_dir, config, registry, agent_context = _prepare_deploy_command() + + assert agent_context.ai_provider is mock_provider + +# ====================================================================== + + +class TestConfigSPRouting: + """Verify SP credentials route to secrets file.""" + + def test_sp_client_id_is_secret(self): + from azext_prototype.config import ProjectConfig + + assert ProjectConfig._is_secret_key("deploy.service_principal.client_id") + assert ProjectConfig._is_secret_key("deploy.service_principal.client_secret") + assert ProjectConfig._is_secret_key("deploy.service_principal.tenant_id") + + def test_default_config_has_sp_section(self): + from azext_prototype.config import DEFAULT_CONFIG + + deploy = DEFAULT_CONFIG["deploy"] + assert "tenant" in deploy + assert "service_principal" in deploy + sp = deploy["service_principal"] + assert "client_id" in sp + assert "client_secret" in sp + assert "tenant_id" in sp + +# ====================================================================== + + +class TestTerraformValidate: + """Tests for the _terraform_validate() helper in deploy_helpers.""" + + @patch("subprocess.run") + def test_validate_success(self, mock_run): + from azext_prototype.stages.deploy_helpers import _terraform_validate + + mock_run.return_value = MagicMock(returncode=0, stdout="Success!", stderr="") + result = _terraform_validate(Path("/tmp/fake")) + assert result["ok"] is True + + @patch("subprocess.run") + def test_validate_failure(self, mock_run): + from azext_prototype.stages.deploy_helpers import _terraform_validate + + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Error: Unsupported block type") + result = _terraform_validate(Path("/tmp/fake")) + assert result["ok"] is False + assert "Unsupported block type" in result["error"] + + @patch("subprocess.run") + def test_validate_returns_stdout_on_empty_stderr(self, mock_run): + from azext_prototype.stages.deploy_helpers import _terraform_validate + + mock_run.return_value = MagicMock(returncode=1, stdout="Invalid HCL syntax", stderr="") + result = _terraform_validate(Path("/tmp/fake")) + assert result["ok"] is False + assert "Invalid HCL syntax" in result["error"] + + @patch("subprocess.run") + def test_deploy_terraform_calls_validate(self, mock_run, tmp_project): + """Verify deploy_terraform() calls validate between init and plan.""" + from azext_prototype.stages.deploy_helpers import deploy_terraform + + # init succeeds, validate fails + mock_run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), # init + MagicMock(returncode=1, stdout="", stderr="Error: bad HCL"), # validate + ] + result = deploy_terraform(tmp_project, "sub-123") + assert result["status"] == "failed" + assert result["command"] == "terraform validate" + assert "bad HCL" in result["error"] + + @patch("subprocess.run") + def test_deploy_terraform_validate_pass_continues(self, mock_run, tmp_project): + """Verify deploy_terraform() continues past validate when it passes.""" + from azext_prototype.stages.deploy_helpers import deploy_terraform + + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = deploy_terraform(tmp_project, "sub-123") + assert result["status"] == "deployed" + # Should have called: init, validate, plan, apply = 4 calls + assert mock_run.call_count == 4 + +# ====================================================================== + + +class TestTerraformPreflightValidation: + """Tests for _check_terraform_validate() in DeploySession.""" + + def _make_session(self, project_dir, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + build_path = _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + # Load build state into deploy state so _check_terraform_validate has stages + session._deploy_state.load_from_build_state(build_path) + return session + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_valid_terraform_passes(self, mock_run, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + stage_dir = tmp_project / "concept" / "infra" / "terraform" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text('resource "azurerm_resource_group" "rg" {}') + + session = self._make_session(tmp_project, build_stages=stages) + + mock_run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), # init + MagicMock(returncode=0, stdout="", stderr=""), # validate + ] + results = session._check_terraform_validate() + assert len(results) == 1 + assert results[0]["status"] == "pass" + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_invalid_terraform_fails(self, mock_run, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + stage_dir = tmp_project / "concept" / "infra" / "terraform" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "versions.tf").write_text("}") + + session = self._make_session(tmp_project, build_stages=stages) + + mock_run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), # init + MagicMock(returncode=1, stdout="", stderr="Error: Unsupported block type"), # validate + ] + results = session._check_terraform_validate() + assert len(results) == 1 + assert results[0]["status"] == "fail" + assert "Unsupported block type" in results[0]["message"] + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_init_failure_reported(self, mock_run, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + stage_dir = tmp_project / "concept" / "infra" / "terraform" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("bad content") + + session = self._make_session(tmp_project, build_stages=stages) + + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Init error") + results = session._check_terraform_validate() + assert len(results) == 1 + assert results[0]["status"] == "fail" + assert "Init failed" in results[0]["message"] + + def test_skips_app_stages(self, tmp_project): + stages = [ + { + "stage": 1, + "name": "App", + "layer": "app", + "capability": "app", + "services": [], + "dir": "concept/apps/stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "apps" / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + results = session._check_terraform_validate() + assert len(results) == 0 + + def test_skips_missing_dirs(self, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform/nonexistent", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + results = session._check_terraform_validate() + assert len(results) == 0 + + def test_skips_dirs_without_tf_files(self, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + stage_dir = tmp_project / "concept" / "infra" / "terraform" + stage_dir.mkdir(parents=True, exist_ok=True) + # No .tf files in the directory + + session = self._make_session(tmp_project, build_stages=stages) + results = session._check_terraform_validate() + assert len(results) == 0 + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_preflight_includes_terraform_validate(self, mock_run, tmp_project): + """Verify _run_preflight() includes terraform validate results.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + stage_dir = tmp_project / "concept" / "infra" / "terraform" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text('resource "null" "x" {}') + + session = self._make_session(tmp_project, build_stages=stages) + session._subscription = "sub-123" + + mock_run.return_value = MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr="") + + with patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True), patch( + "azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123" + ): + results = session._run_preflight() + + names = [r["name"] for r in results] + assert any("Terraform Validate" in n for n in names) + +# ====================================================================== + + +class TestDeployEnv: + """Tests for deploy env construction and threading in DeploySession.""" + + def _make_session(self, project_dir, config_data=None, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + if config_data is None: + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + + config_path = Path(project_dir) / "prototype.yaml" + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_resolve_context_builds_deploy_env(self, tmp_project): + session = self._make_session(tmp_project) + session._resolve_context("sub-123", None) + + assert session._deploy_env is not None + assert session._deploy_env["ARM_SUBSCRIPTION_ID"] == "sub-123" + assert session._deploy_env["SUBSCRIPTION_ID"] == "sub-123" + + def test_resolve_context_with_tenant(self, tmp_project): + session = self._make_session(tmp_project) + session._resolve_context("sub-123", "tenant-456") + + assert session._deploy_env is not None + assert session._deploy_env["ARM_TENANT_ID"] == "tenant-456" + + def test_resolve_context_sp_creds_in_env(self, tmp_project): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + "deploy": { + "service_principal": { + "client_id": "sp-client", + "client_secret": "sp-secret", + "tenant_id": "sp-tenant", + }, + }, + } + # Write secrets file with SP creds + secrets_path = Path(tmp_project) / "prototype.secrets.yaml" + secrets_data = { + "deploy": { + "service_principal": { + "client_id": "sp-client", + "client_secret": "sp-secret", + "tenant_id": "sp-tenant", + }, + }, + } + with open(secrets_path, "w") as f: + yaml.dump(secrets_data, f) + + session = self._make_session(tmp_project, config_data=config_data) + session._resolve_context("sub-123", None) + + env = session._deploy_env + assert env is not None + # SP creds come from config.get("deploy.service_principal") which + # reads merged config+secrets. If the config has them, they should + # appear in the env. + assert env["ARM_SUBSCRIPTION_ID"] == "sub-123" + + @patch("azext_prototype.stages.deploy_session.deploy_terraform") + @patch("azext_prototype.stages.deploy_session.set_deployment_context", return_value={"status": "ok"}) + def test_deploy_single_stage_passes_env(self, _mock_ctx, mock_tf, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, build_stages=stages) + # Load build state into deploy state + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + session._resolve_context("sub-123", "tenant-456") + + mock_tf.return_value = {"status": "deployed"} + + stage = session._deploy_state._state["deployment_stages"][0] + session._deploy_single_stage(stage) + + # Verify env= was passed + assert mock_tf.called + _, kwargs = mock_tf.call_args + assert "env" in kwargs + assert kwargs["env"]["ARM_SUBSCRIPTION_ID"] == "sub-123" + assert kwargs["env"]["ARM_TENANT_ID"] == "tenant-456" + + @patch("azext_prototype.stages.deploy_session.deploy_bicep") + @patch("azext_prototype.stages.deploy_session.set_deployment_context", return_value={"status": "ok"}) + def test_deploy_single_stage_bicep_passes_env(self, _mock_ctx, mock_bicep, tmp_project): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "bicep"}, + "ai": {"provider": "github-models"}, + } + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/bicep", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "bicep").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, config_data=config_data, build_stages=stages) + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + session._resolve_context("sub-123", "tenant-456") + + mock_bicep.return_value = {"status": "deployed"} + + stage = session._deploy_state._state["deployment_stages"][0] + session._deploy_single_stage(stage) + + assert mock_bicep.called + _, kwargs = mock_bicep.call_args + assert kwargs["env"]["ARM_TENANT_ID"] == "tenant-456" + + @patch("azext_prototype.stages.deploy_session.rollback_terraform") + @patch("azext_prototype.stages.deploy_session.set_deployment_context", return_value={"status": "ok"}) + def test_rollback_passes_env(self, _mock_ctx, mock_rb, tmp_project): + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, build_stages=stages) + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + session._resolve_context("sub-123", "tenant-456") + + # Mark as deployed so we can rollback + session._deploy_state.mark_stage_deployed(1) + + mock_rb.return_value = {"status": "rolled_back"} + output = [] + session._rollback_stage(1, lambda msg: output.append(msg)) + + assert mock_rb.called + _, kwargs = mock_rb.call_args + assert kwargs["env"]["ARM_SUBSCRIPTION_ID"] == "sub-123" + +# ====================================================================== + + +class TestDeployerObjectIdLookup: + """Tests for _lookup_deployer_object_id() and its integration.""" + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_sp_lookup(self, mock_run): + from azext_prototype.stages.deploy_session import _lookup_deployer_object_id + + mock_run.return_value = MagicMock(returncode=0, stdout="sp-object-id-abc\n", stderr="") + result = _lookup_deployer_object_id("my-client-id") + + assert result == "sp-object-id-abc" + cmd = mock_run.call_args[0][0] + assert "sp" in cmd + assert "show" in cmd + assert "my-client-id" in cmd + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_user_lookup(self, mock_run): + from azext_prototype.stages.deploy_session import _lookup_deployer_object_id + + mock_run.return_value = MagicMock(returncode=0, stdout="user-object-id-xyz\n", stderr="") + result = _lookup_deployer_object_id(None) + + assert result == "user-object-id-xyz" + cmd = mock_run.call_args[0][0] + assert "signed-in-user" in cmd + + @patch("azext_prototype.stages.deploy_session.subprocess.run") + def test_lookup_failure_returns_none(self, mock_run): + from azext_prototype.stages.deploy_session import _lookup_deployer_object_id + + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error") + assert _lookup_deployer_object_id("bad-id") is None + assert _lookup_deployer_object_id(None) is None + + @patch("azext_prototype.stages.deploy_session.subprocess.run", side_effect=FileNotFoundError) + def test_lookup_no_az_cli(self, _mock_run): + from azext_prototype.stages.deploy_session import _lookup_deployer_object_id + + assert _lookup_deployer_object_id("client-id") is None + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value="sp-oid-123") + @patch("azext_prototype.stages.deploy_session.set_deployment_context", return_value={"status": "ok"}) + def test_resolve_context_sets_deployer_oid_for_sp(self, _mock_ctx, _mock_lookup, tmp_project): + """SP auth: deployer_object_id is the SP's object ID.""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(tmp_project) / "prototype.yaml" + config_data = { + "project": {"name": "t", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + context = AgentContext(project_config={}, project_dir=str(tmp_project), ai_provider=MagicMock()) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + + session._resolve_context("sub-123", "tenant-456", client_id="my-app-id", client_secret="secret") + + assert session._deploy_env["TF_VAR_deployer_object_id"] == "sp-oid-123" + _mock_lookup.assert_called_once_with("my-app-id") + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value="user-oid-456") + def test_resolve_context_sets_deployer_oid_for_user(self, _mock_lookup, tmp_project): + """User auth (no SP): deployer_object_id is the signed-in user's object ID.""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(tmp_project) / "prototype.yaml" + config_data = { + "project": {"name": "t", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + context = AgentContext(project_config={}, project_dir=str(tmp_project), ai_provider=MagicMock()) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + + session._resolve_context("sub-123", None) + + assert session._deploy_env["TF_VAR_deployer_object_id"] == "user-oid-456" + # Called with None (no client_id) → signed-in-user path + _mock_lookup.assert_called_once_with(None) + + @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) + def test_resolve_context_no_oid_when_lookup_fails(self, _mock_lookup, tmp_project): + """When lookup fails, TF_VAR_deployer_object_id is not set.""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(tmp_project) / "prototype.yaml" + config_data = { + "project": {"name": "t", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + context = AgentContext(project_config={}, project_dir=str(tmp_project), ai_provider=MagicMock()) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + + session._resolve_context("sub-123", None) + + assert "TF_VAR_deployer_object_id" not in session._deploy_env + +# ====================================================================== + + +class TestRunPhasesCoverage: + """Tests covering run() phases: no build state, re-entry sync, + preflight failure branch, interactive loop edge cases.""" + + def _make_session(self, project_dir, iac_tool="terraform", build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + if build_stages is not None: + _write_build_yaml(project_dir, stages=build_stages, iac_tool=iac_tool) + + context = AgentContext( + project_config={"project": {"iac_tool": iac_tool}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_run_no_build_state_returns_cancelled(self, tmp_project): + """Lines 322-324: No build state file => cancelled.""" + session = self._make_session(tmp_project, build_stages=None) + # No build.yaml written + output = [] + result = session.run( + subscription="sub-123", + input_fn=lambda p: "quit", + print_fn=lambda msg: output.append(msg), + ) + assert result.cancelled is True + joined = "\n".join(output) + assert "No build state found" in joined + + def test_run_reentry_sync_shows_changes(self, tmp_project): + """Lines 326-335: Re-entry with build state changes shows sync info.""" + from azext_prototype.stages.deploy_state import SyncResult + + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + # Pre-load deployment_stages so re-entry branch triggers + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + + sync = SyncResult(created=["Stage 2: Data"], orphaned=[], updated_code=1, details=["Added new Stage 2: Data"]) + with patch.object(session._deploy_state, "sync_from_build_state", return_value=sync): + output = [] + session.run( + subscription="sub-123", + input_fn=lambda p: "quit", + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "Build state changed" in joined + assert "updated code" in joined.lower() or "1 deployed stage(s)" in joined + + def test_run_tenant_displayed(self, tmp_project): + """Lines 352-353: Tenant is printed during plan overview.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session.run( + subscription="sub-123", + tenant="tenant-abc", + input_fn=lambda p: "quit", + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "tenant-abc" in joined + + def test_run_resource_group_displayed(self, tmp_project): + """Lines 354-355: Resource group is printed when set.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + "deploy": {"resource_group": "my-rg", "subscription": ""}, + } + config_path = Path(tmp_project) / "prototype.yaml" + with open(config_path, "w") as f: + yaml.dump(config_data, f) + _write_build_yaml(tmp_project, stages=stages) + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + + output = [] + session.run( + subscription="sub-123", + input_fn=lambda p: "quit", + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "my-rg" in joined + + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=False) + @patch( + "azext_prototype.stages.deploy_session.subprocess.run", + return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), + ) + def test_run_preflight_failure_branch(self, _mock_sub, _mock_login, tmp_project): + """Lines 388-391: Preflight failures print fix instructions.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + inputs = iter(["", "done"]) + output = [] + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "preflight checks failed" in joined.lower() or "fix the issues" in joined.lower() + + def test_run_empty_input_continues(self, tmp_project): + """Lines 419-420: Empty input during interactive loop does nothing.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + # Skip preflight by having everything fail, then loop: empty -> quit + inputs = iter(["", "", "", "quit"]) + output = [] + with patch.object(session, "_run_preflight", return_value=[]): + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + # Reached quit without error + + def test_run_done_finishes(self, tmp_project): + """Lines 427-428: 'done' word exits loop.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + inputs = iter(["", "lgtm"]) + output = [] + with patch.object(session, "_run_preflight", return_value=[]): + result = session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + assert not result.cancelled + + def test_run_eof_in_interactive_loop_breaks(self, tmp_project): + """Lines 416-417: EOFError in interactive loop breaks cleanly.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + call_count = [0] + + def eof_on_second(p): + call_count[0] += 1 + if call_count[0] == 1: + return "" # confirm + raise EOFError + + with patch.object(session, "_run_preflight", return_value=[]): + result = session.run( + subscription="sub-123", + input_fn=eof_on_second, + print_fn=lambda msg: None, + ) + assert not result.cancelled # exits normally via break + + def test_run_natural_language_fallback(self, tmp_project): + """Line 468: Unrecognized input shows help hint.""" + from azext_prototype.stages.intent import IntentKind, IntentResult + + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + # Mock intent classifier to return CONVERSATIONAL (no matching command) + session._intent_classifier = MagicMock() + session._intent_classifier.classify.return_value = IntentResult( + kind=IntentKind.CONVERSATIONAL, command="", args="" + ) + inputs = iter(["", "something random", "quit"]) + output = [] + with patch.object(session, "_run_preflight", return_value=[]): + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "/help" in joined + + def test_run_natural_language_multi_stage(self, tmp_project): + """Lines 448-456: Multi-stage intent dispatches multiple commands.""" + from azext_prototype.stages.intent import IntentKind, IntentResult + + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + session._intent_classifier = MagicMock() + session._intent_classifier.classify.return_value = IntentResult( + kind=IntentKind.COMMAND, command="/deploy", args="stages 1 and 2" + ) + inputs = iter(["", "deploy stages 1 and 2", "quit"]) + output = [] + with patch.object(session, "_run_preflight", return_value=[]): + with patch.object(session, "_handle_slash_command") as mock_cmd: + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + # Should have dispatched /deploy 1 and /deploy 2 + calls = [c.args[0] for c in mock_cmd.call_args_list] + assert "/deploy 1" in calls + assert "/deploy 2" in calls + +class TestSingleStageFailureRemediation: + """Tests for run_single_stage failure remediation (lines 587-598).""" + + def _make_session(self, project_dir, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + @patch( + "azext_prototype.stages.deploy_session.deploy_terraform", + return_value={"status": "failed", "error": "auth error"}, + ) + def test_single_stage_failure_shows_error_and_attempts_remediation(self, mock_tf, tmp_project): + """Lines 587-598: Single-stage failure prints error and tries remediation.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + # Clear fix agents so _remediate_deploy_failure returns None + session._iac_agents = {} + session._dev_agent = None + + output = [] + session.run_single_stage( + 1, + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "failed" in joined.lower() + assert "auth error" in joined + + @patch("azext_prototype.stages.deploy_session.deploy_terraform") + def test_single_stage_remediation_success(self, mock_tf, tmp_project): + """Lines 597-598: Remediation succeeds prints success.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + # First call fails, remediation returns deployed + mock_tf.return_value = {"status": "failed", "error": "oops"} + + with patch.object( + session, + "_remediate_deploy_failure", + return_value={"status": "deployed"}, + ): + output = [] + session.run_single_stage( + 1, + subscription="sub-123", + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "remediation" in joined.lower() + +class TestDeployPendingStagesAwaitingManual: + """Tests covering awaiting_manual status (lines 892-909).""" + + def _make_session(self, project_dir, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_manual_step_done_marks_deployed(self, tmp_project): + """Lines 892-904: Manual step answered with 'done' marks deployed.""" + stages = [ + { + "stage": 1, + "name": "Manual DNS", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + "deploy_mode": "manual", + "manual_instructions": "Update DNS records.", + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + # Manually set deploy_mode on the loaded state + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + ds = session._deploy_state._state["deployment_stages"][0] + ds["deploy_mode"] = "manual" + ds["manual_instructions"] = "Update DNS records." + + output = [] + session._deploy_pending_stages( + force=False, + use_styled=False, + _print=lambda msg: output.append(msg), + _input=lambda p: "done", + ) + joined = "\n".join(output) + assert "Manual step required" in joined or "manual" in joined.lower() + + def test_manual_step_skip(self, tmp_project): + """Lines 905-906: Manual step answered with 'skip' skips.""" + stages = [ + { + "stage": 1, + "name": "Manual DNS", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + ds = session._deploy_state._state["deployment_stages"][0] + ds["deploy_mode"] = "manual" + ds["manual_instructions"] = "Do something manual." + + output = [] + session._deploy_pending_stages( + force=False, + use_styled=False, + _print=lambda msg: output.append(msg), + _input=lambda p: "skip", + ) + joined = "\n".join(output) + assert "skip" in joined.lower() + + def test_manual_step_eof_skips(self, tmp_project): + """Lines 899-901: Manual step EOF is treated as skipped.""" + stages = [ + { + "stage": 1, + "name": "Manual Step", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + ds = session._deploy_state._state["deployment_stages"][0] + ds["deploy_mode"] = "manual" + ds["manual_instructions"] = "Do it." + + output = [] + session._deploy_pending_stages( + force=False, + use_styled=False, + _print=lambda msg: output.append(msg), + _input=lambda p: (_ for _ in ()).throw(EOFError), + ) + joined = "\n".join(output) + assert "skipped" in joined.lower() + + def test_manual_step_other_breaks(self, tmp_project): + """Lines 907-909: Unknown answer pauses deployment.""" + stages = [ + { + "stage": 1, + "name": "Manual Step", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + ds = session._deploy_state._state["deployment_stages"][0] + ds["deploy_mode"] = "manual" + ds["manual_instructions"] = "Do it." + + output = [] + session._deploy_pending_stages( + force=False, + use_styled=False, + _print=lambda msg: output.append(msg), + _input=lambda p: "help me", + ) + joined = "\n".join(output) + assert "pausing" in joined.lower() or "continue" in joined.lower() + +class TestRollbackAllCoverage: + """Tests for _rollback_all (lines 1618-1640).""" + + def _make_session(self, project_dir, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_rollback_all_no_candidates(self, tmp_project): + """Lines 1619-1621: No deployed stages to roll back.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + + output = [] + session._rollback_all(lambda msg: output.append(msg), lambda p: "y") + joined = "\n".join(output) + assert "No deployed stages" in joined + + @patch("azext_prototype.stages.deploy_session.rollback_terraform") + def test_rollback_all_confirms_each(self, mock_rb, tmp_project): + """Lines 1626-1640: Confirms each stage and rolls back.""" + stages = [ + { + "stage": 1, + "name": "A", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + { + "stage": 2, + "name": "B", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "stage-2", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + (tmp_project / "stage-2").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + session._deploy_state.mark_stage_deployed(1) + session._deploy_state.mark_stage_deployed(2) + session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} + + mock_rb.return_value = {"status": "rolled_back"} + output = [] + session._rollback_all(lambda msg: output.append(msg), lambda p: "y") + joined = "\n".join(output) + assert "Rolling back" in joined + assert mock_rb.call_count == 2 + + def test_rollback_all_decline_stops(self, tmp_project): + """Lines 1635-1637: Declining rollback stops the sequence.""" + stages = [ + { + "stage": 1, + "name": "A", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + { + "stage": 2, + "name": "B", + "capability": "infra", + "services": [], + "dir": "stage-2", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + session._deploy_state.mark_stage_deployed(1) + session._deploy_state.mark_stage_deployed(2) + + output = [] + session._rollback_all(lambda msg: output.append(msg), lambda p: "n") + joined = "\n".join(output) + assert "Skipping" in joined + + def test_rollback_all_eof_cancels(self, tmp_project): + """Lines 1631-1633: EOF during rollback cancels.""" + stages = [ + { + "stage": 1, + "name": "A", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + session._deploy_state.mark_stage_deployed(1) + + output = [] + session._rollback_all( + lambda msg: output.append(msg), + lambda p: (_ for _ in ()).throw(EOFError), + ) + joined = "\n".join(output) + assert "cancelled" in joined.lower() + +class TestSlashCommandPlan: + """Tests covering /plan slash command (lines 1842-1875).""" + + def _make_session(self, project_dir, iac_tool="terraform", build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages, iac_tool=iac_tool) + + context = AgentContext( + project_config={"project": {"iac_tool": iac_tool}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + return session + + def test_plan_no_arg(self, tmp_project): + """Lines 1843-1844: /plan without arg shows usage.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_slash_command("/plan", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "Usage" in joined + + def test_plan_manual_stage(self, tmp_project): + """Line 1850: Manual stage has no plan preview.""" + stages = [ + { + "stage": 1, + "name": "Manual", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + ds = session._deploy_state._state["deployment_stages"][0] + ds["deploy_mode"] = "manual" + + output = [] + session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "manual step" in joined.lower() + + def test_plan_missing_dir(self, tmp_project): + """Lines 1851-1852: Stage dir not found.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "nonexistent", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "not found" in joined.lower() + + @patch( + "azext_prototype.stages.deploy_session.plan_terraform", + return_value={"output": "Plan: 5 to add", "error": None}, + ) + def test_plan_terraform_infra_stage(self, mock_plan, tmp_project): + """Lines 1855-1861: Terraform plan for infra stage.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} + session._subscription = "sub-123" + + output = [] + session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "Plan: 5 to add" in joined + + @patch( + "azext_prototype.stages.deploy_session.whatif_bicep", + return_value={"output": "What-if: 2 to create", "error": None}, + ) + def test_plan_bicep_infra_stage(self, mock_whatif, tmp_project): + """Lines 1862-1868: Bicep what-if for infra stage.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, iac_tool="bicep", build_stages=stages) + session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} + session._subscription = "sub-123" + session._resource_group = "my-rg" + + output = [] + session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "What-if: 2 to create" in joined + + @patch( + "azext_prototype.stages.deploy_session.plan_terraform", + return_value={"output": None, "error": "Init failed"}, + ) + def test_plan_with_error(self, mock_plan, tmp_project): + """Lines 1871-1872: Plan error is displayed.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "layer": "infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} + session._subscription = "sub-123" + + output = [] + session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "Init failed" in joined + + def test_plan_app_stage_no_preview(self, tmp_project): + """Lines 1873-1874: App stages have no plan preview.""" + stages = [ + { + "stage": 1, + "name": "App", + "capability": "app", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + + output = [] + session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "app stage" in joined.lower() + +class TestSlashCommandSplit: + """Tests covering /split slash command (lines 1878-1903).""" + + def _make_session(self, project_dir, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + return session + + def test_split_no_arg(self, tmp_project): + """Lines 1879-1880: /split without arg shows usage.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_slash_command("/split", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "Usage" in joined + + def test_split_success(self, tmp_project): + """Lines 1887-1900: Split stage into substages.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + names = iter(["Networking", "Compute", ""]) # 2 substages + blank + + output = [] + session._handle_slash_command( + "/split 1", + False, + False, + lambda msg: output.append(msg), + lambda p: next(names), + ) + joined = "\n".join(output) + assert "Split into 2 substages" in joined + + def test_split_too_few_substages(self, tmp_project): + """Lines 1901-1902: Less than 2 substages cancels.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + names = iter(["OnlyOne", ""]) # 1 substage + blank + + output = [] + session._handle_slash_command( + "/split 1", + False, + False, + lambda msg: output.append(msg), + lambda p: next(names), + ) + joined = "\n".join(output) + assert "at least 2" in joined.lower() + + def test_split_eof_during_input(self, tmp_project): + """Lines 1893-1894: EOF during substage naming stops input.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + + output = [] + session._handle_slash_command( + "/split 1", + False, + False, + lambda msg: output.append(msg), + lambda p: (_ for _ in ()).throw(EOFError), + ) + # Should not crash, split cancelled + joined = "\n".join(output) + assert "at least 2" in joined.lower() or "Split" in joined + +class TestSlashCommandDestroy: + """Tests covering /destroy slash command (lines 1906-1927).""" + + def _make_session(self, project_dir, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + return session + + def test_destroy_no_arg(self, tmp_project): + """Lines 1907-1908: /destroy without arg shows usage.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_slash_command("/destroy", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "Usage" in joined + + @patch("azext_prototype.stages.deploy_session.rollback_terraform") + def test_destroy_confirmed(self, mock_rb, tmp_project): + """Lines 1918-1922: Destroy confirmed rolls back and destroys.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) + session = self._make_session(tmp_project, build_stages=stages) + session._deploy_state.mark_stage_deployed(1) + session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} + mock_rb.return_value = {"status": "rolled_back"} + + output = [] + session._handle_slash_command( + "/destroy 1", + False, + False, + lambda msg: output.append(msg), + lambda p: "y", + ) + joined = "\n".join(output) + assert "destroyed" in joined.lower() + + def test_destroy_cancelled(self, tmp_project): + """Lines 1925-1926: Destroy declined is cancelled.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + session._deploy_state.mark_stage_deployed(1) + + output = [] + session._handle_slash_command( + "/destroy 1", + False, + False, + lambda msg: output.append(msg), + lambda p: "n", + ) + joined = "\n".join(output) + assert "cancelled" in joined.lower() + + def test_destroy_eof_cancels(self, tmp_project): + """Lines 1915-1917: EOF during destroy confirmation cancels.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + session._deploy_state.mark_stage_deployed(1) + + output = [] + session._handle_slash_command( + "/destroy 1", + False, + False, + lambda msg: output.append(msg), + lambda p: (_ for _ in ()).throw(EOFError), + ) + joined = "\n".join(output) + assert "cancelled" in joined.lower() + +class TestSlashCommandManual: + """Tests covering /manual slash command (lines 1930-1952).""" + + def _make_session(self, project_dir, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + return session + + def test_manual_no_arg(self, tmp_project): + """Lines 1931-1932: /manual without arg shows usage.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_slash_command("/manual", False, False, lambda msg: output.append(msg), lambda p: "") + joined = "\n".join(output) + assert "Usage" in joined + + def test_manual_set_instructions(self, tmp_project): + """Lines 1940-1944: Setting manual instructions.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_slash_command( + '/manual 1 "Run az keyvault set-policy"', + False, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + joined = "\n".join(output) + assert "manual mode" in joined.lower() + # Verify it was saved + ds = session._deploy_state._state["deployment_stages"][0] + assert ds["deploy_mode"] == "manual" + + def test_manual_view_existing_instructions(self, tmp_project): + """Lines 1946-1948: Viewing existing manual instructions.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + ds = session._deploy_state._state["deployment_stages"][0] + ds["manual_instructions"] = "Do the thing." + + output = [] + session._handle_slash_command( + "/manual 1", + False, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + joined = "\n".join(output) + assert "Do the thing" in joined + + def test_manual_view_no_instructions(self, tmp_project): + """Lines 1949-1951: No instructions set shows hint.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_slash_command( + "/manual 1", + False, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + joined = "\n".join(output) + assert "No manual instructions" in joined + +class TestHandleDescribe: + """Tests for _handle_describe (lines 2020-2080).""" + + def _make_session(self, project_dir, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + return session + + def test_describe_no_arg(self, tmp_project): + """Lines 2024-2026: No arg shows usage.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_describe("", lambda msg: output.append(msg)) + joined = "\n".join(output) + assert "Usage" in joined + + def test_describe_no_numbers(self, tmp_project): + """Lines 2029-2031: No number in arg shows usage.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_describe("abc", lambda msg: output.append(msg)) + joined = "\n".join(output) + assert "Usage" in joined + + def test_describe_not_found(self, tmp_project): + """Lines 2035-2037: Stage not found.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_describe("99", lambda msg: output.append(msg)) + joined = "\n".join(output) + assert "not found" in joined.lower() + + def test_describe_full_details(self, tmp_project): + """Lines 2040-2080: Full description with services, files, output, error.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [ + { + "name": "kv", + "computed_name": "mykv", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + } + ], + "dir": "stage-1", + "status": "generated", + "files": ["stage-1/main.tf"], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + ds = session._deploy_state._state["deployment_stages"][0] + ds["deployed_at"] = "2026-01-01T12:00:00" + ds["deploy_output"] = "resource_id=abc123\nendpoint=https://foo.com" + ds["deploy_error"] = "some warning message" + + output = [] + session._handle_describe("1", lambda msg: output.append(msg)) + joined = "\n".join(output) + assert "Infra" in joined + assert "mykv" in joined + assert "Microsoft.KeyVault" in joined + assert "standard" in joined + assert "main.tf" in joined + assert "2026-01-01T12:00:00" in joined + assert "resource_id=abc123" in joined + assert "some warning message" in joined + + def test_describe_truncates_long_output(self, tmp_project): + """Lines 2074-2075: Long deploy output is truncated.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + ds = session._deploy_state._state["deployment_stages"][0] + ds["deploy_output"] = "\n".join(f"line {i}" for i in range(20)) + + output = [] + session._handle_describe("1", lambda msg: output.append(msg)) + joined = "\n".join(output) + assert "truncated" in joined.lower() + +class TestUnknownSlashCommand: + """Tests for unknown slash command (line 2020).""" + + def _make_session(self, project_dir, build_stages=None): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + session = DeploySession(context, registry) + build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + return session + + def test_unknown_command(self, tmp_project): + """Line 2020: Unknown slash command shows error.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "stage-1", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + output = [] + session._handle_slash_command( + "/foobar", + False, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + joined = "\n".join(output) + assert "Unknown command" in joined + +class TestMaybeSpinner: + """Tests for _maybe_spinner (lines 2099-2116).""" + + def _make_session(self, project_dir): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_spinner_with_status_fn(self, tmp_project): + """Lines 2106-2114: status_fn mode calls start/end/tokens.""" + session = self._make_session(tmp_project) + calls = [] + session._status_fn = lambda msg, kind: calls.append((msg, kind)) + + with session._maybe_spinner("Working...", use_styled=False): + pass + + # Should have called start and end + kinds = [k for _, k in calls] + assert "start" in kinds + assert "end" in kinds + + def test_spinner_plain_mode(self, tmp_project): + """Line 2116: Plain mode (no styled, no status_fn) just yields.""" + session = self._make_session(tmp_project) + session._status_fn = None + + with session._maybe_spinner("Working...", use_styled=False): + pass # Should not crash + +class TestCollectStageFileContent: + """Tests for _collect_stage_file_content (lines 1178-1225).""" + + def _make_session(self, project_dir): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_glob_fallback_when_no_files(self, tmp_project): + """Lines 1191-1200: Falls back to globbing when files list is empty.""" + session = self._make_session(tmp_project) + stage_dir = tmp_project / "stage-1" + stage_dir.mkdir() + (stage_dir / "main.tf").write_text("resource {} {}") + + stage = {"dir": "stage-1", "files": []} + content = session._collect_stage_file_content(stage) + assert "main.tf" in content + assert "resource {} {}" in content + + def test_empty_dir_returns_empty(self, tmp_project): + """Lines 1202-1203: No files found returns empty string.""" + session = self._make_session(tmp_project) + stage = {"dir": "nonexistent", "files": []} + content = session._collect_stage_file_content(stage) + assert content == "" + + def test_unreadable_file(self, tmp_project): + """Lines 1213-1215: Unreadable file shows 'could not read'.""" + session = self._make_session(tmp_project) + stage = {"dir": "stage-1", "files": ["stage-1/missing.tf"]} + content = session._collect_stage_file_content(stage) + assert "could not read" in content + + def test_max_bytes_cap(self, tmp_project): + """Lines 1206-1208: Size cap truncates remaining files.""" + session = self._make_session(tmp_project) + stage_dir = tmp_project / "stage-1" + stage_dir.mkdir() + # Create a file larger than 1000 bytes + (stage_dir / "big.tf").write_text("x" * 2000) + + stage = {"dir": "stage-1", "files": ["stage-1/big.tf", "stage-1/other.tf"]} + content = session._collect_stage_file_content(stage, max_bytes=100) + assert "omitted" in content.lower() or "big.tf" in content + + def test_truncates_large_individual_files(self, tmp_project): + """Lines 1218-1219: Individual files over 8000 chars are truncated.""" + session = self._make_session(tmp_project) + stage_dir = tmp_project / "stage-1" + stage_dir.mkdir() + (stage_dir / "huge.tf").write_text("x" * 10000) + + stage = {"dir": "stage-1", "files": ["stage-1/huge.tf"]} + content = session._collect_stage_file_content(stage) + assert "truncated" in content.lower() + +class TestParseStageNumbers: + """Tests for _parse_stage_numbers static method.""" + + def test_parses_json_array(self): + from azext_prototype.stages.deploy_session import DeploySession + + valid = [{"stage": 3}, {"stage": 4}, {"stage": 5}] + result = DeploySession._parse_stage_numbers("[3, 4]", valid) + assert result == [3, 4] + + def test_filters_invalid_numbers(self): + from azext_prototype.stages.deploy_session import DeploySession + + valid = [{"stage": 3}] + result = DeploySession._parse_stage_numbers("[3, 99]", valid) + assert result == [3] + + def test_fallback_to_regex(self): + from azext_prototype.stages.deploy_session import DeploySession + + valid = [{"stage": 5}, {"stage": 6}] + result = DeploySession._parse_stage_numbers("Stages 5 and 6 need updates", valid) + assert 5 in result + assert 6 in result + + def test_empty_array(self): + from azext_prototype.stages.deploy_session import DeploySession + + valid = [{"stage": 1}] + result = DeploySession._parse_stage_numbers("[]", valid) + assert result == [] + +class TestWriteStageFiles: + """Tests for _write_stage_files (lines 1289-1330).""" + + def _make_session(self, project_dir): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_empty_content(self, tmp_project): + """Line 1294-1295: Empty content returns empty list.""" + session = self._make_session(tmp_project) + result = session._write_stage_files({"dir": "stage-1"}, "") + assert result == [] + + def test_no_file_blocks(self, tmp_project): + """Lines 1298-1299: No parseable file blocks returns empty.""" + session = self._make_session(tmp_project) + result = session._write_stage_files({"dir": "stage-1"}, "No code blocks here.") + assert result == [] + + def test_writes_files_and_strips_prefix(self, tmp_project): + """Lines 1310-1314: Stage dir prefix is stripped from filenames.""" + session = self._make_session(tmp_project) + stage_dir = tmp_project / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + + content = "```stage-1/main.tf\nresource {} {}\n```" + with patch.object(session, "_sync_build_state"): + result = session._write_stage_files({"dir": "stage-1", "stage": 1}, content) + assert len(result) == 1 + assert (stage_dir / "main.tf").exists() + + def test_blocked_files_dropped(self, tmp_project): + """Lines 1316-1318: Blocked files (versions.tf for terraform) are dropped.""" + session = self._make_session(tmp_project) + stage_dir = tmp_project / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + + content = ( + "```stage-1/main.tf\nresource {} {}\n```\n\n" + '```stage-1/versions.tf\nterraform { required_version = ">= 1.0" }\n```' + ) + with patch.object(session, "_sync_build_state"): + result = session._write_stage_files({"dir": "stage-1", "stage": 1}, content) + # versions.tf should be dropped + written_names = [Path(f).name for f in result] + assert "versions.tf" not in written_names + assert "main.tf" in written_names + +class TestBuildFixTask: + """Tests for _build_fix_task (lines 1227-1287).""" + + def _make_session(self, project_dir): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + return DeploySession(context, registry) + + def test_infra_stage_selects_iac_agent(self, tmp_project): + """Lines 1242-1243: Infra capability selects IaC agent.""" + session = self._make_session(tmp_project) + stage = { + "stage": 1, + "name": "Infra", + "capability": "infra", + "dir": "stage-1", + "services": [], + } + agent, task = session._build_fix_task(stage, "error", "diag", "guide") + assert agent is not None # terraform agent from registry + assert "Fix deployment Stage 1" in task + + def test_app_stage_selects_dev_agent(self, tmp_project): + """Lines 1244-1245: App capability selects dev agent.""" + session = self._make_session(tmp_project) + stage = { + "stage": 1, + "name": "App", + "capability": "app", + "dir": "stage-1", + "services": [], + } + agent, task = session._build_fix_task(stage, "error", "diag", "guide") + assert agent is not None + assert "Fix deployment Stage 1" in task + + def test_no_agent_returns_none(self, tmp_project): + """Lines 1249-1250: No suitable agent returns (None, '').""" + session = self._make_session(tmp_project) + session._iac_agents = {} + session._dev_agent = None + stage = { + "stage": 1, + "name": "Infra", + "capability": "infra", + "dir": "stage-1", + "services": [], + } + agent, task = session._build_fix_task(stage, "error", "diag", "guide") + assert agent is None + assert task == "" + + def test_includes_services_in_task(self, tmp_project): + """Line 1277: Services included in fix task.""" + session = self._make_session(tmp_project) + stage = { + "stage": 1, + "name": "Infra", + "capability": "infra", + "dir": "stage-1", + "services": [ + { + "name": "kv", + "computed_name": "mykv", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + } + ], + } + agent, task = session._build_fix_task(stage, "err", "diag", "guide") + assert "mykv" in task + assert "Microsoft.KeyVault" in task + +# ====================================================================== + + +class TestNaturalLanguageIntentDeploy: + """Test that natural language triggers correct deploy commands.""" + + def _make_session(self, project_dir, build_stages=None): + """Create a DeploySession with dependencies mocked.""" + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages) + + context = AgentContext( + project_config={"project": {"iac_tool": "terraform"}}, + project_dir=str(project_dir), + ai_provider=MagicMock(), + ) + registry = AgentRegistry() + register_all_builtin(registry) + + return DeploySession(context, registry) + + @patch( + "azext_prototype.stages.deploy_session.subprocess.run", + return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), + ) + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) + def test_nl_deploy_stage_1(self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): + """'deploy stage 1' in natural language triggers deploy.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, build_stages=stages) + inputs = iter(["", "deploy stage 1", "done"]) + output = [] + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + # Should show deploy success or at least process the deploy command + assert "deployed" in joined.lower() or "Stage 1" in joined + + def test_nl_describe_stage(self, tmp_project): + """'describe stage 1' shows stage details.""" + session = self._make_session(tmp_project) + inputs = iter(["", "describe stage 1", "done"]) + output = [] + session.run( + subscription="sub-123", + input_fn=lambda p: next(inputs), + print_fn=lambda msg: output.append(msg), + ) + joined = "\n".join(output) + assert "Foundation" in joined or "Stage 1" in joined + +# ====================================================================== + + +class TestDeployStateRemediation: + """Tests for remediation state tracking in DeployState.""" + + def test_mark_stage_remediating(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_failed(1, "auth error") + ds.mark_stage_remediating(1) + + stage = ds.get_stage(1) + assert stage["deploy_status"] == "remediating" + assert stage["remediation_attempts"] == 1 + + def test_remediation_attempts_increment(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_remediating(1) + assert ds.get_stage(1)["remediation_attempts"] == 1 + + ds.mark_stage_remediating(1) + assert ds.get_stage(1)["remediation_attempts"] == 2 + + ds.mark_stage_remediating(1) + assert ds.get_stage(1)["remediation_attempts"] == 3 + + def test_reset_stage_to_pending(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_failed(1, "timeout") + assert ds.get_stage(1)["deploy_status"] == "failed" + assert ds.get_stage(1)["deploy_error"] == "timeout" + + ds.reset_stage_to_pending(1) + stage = ds.get_stage(1) + assert stage["deploy_status"] == "pending" + assert stage["deploy_error"] == "" + + def test_add_patch_stages(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + new_stages = [ + {"stage": 0, "name": "Patch Fix", "capability": "infra"}, + ] + ds.add_patch_stages(new_stages) + + stages = ds.state["deployment_stages"] + assert len(stages) == 4 + # Should have deploy-specific fields + patch_stage = [s for s in stages if s["name"] == "Patch Fix"][0] + assert patch_stage["deploy_status"] == "pending" + assert patch_stage["remediation_attempts"] == 0 + assert patch_stage["deploy_timestamp"] is None + + def test_add_patch_stages_before_docs(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + stages = [ + {"stage": 1, "name": "Infra", "capability": "infra", "services": [], "dir": "s1", "files": []}, + {"stage": 2, "name": "Docs", "capability": "docs", "services": [], "dir": "s2", "files": []}, + ] + build_path = _write_build_yaml(tmp_project, stages=stages) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.add_patch_stages([{"stage": 0, "name": "Patch", "capability": "infra"}]) + + stage_names = [s["name"] for s in ds.state["deployment_stages"]] + # Patch should be before Docs + assert stage_names.index("Patch") < stage_names.index("Docs") + + def test_renumber_stages(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + # Manually set non-sequential numbers + ds.state["deployment_stages"][0]["stage"] = 10 + ds.state["deployment_stages"][1]["stage"] = 20 + ds.state["deployment_stages"][2]["stage"] = 30 + + ds.renumber_stages() + + nums = [s["stage"] for s in ds.state["deployment_stages"]] + assert nums == [1, 2, 3] + + def test_remediation_attempts_in_load_from_build_state(self, tmp_project): + """Verify remediation_attempts field is added during build state import.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + for stage in ds.state["deployment_stages"]: + assert "remediation_attempts" in stage + assert stage["remediation_attempts"] == 0 + + def test_remediating_status_icon(self, tmp_project): + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_remediating(1) + status = ds.format_stage_status() + assert "<>" in status + +# ====================================================================== + + +class TestDeployRemediation: + """Tests for the deploy auto-remediation loop in DeploySession.""" + + _SENTINEL = object() + + def _make_session(self, project_dir, iac_tool="terraform", build_stages=None, ai_provider=_SENTINEL): + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.builtin import register_all_builtin + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.stages.deploy_session import DeploySession + + config_path = Path(project_dir) / "prototype.yaml" + if not config_path.exists(): + config_data = { + "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, + "ai": {"provider": "github-models"}, + } + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + _write_build_yaml(project_dir, stages=build_stages, iac_tool=iac_tool) + + provider = MagicMock() if ai_provider is self._SENTINEL else ai_provider + context = AgentContext( + project_config={"project": {"iac_tool": iac_tool}}, + project_dir=str(project_dir), + ai_provider=provider, + ) + registry = AgentRegistry() + register_all_builtin(registry) + + session = DeploySession(context, registry) + # Pre-load build state into deploy state + build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" + session._deploy_state.load_from_build_state(build_path) + return session + + def test_remediation_succeeds_first_attempt(self, tmp_project): + """Deploy fails -> QA diagnoses -> fix agent fixes -> redeploy succeeds.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + (tmp_project / "concept" / "infra" / "terraform" / "main.tf").write_text("# original") + + session = self._make_session(tmp_project, build_stages=stages) + + # Mock QA agent + session._qa_agent = MagicMock() + session._qa_agent.execute.return_value = _make_response( + "Missing provider configuration. Add required_providers block." + ) + + # Mock architect agent + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = _make_response( + "Root cause: missing provider. Add azurerm provider config.\nNo downstream impact." + ) + + # Mock IaC agent (terraform) + mock_iac = MagicMock() + mock_iac.execute.return_value = _make_response( + "```main.tf\n# fixed provider config\nterraform { required_providers " + '{ azurerm = { source = "hashicorp/azurerm" } } }\n```' + ) + session._iac_agents["terraform"] = mock_iac + + result = {"status": "failed", "error": "Error: No provider configured"} + stage = session._deploy_state.get_stage(1) + output = [] + + with patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}): + remediated = session._remediate_deploy_failure( + stage, + result, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + + assert remediated is not None + assert remediated["status"] == "deployed" + joined = "\n".join(output) + assert "Remediating" in joined + assert "deployed successfully after remediation" in joined + + def test_remediation_succeeds_second_attempt(self, tmp_project): + """First redeploy fails, second attempt succeeds.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + (tmp_project / "concept" / "infra" / "terraform" / "main.tf").write_text("# original") + + session = self._make_session(tmp_project, build_stages=stages) + + session._qa_agent = MagicMock() + session._qa_agent.execute.return_value = _make_response("Diagnosis: missing config") + + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = _make_response("Fix the provider.\n[]") + + mock_iac = MagicMock() + mock_iac.execute.return_value = _make_response("```main.tf\n# fixed\n```") + session._iac_agents["terraform"] = mock_iac + + result = {"status": "failed", "error": "Error: provider error"} + stage = session._deploy_state.get_stage(1) + output = [] + + deploy_call_count = [0] + + def mock_deploy(*args, **kwargs): + deploy_call_count[0] += 1 + if deploy_call_count[0] <= 1: + return {"status": "failed", "error": "still broken"} + return {"status": "deployed"} + + with patch.object(session, "_deploy_single_stage", side_effect=mock_deploy): + remediated = session._remediate_deploy_failure( + stage, + result, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + + assert remediated is not None + assert remediated["status"] == "deployed" + assert deploy_call_count[0] == 2 + + def test_remediation_exhausted(self, tmp_project): + """All remediation attempts fail — falls through.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + (tmp_project / "concept" / "infra" / "terraform" / "main.tf").write_text("# original") + + session = self._make_session(tmp_project, build_stages=stages) + + session._qa_agent = MagicMock() + session._qa_agent.execute.return_value = _make_response("Diagnosis: broken") + + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = _make_response("Fix it.\n[]") + + mock_iac = MagicMock() + mock_iac.execute.return_value = _make_response("```main.tf\n# attempt\n```") + session._iac_agents["terraform"] = mock_iac + + result = {"status": "failed", "error": "persistent error"} + stage = session._deploy_state.get_stage(1) + output = [] + + with patch.object(session, "_deploy_single_stage", return_value={"status": "failed", "error": "still broken"}): + remediated = session._remediate_deploy_failure( + stage, + result, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + + assert remediated is not None + assert remediated["status"] == "failed" + joined = "\n".join(output) + assert "Re-deploy failed" in joined + + def test_remediation_no_agents(self, tmp_project): + """Gracefully skipped when no fix agents are available.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + + # Clear all agents + session._qa_agent = None + session._iac_agents = {} + session._dev_agent = None + session._architect_agent = None + + result = {"status": "failed", "error": "auth error"} + stage = session._deploy_state.get_stage(1) + output = [] + + remediated = session._remediate_deploy_failure( + stage, + result, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + + assert remediated is None # No remediation attempted + + def test_remediation_qa_cannot_diagnose(self, tmp_project): + """Stops early when QA can't diagnose.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + + # QA returns no diagnosis + session._qa_agent = MagicMock() + session._qa_agent.execute.return_value = _make_response("") + + mock_iac = MagicMock() + session._iac_agents["terraform"] = mock_iac + + result = {"status": "failed", "error": "auth error"} + stage = session._deploy_state.get_stage(1) + output = [] + + session._remediate_deploy_failure( + stage, + result, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + + # Should not have called the IaC agent since QA couldn't diagnose + mock_iac.execute.assert_not_called() + + def test_remediation_updates_build_state(self, tmp_project): + """Build.yaml files list is updated after remediation writes.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": ["concept/infra/terraform/main.tf"], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + (tmp_project / "concept" / "infra" / "terraform" / "main.tf").write_text("# original") + + session = self._make_session(tmp_project, build_stages=stages) + + content = "```main.tf\n# fixed content\n```" + stage = session._deploy_state.get_stage(1) + written = session._write_stage_files(stage, content) + + assert len(written) == 1 + assert "main.tf" in written[0] + + # Verify build state was updated + from azext_prototype.stages.build_state import BuildState + + bs = BuildState(str(tmp_project)) + bs.load() + build_stage = bs.state["deployment_stages"][0] + assert build_stage["files"] == written + + @patch( + "azext_prototype.stages.deploy_session.subprocess.run", + return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), + ) + @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) + @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") + @patch("azext_prototype.stages.deploy_session.deploy_terraform") + def test_slash_deploy_routes_through_remediation(self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): + """/deploy N triggers remediation on failure.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, build_stages=stages) + + mock_tf.return_value = {"status": "failed", "error": "auth error"} + output = [] + + with patch.object( + session, "_handle_deploy_failure", return_value={"status": "failed", "error": "auth error"} + ) as mock_handle: + session._handle_slash_command( + "/deploy 1", + False, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + + # _handle_deploy_failure should have been called + mock_handle.assert_called_once() + + @patch("azext_prototype.stages.deploy_session.deploy_terraform") + def test_slash_redeploy_routes_through_remediation(self, mock_tf, tmp_project): + """/redeploy N triggers remediation on failure.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) + + session = self._make_session(tmp_project, build_stages=stages) + session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} + + mock_tf.return_value = {"status": "failed", "error": "deploy error"} + output = [] + + with patch.object( + session, "_handle_deploy_failure", return_value={"status": "failed", "error": "deploy error"} + ) as mock_handle: + session._handle_slash_command( + "/redeploy 1", + False, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + + mock_handle.assert_called_once() + + def test_downstream_impact_detected(self, tmp_project): + """Architect flags downstream stages for regeneration.""" + stages = [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform/stage-1", + "status": "generated", + "files": [], + }, + { + "stage": 2, + "name": "Data Layer", + "capability": "data", + "services": [], + "dir": "concept/infra/terraform/stage-2", + "status": "generated", + "files": [], + }, + { + "stage": 3, + "name": "App", + "capability": "app", + "services": [], + "dir": "concept/apps/stage-3", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + + # Mark stage 2 and 3 as pending (downstream) + session._deploy_state.get_stage(2)["deploy_status"] = "pending" + session._deploy_state.get_stage(3)["deploy_status"] = "pending" + + # Architect returns stage 2 as affected + session._architect_agent = MagicMock() + session._architect_agent.execute.return_value = _make_response("Affected stages: [2]") + + stage = session._deploy_state.get_stage(1) + result = session._check_downstream_impact(stage, "Changed outputs from foundation") + + assert 2 in result + assert 1 not in result # Not downstream of itself + + def test_downstream_regeneration(self, tmp_project): + """Flagged downstream stages get regenerated code.""" + stages = [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform/stage-1", + "status": "generated", + "files": [], + }, + { + "stage": 2, + "name": "Data Layer", + "capability": "data", + "services": [], + "dir": "concept/infra/terraform/stage-2", + "status": "generated", + "files": [], + }, + ] + for s in stages: + (tmp_project / s["dir"]).mkdir(parents=True, exist_ok=True) + (tmp_project / s["dir"] / "main.tf").write_text("# original") + + session = self._make_session(tmp_project, build_stages=stages) + + # Mock IaC agent to return regenerated content + mock_iac = MagicMock() + mock_iac.execute.return_value = _make_response("```main.tf\n# regenerated with fixed references\n```") + session._iac_agents["terraform"] = mock_iac + + output = [] + session._regenerate_downstream_stages( + [2], + False, + lambda msg: output.append(msg), + ) + + joined = "\n".join(output) + assert "regenerated" in joined.lower() + # Verify the file was actually written + content = (tmp_project / "concept" / "infra" / "terraform" / "stage-2" / "main.tf").read_text() + assert "regenerated" in content + + def test_handle_deploy_failure_returns_result(self, tmp_project): + """_handle_deploy_failure returns the remediation result.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages) + + # No agents available — remediation returns None + session._qa_agent = None + session._iac_agents = {} + session._dev_agent = None + + result = {"status": "failed", "error": "auth error"} + stage = session._deploy_state.get_stage(1) + output = [] + + returned = session._handle_deploy_failure( + stage, + result, + False, + lambda msg: output.append(msg), + lambda p: "", + ) + + # Should return original result when remediation not possible + assert returned["status"] == "failed" + # Should still show interactive options + joined = "\n".join(output) + assert "/deploy" in joined + + def test_no_ai_provider_skips_remediation(self, tmp_project): + """Remediation is skipped when ai_provider is None.""" + stages = [ + { + "stage": 1, + "name": "Infra", + "capability": "infra", + "services": [], + "dir": "concept/infra/terraform", + "status": "generated", + "files": [], + }, + ] + session = self._make_session(tmp_project, build_stages=stages, ai_provider=None) + + result = {"status": "failed", "error": "auth error"} + stage = session._deploy_state.get_stage(1) + + remediated = session._remediate_deploy_failure( + stage, + result, + False, + lambda msg: None, + lambda p: "", + ) + + assert remediated is None + +# ====================================================================== + + +def _build_yaml_with_ids(stages=None, iac_tool="terraform"): + """Build YAML with stable IDs.""" + if stages is None: + stages = [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "id": "foundation", + "deploy_mode": "auto", + "manual_instructions": None, + "services": [ + { + "name": "key-vault", + "computed_name": "kv-1", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + } + ], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-foundation", + "files": ["main.tf"], + }, + { + "stage": 2, + "name": "Data Layer", + "capability": "data", + "id": "data-layer", + "deploy_mode": "auto", + "manual_instructions": None, + "services": [ + {"name": "sql-db", "computed_name": "sql-1", "resource_type": "Microsoft.Sql/servers", "sku": "S0"} + ], + "status": "generated", + "dir": "concept/infra/terraform/stage-2-data", + "files": ["main.tf"], + }, + { + "stage": 3, + "name": "Application", + "capability": "app", + "id": "application", + "deploy_mode": "auto", + "manual_instructions": None, + "services": [ + {"name": "web-app", "computed_name": "app-1", "resource_type": "Microsoft.Web/sites", "sku": "B1"} + ], + "status": "generated", + "dir": "concept/apps/stage-3-application", + "files": ["app.py"], + }, + ] + return { + "iac_tool": iac_tool, + "deployment_stages": stages, + "_metadata": {"created": "2026-01-01T00:00:00", "last_updated": "2026-01-01T00:00:00", "iteration": 1}, + } + +def _write_build_yaml_with_ids(project_dir, stages=None, iac_tool="terraform"): + """Write build.yaml with stable IDs.""" + state_dir = Path(project_dir) / ".prototype" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + data = _build_yaml_with_ids(stages, iac_tool) + with open(state_dir / "build.yaml", "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False) + return state_dir / "build.yaml" + +class TestSyncFromBuildState: + + def test_sync_from_build_state_fresh(self, tmp_project): + """First sync creates deploy stages from build stages.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + result = ds.sync_from_build_state(build_path) + + assert result.created == 3 + assert result.matched == 0 + assert result.orphaned == 0 + assert len(ds.state["deployment_stages"]) == 3 + assert ds.state["deployment_stages"][0]["build_stage_id"] == "foundation" + + def test_sync_from_build_state_preserves_deploy_status(self, tmp_project): + """Matched stages keep their deploy state.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + # Deploy stage 1 + ds.mark_stage_deployed(1, output="done") + + # Re-sync + result = ds.sync_from_build_state(build_path) + assert result.matched == 3 + assert result.created == 0 + + stage1 = ds.state["deployment_stages"][0] + assert stage1["deploy_status"] == "deployed" + assert stage1["deploy_output"] == "done" + + def test_sync_from_build_state_detects_code_change(self, tmp_project): + """Changed files trigger _code_updated marking.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + ds.mark_stage_deployed(1) + + # Update build state with new files + updated_stages = _build_yaml_with_ids()["deployment_stages"] + updated_stages[0]["files"] = ["main.tf", "variables.tf"] # changed + _write_build_yaml_with_ids(tmp_project, stages=updated_stages) + + result = ds.sync_from_build_state(build_path) + assert result.updated_code == 1 + assert ds.state["deployment_stages"][0].get("_code_updated") is True + + def test_sync_from_build_state_creates_new(self, tmp_project): + """New build stage creates new deploy stage.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + # Add new stage to build + stages = _build_yaml_with_ids()["deployment_stages"] + stages.append( + { + "stage": 4, + "name": "Monitoring", + "capability": "infra", + "id": "monitoring", + "deploy_mode": "auto", + "manual_instructions": None, + "services": [], + "status": "generated", + "dir": "concept/infra/terraform/stage-4-monitoring", + "files": [], + } + ) + _write_build_yaml_with_ids(tmp_project, stages=stages) + + result = ds.sync_from_build_state(build_path) + assert result.created == 1 + assert len(ds.state["deployment_stages"]) == 4 + assert ds.state["deployment_stages"][3]["build_stage_id"] == "monitoring" + + def test_sync_from_build_state_with_substages(self, tmp_project): + """Split stages preserved across sync.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + # Split stage 2 into substages + ds.split_stage( + 2, + [ + {"name": "Data Layer - Base", "dir": "concept/infra/terraform/stage-2-data"}, + {"name": "Data Layer - Schema", "dir": "concept/db/schema"}, + ], + ) + + # Re-sync — substages should be preserved + ds.sync_from_build_state(build_path) + data_stages = ds.get_stages_for_build_stage("data-layer") + assert len(data_stages) == 2 + assert data_stages[0]["substage_label"] == "a" + assert data_stages[1]["substage_label"] == "b" + + def test_sync_orphan_sets_removed_status(self, tmp_project): + """Removed build stage → deploy stage gets 'removed' status.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + # Remove a stage from build + stages = _build_yaml_with_ids()["deployment_stages"] + stages = [s for s in stages if s["id"] != "data-layer"] + _write_build_yaml_with_ids(tmp_project, stages=stages) + + result = ds.sync_from_build_state(build_path) + assert result.orphaned == 1 + + removed = [s for s in ds.state["deployment_stages"] if s.get("deploy_status") == "removed"] + assert len(removed) == 1 + assert removed[0]["build_stage_id"] == "data-layer" + +class TestStageSpitting: + + def test_split_stage(self, tmp_project): + """Split creates substages with shared build_stage_id.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.split_stage( + 2, + [ + {"name": "Data - Base", "dir": "concept/infra/terraform/stage-2-data"}, + {"name": "Data - Schema", "dir": "concept/db/schema"}, + ], + ) + + # All substages share the same build_stage_id + data_stages = ds.get_stages_for_build_stage("data-layer") + assert len(data_stages) == 2 + assert data_stages[0]["substage_label"] == "a" + assert data_stages[1]["substage_label"] == "b" + assert data_stages[0]["_is_substage"] is True + assert data_stages[1]["_is_substage"] is True + + def test_split_stage_renumbering(self, tmp_project): + """After split, stage numbers are correct.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.split_stage( + 2, + [ + {"name": "Data - Base", "dir": "dir1"}, + {"name": "Data - Schema", "dir": "dir2"}, + ], + ) + + stages = ds.state["deployment_stages"] + # Stage 1 stays as 1, substages get stage 2 with labels, stage 3 stays + assert stages[0]["stage"] == 1 # Foundation + assert stages[1]["stage"] == 2 # Data - Base (2a) + assert stages[1]["substage_label"] == "a" + assert stages[2]["stage"] == 2 # Data - Schema (2b) + assert stages[2]["substage_label"] == "b" + assert stages[3]["stage"] == 3 # Application + + def test_get_stage_groups(self, tmp_project): + """Verify grouping by build_stage_id.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.split_stage( + 2, + [ + {"name": "Data - Base", "dir": "dir1"}, + {"name": "Data - Schema", "dir": "dir2"}, + ], + ) + + groups = ds.get_stage_groups() + assert "foundation" in groups + assert "data-layer" in groups + assert "application" in groups + assert len(groups["data-layer"]) == 2 + assert len(groups["foundation"]) == 1 + + def test_can_rollback_with_substages(self, tmp_project): + """Rollback checks work with substages.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.split_stage( + 2, + [ + {"name": "Data - Base", "dir": "dir1"}, + {"name": "Data - Schema", "dir": "dir2"}, + ], + ) + + # Deploy both substages + substages = ds.get_stages_for_build_stage("data-layer") + substages[0]["deploy_status"] = "deployed" + substages[1]["deploy_status"] = "deployed" + ds.save() + + # Can't rollback "a" while "b" is deployed + assert ds.can_rollback(2, "a") is False + # Can rollback "b" + assert ds.can_rollback(2, "b") is True + + def test_get_stage_by_display_id(self, tmp_project): + """Parse and lookup by compound display ID.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.split_stage( + 2, + [ + {"name": "Data - Base", "dir": "dir1"}, + {"name": "Data - Schema", "dir": "dir2"}, + ], + ) + + found = ds.get_stage_by_display_id("2a") + assert found is not None + assert found["name"] == "Data - Base" + + found_b = ds.get_stage_by_display_id("2b") + assert found_b is not None + assert found_b["name"] == "Data - Schema" + +class TestDeployStateNewStatuses: + + def test_load_from_build_state_backward_compat(self, tmp_project): + """Legacy build state without IDs still imports correctly.""" + from azext_prototype.stages.deploy_state import DeployState + + # Write legacy build yaml (no id field) + build_path = _write_build_yaml(tmp_project) + ds = DeployState(str(tmp_project)) + result = ds.load_from_build_state(build_path) + + assert result is True + # build_stage_id should be auto-generated from name + for stage in ds.state["deployment_stages"]: + assert stage.get("build_stage_id") + + def test_destroy_stage(self, tmp_project): + """Destroyed status after rollback.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_deployed(1) + ds.mark_stage_rolled_back(1) + ds.mark_stage_destroyed(1) + + assert ds.get_stage(1)["deploy_status"] == "destroyed" + + def test_destruction_declined_not_reprompted(self, tmp_project): + """_destruction_declined flag persists across save/load.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + stage = ds.get_stage(1) + stage["_destruction_declined"] = True + ds.save() + + ds2 = DeployState(str(tmp_project)) + ds2.load() + assert ds2.get_stage(1)["_destruction_declined"] is True + + def test_awaiting_manual_status(self, tmp_project): + """Manual step sets awaiting_manual status.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.mark_stage_awaiting_manual(1) + assert ds.get_stage(1)["deploy_status"] == "awaiting_manual" + +class TestManualStepDeploy: + + def test_manual_step_deploy(self, tmp_project): + """Manual stage shows instructions, waits for confirmation.""" + from azext_prototype.stages.deploy_state import DeployState + + stages = [ + { + "stage": 1, + "name": "Upload Notebook", + "capability": "external", + "id": "upload-notebook", + "deploy_mode": "manual", + "manual_instructions": "Upload the notebook to Fabric workspace.", + "services": [], + "status": "generated", + "dir": "concept/docs", + "files": [], + }, + ] + build_path = _write_build_yaml_with_ids(tmp_project, stages=stages) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + # Verify the manual stage imported correctly + stage = ds.get_stage(1) + assert stage["deploy_mode"] == "manual" + assert "Upload" in stage["manual_instructions"] + + def test_manual_step_from_build(self, tmp_project): + """deploy_mode: 'manual' inherited from build stage via sync.""" + from azext_prototype.stages.deploy_state import DeployState + + stages = [ + { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "id": "foundation", + "deploy_mode": "auto", + "manual_instructions": None, + "services": [], + "status": "generated", + "dir": "concept/infra/terraform/stage-1-foundation", + "files": [], + }, + { + "stage": 2, + "name": "Manual Config", + "capability": "external", + "id": "manual-config", + "deploy_mode": "manual", + "manual_instructions": "Configure the firewall rules manually.", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + ] + build_path = _write_build_yaml_with_ids(tmp_project, stages=stages) + + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + manual_stage = ds.state["deployment_stages"][1] + assert manual_stage["deploy_mode"] == "manual" + assert "firewall" in manual_stage["manual_instructions"] + + def test_code_split_syncs_back_to_build(self, tmp_project): + """Type A split: _sync_build_state uses build_stage_id for matching.""" + from azext_prototype.stages.build_state import BuildState + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + + # Load into deploy state + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + # Load build state and verify get_stage_by_id works + bs = BuildState(str(tmp_project)) + bs.load() + + # Verify the build stage has the right id + build_stage = bs.get_stage_by_id("data-layer") + assert build_stage is not None + assert build_stage["name"] == "Data Layer" + + # Deploy stage links back correctly + deploy_stage = ds.state["deployment_stages"][1] + assert deploy_stage["build_stage_id"] == "data-layer" + +class TestParseStageRef: + + def test_parse_simple_number(self): + from azext_prototype.stages.deploy_state import parse_stage_ref + + num, label = parse_stage_ref("5") + assert num == 5 + assert label is None + + def test_parse_substage(self): + from azext_prototype.stages.deploy_state import parse_stage_ref + + num, label = parse_stage_ref("5a") + assert num == 5 + assert label == "a" + + def test_parse_invalid(self): + from azext_prototype.stages.deploy_state import parse_stage_ref + + num, label = parse_stage_ref("abc") + assert num is None + assert label is None + + def test_parse_empty(self): + from azext_prototype.stages.deploy_state import parse_stage_ref + + num, label = parse_stage_ref("") + assert num is None + + def test_parse_with_whitespace(self): + from azext_prototype.stages.deploy_state import parse_stage_ref + + num, label = parse_stage_ref(" 3b ") + assert num == 3 + assert label == "b" + +class TestRenumberWithSubstages: + + def test_renumber_preserves_substage_labels(self, tmp_project): + """Substages keep their labels and inherit parent number.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + # Split stage 2 + ds.split_stage( + 2, + [ + {"name": "Data - Base", "dir": "dir1"}, + {"name": "Data - Schema", "dir": "dir2"}, + ], + ) + + # Remove stage 1 — renumber should shift substages + stages = ds.state["deployment_stages"] + ds._state["deployment_stages"] = [s for s in stages if s.get("build_stage_id") != "foundation"] + ds.renumber_stages() + + stages = ds.state["deployment_stages"] + # Now data substages should be stage 1 + assert stages[0]["stage"] == 1 + assert stages[0]["substage_label"] == "a" + assert stages[1]["stage"] == 1 + assert stages[1]["substage_label"] == "b" + # Application should be stage 2 + assert stages[2]["stage"] == 2 + assert stages[2]["substage_label"] is None + +class TestFormatDisplayId: + + def test_format_top_level(self): + from azext_prototype.stages.deploy_state import _format_display_id + + assert _format_display_id({"stage": 3}) == "3" + + def test_format_substage(self): + from azext_prototype.stages.deploy_state import _format_display_id + + assert _format_display_id({"stage": 3, "substage_label": "b"}) == "3b" + + def test_format_no_label(self): + from azext_prototype.stages.deploy_state import _format_display_id + + assert _format_display_id({"stage": 1, "substage_label": None}) == "1" + +class TestNewStatusIcons: + + def test_removed_icon(self): + from azext_prototype.stages.deploy_state import _status_icon + + assert _status_icon("removed") == "~~" + + def test_destroyed_icon(self): + from azext_prototype.stages.deploy_state import _status_icon + + assert _status_icon("destroyed") == "xx" + + def test_awaiting_manual_icon(self): + from azext_prototype.stages.deploy_state import _status_icon + + assert _status_icon("awaiting_manual") == "!!" + + def test_existing_icons_unchanged(self): + from azext_prototype.stages.deploy_state import _status_icon + + assert _status_icon("pending") == " " + assert _status_icon("deployed") == " v" + assert _status_icon("failed") == " x" + assert _status_icon("remediating") == "<>" + +class TestDeployReportFormatting: + + def test_format_shows_removed_stages(self, tmp_project): + """Removed stages show with strikethrough in report.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + ds.mark_stage_removed(2) + + report = ds.format_deploy_report() + assert "(Removed)" in report + assert "~~Data Layer~~" in report + + def test_format_shows_manual_badge(self, tmp_project): + """Manual stages show [Manual] badge.""" + from azext_prototype.stages.deploy_state import DeployState + + stages = [ + { + "stage": 1, + "name": "Manual Step", + "capability": "external", + "id": "manual", + "deploy_mode": "manual", + "manual_instructions": "Do the thing.", + "services": [], + "status": "generated", + "dir": "", + "files": [], + }, + ] + build_path = _write_build_yaml_with_ids(tmp_project, stages=stages) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + report = ds.format_deploy_report() + assert "[Manual]" in report + + status = ds.format_stage_status() + assert "[Manual]" in status + + def test_format_shows_substage_ids(self, tmp_project): + """Substages show compound display IDs like 2a, 2b.""" + from azext_prototype.stages.deploy_state import DeployState + + build_path = _write_build_yaml_with_ids(tmp_project) + ds = DeployState(str(tmp_project)) + ds.load_from_build_state(build_path) + + ds.split_stage( + 2, + [ + {"name": "Data - Base", "dir": "dir1"}, + {"name": "Data - Schema", "dir": "dir2"}, + ], + ) + + status = ds.format_stage_status() + assert "2a" in status + assert "2b" in status diff --git a/tests/stages/test_discovery.py b/tests/stages/test_discovery.py index 0b9570e..43578ef 100644 --- a/tests/stages/test_discovery.py +++ b/tests/stages/test_discovery.py @@ -532,3 +532,2931 @@ def test_new_topics_added(self, discovery_context, discovery_registry): result = session._handle_incremental_context("Add Redis caching", "", None, lambda m: None, False, None) assert result is True + +# --- Additional imports from merged flat test --- +from azext_prototype.ai.provider import AIMessage, AIResponse +from azext_prototype.stages.discovery import _DONE_WORDS, _QUIT_WORDS, _READY_MARKER, DiscoveryResult, DiscoverySession, extract_section_headers, parse_sections +from unittest.mock import patch + + +# ====================================================================== +# Fixtures +# ====================================================================== + + +@pytest.fixture +def mock_biz_agent(): + agent = MagicMock() + agent.name = "biz-analyst" + agent.capabilities = [AgentCapability.BIZ_ANALYSIS, AgentCapability.ANALYZE] + agent._temperature = 0.5 + agent._max_tokens = 8192 + agent.get_system_messages.side_effect = lambda: [ + AIMessage(role="system", content="You are a biz-analyst."), + ] + return agent + + +@pytest.fixture +def mock_architect_agent(): + agent = MagicMock() + agent.name = "cloud-architect" + agent.capabilities = [AgentCapability.ARCHITECT, AgentCapability.COORDINATE] + agent.constraints = [ + "All Azure services MUST use Managed Identity", + "Follow Microsoft Well-Architected Framework principles", + "This is a PROTOTYPE — optimize for speed and demonstration", + "Prefer PaaS over IaaS for simplicity", + ] + return agent + + +@pytest.fixture +def mock_registry(mock_biz_agent, mock_architect_agent): + registry = MagicMock() + + def find_by_cap(cap): + if cap == AgentCapability.BIZ_ANALYSIS: + return [mock_biz_agent] + if cap == AgentCapability.ARCHITECT: + return [mock_architect_agent] + return [] + + registry.find_by_capability.side_effect = find_by_cap + return registry + + +@pytest.fixture +def mock_agent_context(tmp_path): + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_path), + ai_provider=MagicMock(), + ) + return ctx + + +def _make_response(content: str) -> AIResponse: + """Shorthand for creating an AIResponse.""" + return AIResponse(content=content, model="gpt-4o", usage={}) + + +# ====================================================================== +# DiscoverySession — basic conversation flow +# ====================================================================== + + +class TestBasicConversationFlow: + """The core contract: user and agent exchange messages naturally.""" + + def test_bare_invocation_agent_speaks_first( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """With no context, the agent gets a generic opening and starts talking.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Tell me about what you'd like to build."), + _make_response("Interesting — a REST API for orders. What database?"), + _make_response("## Summary\nOrders API, PostgreSQL."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["A REST API for order management", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # exchange_count includes the opening exchange (1) + user reply (2) + assert result.exchange_count == 2 + assert not result.cancelled + # The AI was called: opening + user reply + summary + assert mock_agent_context.ai_provider.chat.call_count == 3 + + def test_with_context_agent_analyzes_and_follows_up( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """When --context is provided, it becomes the opening message.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("I see an inventory system. What about auth?"), + _make_response("Entra ID, got it. What about scale?"), + _make_response("50 users, read-heavy. Makes sense."), + _make_response("## Summary\nInventory system confirmed."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["Entra ID for auth", "About 50 users", "done"]) + + result = session.run( + seed_context="Build an inventory management system", + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # exchange_count: opening (1) + 2 user replies (2, 3) + assert result.exchange_count == 3 + assert not result.cancelled + # Check that the opening message was the seed context + first_call_messages = mock_agent_context.ai_provider.chat.call_args_list[0][0][0] + user_msgs = [m for m in first_call_messages if m.role == "user"] + assert "inventory management" in user_msgs[0].content.lower() + + def test_with_artifacts_and_context( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """Both artifacts AND context form a combined opening message.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("I see both context and specs. Scale?"), + _make_response("50 users, noted. Anything else?"), + _make_response("## Summary\nAll confirmed."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["50 concurrent users", "done"]) + + result = session.run( + seed_context="Inventory system", + artifacts="## Spec\nCRUD for products", + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # exchange_count: opening (1) + user reply (2) + assert result.exchange_count == 2 + first_call_messages = mock_agent_context.ai_provider.chat.call_args_list[0][0][0] + user_msgs = [m for m in first_call_messages if m.role == "user"] + assert "inventory" in user_msgs[0].content.lower() + assert "CRUD" in user_msgs[0].content or "requirement documents" in user_msgs[0].content.lower() + + def test_with_only_artifacts( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """Artifacts alone — opening says 'I have documents for you'.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Let me review... looks like a product catalog."), + _make_response("## Summary\nProduct catalog."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + session.run( + artifacts="## Product Catalog Spec\nCRUD endpoints", + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + + first_user_msg = [m for m in mock_agent_context.ai_provider.chat.call_args_list[0][0][0] if m.role == "user"][0] + assert "requirement documents" in first_user_msg.content.lower() + + +# ====================================================================== +# Multi-turn message history +# ====================================================================== + + +class TestMultiTurnHistory: + """The key architectural requirement: full conversation history on every call.""" + + def test_history_grows_with_each_exchange( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """Each AI call includes the full conversation history.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("A REST API. What database?"), + _make_response("PostgreSQL. Auth?"), + _make_response("## Summary"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["A REST API", "PostgreSQL", "done"]) + + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + calls = mock_agent_context.ai_provider.chat.call_args_list + + # Call 0 (opening): system + 1 user message + # Call 1 (exchange 1): system + 2 user + 1 assistant + # Call 2 (exchange 2): system + 3 user + 2 assistant + # Call 3 (summary): system + 4 user + 3 assistant + + user_count_per_call = [] + for c in calls: + messages = c[0][0] + user_count_per_call.append(sum(1 for m in messages if m.role == "user")) + + # History should grow monotonically + assert user_count_per_call == sorted(user_count_per_call) + assert user_count_per_call[-1] > user_count_per_call[0] + + def test_no_meta_prompt_injection( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """User text goes to the AI unmodified — no wrapping or injection.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("Got it."), + _make_response("## Summary"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["Build me a web app with React and Node.js", "done"]) + + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # The second call should contain the user's exact text + second_call_messages = mock_agent_context.ai_provider.chat.call_args_list[1][0][0] + user_msgs = [m.content for m in second_call_messages if m.role == "user"] + # The user's message should appear verbatim + assert "Build me a web app with React and Node.js" in user_msgs + + +# ====================================================================== +# Session ending +# ====================================================================== + + +class TestSessionEnding: + def test_quit_cancels(self, mock_agent_context, mock_registry, mock_biz_agent): + mock_agent_context.ai_provider.chat.return_value = _make_response("Hi!") + session = DiscoverySession(mock_agent_context, mock_registry) + + result = session.run( + input_fn=lambda _: "q", + print_fn=lambda x: None, + ) + assert result.cancelled is True + assert result.requirements == "" + + def test_all_quit_words(self, mock_agent_context, mock_registry, mock_biz_agent): + for word in _QUIT_WORDS: + mock_agent_context.ai_provider.chat.return_value = _make_response("Hi!") + session = DiscoverySession(mock_agent_context, mock_registry) + result = session.run( + input_fn=lambda _: word, + print_fn=lambda x: None, + ) + assert result.cancelled, f"'{word}' should cancel" + + def test_all_done_words(self, mock_agent_context, mock_registry, mock_biz_agent): + for word in _DONE_WORDS: + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Hi!"), + _make_response("## Summary"), + ] + session = DiscoverySession(mock_agent_context, mock_registry) + result = session.run( + input_fn=lambda _: word, + print_fn=lambda x: None, + ) + assert not result.cancelled, f"'{word}' should end gracefully, not cancel" + + def test_end_in_done_words(self): + """'end' should be recognized as a done word.""" + assert "end" in _DONE_WORDS + + def test_end_word_finishes_session(self, mock_agent_context, mock_registry, mock_biz_agent): + """Typing 'end' should complete the session (not cancel).""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Hi! Tell me about your project."), + _make_response("## Summary\nHere's what we discussed."), + ] + session = DiscoverySession(mock_agent_context, mock_registry) + result = session.run( + input_fn=lambda _: "end", + print_fn=lambda x: None, + ) + assert not result.cancelled + assert result.exchange_count >= 1 + + def test_eof_exits_gracefully(self, mock_agent_context, mock_registry, mock_biz_agent): + mock_agent_context.ai_provider.chat.return_value = _make_response("Hi!") + session = DiscoverySession(mock_agent_context, mock_registry) + + result = session.run( + input_fn=lambda _: (_ for _ in ()).throw(EOFError), + print_fn=lambda x: None, + ) + assert result is not None + + def test_keyboard_interrupt_exits(self, mock_agent_context, mock_registry, mock_biz_agent): + mock_agent_context.ai_provider.chat.return_value = _make_response("Hi!") + session = DiscoverySession(mock_agent_context, mock_registry) + + result = session.run( + input_fn=lambda _: (_ for _ in ()).throw(KeyboardInterrupt), + print_fn=lambda x: None, + ) + assert result is not None + + def test_empty_input_ignored(self, mock_agent_context, mock_registry, mock_biz_agent): + """Blank lines don't count as exchanges.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What do you want to build?"), + _make_response("A web app. Got it."), + _make_response("## Summary"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["", "", "Build a web app", "", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + # exchange_count: opening (1) + one real user reply (2) + assert result.exchange_count == 2 + + +# ====================================================================== +# Agent-driven convergence via [READY] marker +# ====================================================================== + + +class TestConvergence: + def test_ready_marker_triggers_confirmation( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """When agent includes [READY], user is prompted to confirm.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response(f"I have a good picture now. Here's what I've got. {_READY_MARKER}"), + _make_response("## Summary\nAll confirmed."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter( + [ + "A simple REST API for orders", + "", # Enter to accept after [READY] + ] + ) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # exchange_count: opening (1) + user reply (2) + assert result.exchange_count == 2 + assert not result.cancelled + + def test_ready_marker_stripped_from_display( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """The [READY] marker is never shown to the user.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response(f"I think we're done. {_READY_MARKER}"), + _make_response("## Summary"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + printed = [] + inputs = iter(["A web app", ""]) # exchange, then Enter to accept + + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: printed.append(x), + ) + + all_output = "\n".join(printed) + assert _READY_MARKER not in all_output + + def test_user_can_continue_after_ready( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """User can keep typing after agent signals [READY].""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response(f"Looks complete. {_READY_MARKER}"), + _make_response("Redis added. Anything else?"), + _make_response("## Summary"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter( + [ + "A web app", + "Actually, also add Redis caching", # continues after READY + "done", + ] + ) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # exchange_count: opening (1) + user reply (2) + continue after READY (3) + assert result.exchange_count == 3 + + +# ====================================================================== +# No biz-analyst fallback +# ====================================================================== + + +class TestNoBizAnalystFallback: + def test_falls_back_to_input(self, mock_agent_context): + registry = MagicMock() + registry.find_by_capability.return_value = [] + + session = DiscoverySession(mock_agent_context, registry) + result = session.run( + input_fn=lambda _: "Build a web API", + print_fn=lambda x: None, + ) + + assert result.requirements == "Build a web API" + assert result.exchange_count == 0 + + +# ====================================================================== +# Summary production +# ====================================================================== + + +class TestSummaryProduction: + def test_summary_requested_at_end( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """After conversation, a summary call is made.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("An orders API. Makes sense."), + _make_response("## Confirmed Requirements\n- Orders REST API\n- PostgreSQL"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["An orders REST API with PostgreSQL", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + assert "orders" in result.requirements.lower() or "Orders" in result.requirements + + def test_no_summary_when_zero_exchanges( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """If user immediately types 'done', a summary is still produced + because the opening exchange counts.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What would you like to build?"), + _make_response("## Summary\nA web app"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + result = session.run( + seed_context="A web app", + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + + assert "web app" in result.requirements.lower() + # 2 chat calls: opening + summary + assert mock_agent_context.ai_provider.chat.call_count == 2 + + +# ====================================================================== +# Policy override extraction from summary +# ====================================================================== + + +class TestPolicyOverrideExtraction: + def test_extracts_overrides_from_summary( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """If the summary contains a 'Policy Overrides' section, parse it.""" + summary_text = ( + "## Confirmed Requirements\n" + "- Orders API\n\n" + "## Policy Overrides\n" + "- managed-identity: User requires connection strings for legacy compat\n" + "- network-isolation: Public endpoint needed for demo\n\n" + "## Open Items\n" + "- Timeline TBD" + ) + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("Got it."), + _make_response(summary_text), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["An orders API with connection strings", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + assert len(result.policy_overrides) == 2 + names = [o["policy_name"] for o in result.policy_overrides] + assert "managed-identity" in names + assert "network-isolation" in names + + def test_no_overrides_when_section_absent( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """No Policy Overrides heading → empty list.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("Got it."), + _make_response("## Summary\n- Just an API\n## Open Items\n- None"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["A web API", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + assert result.policy_overrides == [] + + +# ====================================================================== +# Integration with DesignStage +# ====================================================================== + + +class TestDesignStageDiscoveryIntegration: + """Test that DesignStage.execute() uses the DiscoverySession.""" + + def test_design_stage_uses_discovery( + self, + project_with_config, + mock_agent_context, + populated_registry, + ): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + stage.get_guards = lambda: [] + + mock_agent_context.project_dir = str(project_with_config) + mock_agent_context.ai_provider.chat.return_value = _make_response("Tell me more about your project.") + + inputs = iter(["Build a REST API", "PostgreSQL, 50 users", "done"]) + result = stage.execute( + mock_agent_context, + populated_registry, + context="Build a simple web app", + interactive=False, + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + assert result["status"] == "success" + + def test_cancelled_discovery_cancels_design( + self, + project_with_config, + mock_agent_context, + populated_registry, + ): + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + stage.get_guards = lambda: [] + + mock_agent_context.project_dir = str(project_with_config) + mock_agent_context.ai_provider.chat.return_value = _make_response("Tell me about your project.") + + result = stage.execute( + mock_agent_context, + populated_registry, + interactive=False, + input_fn=lambda _: "quit", + print_fn=lambda x: None, + ) + assert result["status"] == "cancelled" + + def test_design_stage_persists_policy_overrides( + self, + project_with_config, + mock_agent_context, + populated_registry, + ): + """Policy overrides from discovery are persisted in design state.""" + import json as _json + + from azext_prototype.stages.design_stage import DesignStage + + stage = DesignStage() + stage.get_guards = lambda: [] + + mock_agent_context.project_dir = str(project_with_config) + mock_agent_context.ai_provider.chat.return_value = _make_response("Architecture design with overrides.") + + mock_result = DiscoveryResult( + requirements="Build an API with connection strings (overridden)", + conversation=[], + policy_overrides=[ + { + "rule_id": "managed-identity", + "policy_name": "managed-identity", + "description": "Legacy compat", + "recommendation": "", + "user_text": "Legacy compat", + } + ], + exchange_count=3, + ) + + with patch("azext_prototype.stages.design_stage.DiscoverySession") as MockDS: + MockDS.return_value.run.return_value = mock_result + + result = stage.execute( + mock_agent_context, + populated_registry, + context="Build a web app", + interactive=False, + ) + + assert result["status"] == "success" + state_path = project_with_config / ".prototype" / "state" / "design.json" + state = _json.loads(state_path.read_text(encoding="utf-8")) + assert len(state.get("policy_overrides", [])) == 1 + assert state["policy_overrides"][0]["rule_id"] == "managed-identity" + + +# ====================================================================== +# _clean helper +# ====================================================================== + + +class TestCleanHelper: + def test_strips_ready_marker(self): + assert DiscoverySession._clean(f"Hello {_READY_MARKER}") == "Hello" + + def test_no_marker_passthrough(self): + assert DiscoverySession._clean("Hello world") == "Hello world" + + +# ====================================================================== +# _extract_overrides helper +# ====================================================================== + + +class TestExtractOverrides: + def test_parses_bullet_list(self): + text = ( + "## Policy Overrides\n" + "- managed-identity: Legacy system needs connection strings\n" + "- network-isolation: Demo requires public access\n" + "\n## Next Steps\n" + ) + overrides = DiscoverySession._extract_overrides(text) + assert len(overrides) == 2 + assert overrides[0]["policy_name"] == "managed-identity" + assert "Legacy" in overrides[0]["description"] + + def test_empty_when_no_section(self): + assert DiscoverySession._extract_overrides("## Summary\nJust a summary.") == [] + + def test_handles_bold_names(self): + text = "## Policy Overrides\n" "- **MI-001**: User needs connection strings\n" + overrides = DiscoverySession._extract_overrides(text) + assert len(overrides) == 1 + assert overrides[0]["policy_name"] == "MI-001" + + +# ====================================================================== +# /summary slash command +# ====================================================================== + + +class TestSummaryCommand: + def test_summary_triggers_ai_call( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """/summary should call the AI for a mid-session summary.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What would you like to build?"), + _make_response("Here's a summary of what we have so far."), + _make_response("## Summary\nFinal summary."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["/summary", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # 3 AI calls: opening, /summary, final summary + assert mock_agent_context.ai_provider.chat.call_count == 3 + # /summary doesn't count as a user exchange — only the opening does + assert result.exchange_count == 1 + + def test_summary_does_not_increment_exchange_count( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """/summary is a meta-command — exchange count stays the same.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Tell me about your project."), + _make_response("Got it — an API."), + _make_response("Mid-session summary: API project."), + _make_response("## Summary\nAPI confirmed."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["I want an API", "/summary", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # Opening (1) + one real user exchange (2), /summary doesn't count + assert result.exchange_count == 2 + + +# ====================================================================== +# /restart slash command +# ====================================================================== + + +class TestRestartCommand: + def test_restart_clears_state_and_resets( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """/restart should reset state and re-send the opening.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What would you like to build?"), + _make_response("Got it — a web app."), + _make_response("Fresh start! What would you like to build?"), + _make_response("## Summary\nFresh summary."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["A web app", "/restart", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # After /restart, exchange_count resets to 1 (the new opening) + assert result.exchange_count == 1 + # Messages were cleared and rebuilt + assert len(session._messages) > 0 + + def test_restart_clears_conversation_history( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """/restart should clear the in-memory message list.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Tell me more."), + _make_response("OK — a database."), + _make_response("Starting fresh!"), + _make_response("## Summary\nEmpty."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["Need a database", "/restart", "done"]) + + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # After restart + done, messages should only contain the + # post-restart opening exchange + the summary exchange + # (pre-restart messages were cleared) + user_msgs = [m for m in session._messages if m.role == "user"] + assert not any("database" in m.content.lower() for m in user_msgs) + + +# ====================================================================== +# /why slash command +# ====================================================================== + + +class TestWhyCommand: + def test_why_no_argument_shows_usage( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """/why with no argument should show usage hint, not crash.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What would you like to build?"), + _make_response("## Summary\nNothing yet."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["/why", "done"]) + output = [] + + session.run( + input_fn=lambda _: next(inputs), + print_fn=output.append, + ) + + combined = "\n".join(str(x) for x in output) + assert "Usage" in combined or "/why" in combined + + def test_why_with_matching_query( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """/why should find exchanges mentioning the queried topic.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What would you like to build?"), + _make_response("Managed identity is the recommended auth approach."), + _make_response("## Summary\nAll confirmed."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["Use managed identity for auth", "/why managed identity", "done"]) + output = [] + + session.run( + input_fn=lambda _: next(inputs), + print_fn=output.append, + ) + + combined = "\n".join(str(x) for x in output) + assert "Exchange" in combined + + def test_why_no_matches( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + ): + """/why with no matching history should show 'no exchanges found'.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What would you like to build?"), + _make_response("## Summary\nNothing yet."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["/why kubernetes", "done"]) + output = [] + + session.run( + input_fn=lambda _: next(inputs), + print_fn=output.append, + ) + + combined = "\n".join(str(x) for x in output) + assert "No exchanges found" in combined + + +# ====================================================================== +# Multi-modal (images) support +# ====================================================================== + + +class TestMultiModalOpening: + """Test that images produce multi-modal content arrays.""" + + def test_build_opening_without_images(self, mock_agent_context, mock_registry): + session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) + result = session._build_opening("context", "artifacts") + assert isinstance(result, str) + assert "context" in result + + def test_build_opening_with_images(self, mock_agent_context, mock_registry): + session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) + images = [ + {"filename": "arch.png", "data": "abc123", "mime": "image/png"}, + {"filename": "flow.jpg", "data": "def456", "mime": "image/jpeg"}, + ] + result = session._build_opening("context", "artifacts", images=images) + assert isinstance(result, list) + # First element is text + assert result[0]["type"] == "text" + assert "context" in result[0]["text"] + # Images follow + assert result[1]["type"] == "image_url" + assert "image/png" in result[1]["image_url"]["url"] + assert result[2]["type"] == "image_url" + assert "image/jpeg" in result[2]["image_url"]["url"] + + def test_build_opening_empty_images_returns_string(self, mock_agent_context, mock_registry): + session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) + result = session._build_opening("context", "", images=[]) + assert isinstance(result, str) + + def test_chat_with_multimodal_content(self, mock_agent_context, mock_registry): + """Multi-modal content array flows through _chat successfully.""" + mock_agent_context.ai_provider.chat.return_value = _make_response("I see the diagram.") + session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) + + content = [ + {"type": "text", "text": "Review this architecture"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ] + response = session._chat(content) + assert response == "I see the diagram." + # Verify AIMessage was constructed with list content + call_args = mock_agent_context.ai_provider.chat.call_args + messages = call_args[0][0] + user_msg = [m for m in messages if m.role == "user"][-1] + assert isinstance(user_msg.content, list) + + def test_chat_vision_fallback(self, mock_agent_context, mock_registry): + """When multi-modal chat fails, _chat retries as text-only.""" + # First call raises, second succeeds + mock_agent_context.ai_provider.chat.side_effect = [ + Exception("Vision not supported"), + _make_response("Got it (text only)."), + ] + session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) + + content = [ + {"type": "text", "text": "Review this"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ] + response = session._chat(content) + assert response == "Got it (text only)." + # Provider was called twice + assert mock_agent_context.ai_provider.chat.call_count == 2 + # Second call has string content (fallback) + second_call = mock_agent_context.ai_provider.chat.call_args_list[1] + messages = second_call[0][0] + user_msg = [m for m in messages if m.role == "user"][-1] + assert isinstance(user_msg.content, str) + assert "[Images could not be processed" in user_msg.content + + def test_run_passes_images_to_opening(self, mock_agent_context, mock_registry): + """The run() method passes artifact_images to _build_opening.""" + mock_agent_context.ai_provider.chat.return_value = _make_response(f"Got your images! {_READY_MARKER}") + session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) + images = [{"filename": "x.png", "data": "abc", "mime": "image/png"}] + + result = session.run( # noqa: F841 + seed_context="test", + artifact_images=images, + input_fn=lambda _: "done", + print_fn=lambda x: None, + context_only=True, + ) + # Verify the provider received a multi-modal message + first_call = mock_agent_context.ai_provider.chat.call_args_list[0] + messages = first_call[0][0] + user_msg = [m for m in messages if m.role == "user"][0] + assert isinstance(user_msg.content, list) + + +# ====================================================================== +# Discovery state multi-modal persistence +# ====================================================================== + + +class TestDiscoveryStateMultiModal: + """Multi-modal content is persisted as text with image count.""" + + def test_update_from_exchange_multimodal(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + state = DiscoveryState(str(tmp_path)) + state.load() + multimodal = [ + {"type": "text", "text": "Here is my architecture"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,def"}}, + ] + state.update_from_exchange(multimodal, "Looks good!", 1) + + history = state.state["conversation_history"] + assert len(history) == 1 + assert "Here is my architecture" in history[0]["user"] + assert "[2 image(s) attached]" in history[0]["user"] + assert "base64" not in history[0]["user"] + + def test_update_from_exchange_string(self, tmp_path): + """Regular string input still works.""" + from azext_prototype.stages.discovery_state import DiscoveryState + + state = DiscoveryState(str(tmp_path)) + state.load() + state.update_from_exchange("plain text", "response", 1) + + history = state.state["conversation_history"] + assert history[0]["user"] == "plain text" + + +# ====================================================================== +# Joint analyst + architect discovery +# ====================================================================== + + +class TestJointDiscovery: + """Test that both biz-analyst and cloud-architect contribute to discovery.""" + + def test_architect_context_injected_into_chat( + self, + mock_agent_context, + mock_registry, + ): + """System messages should include architect constraints.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Tell me about your project."), + _make_response("## Project Summary\nTest project."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + session.run( + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + + # Check that the first AI call includes architect context + first_call = mock_agent_context.ai_provider.chat.call_args_list[0] + messages = first_call[0][0] + system_msgs = [m.content for m in messages if m.role == "system"] + combined = "\n".join(system_msgs) + assert "Architectural Guidance" in combined + assert "Managed Identity" in combined + + def test_architect_constraints_in_system_messages( + self, + mock_agent_context, + mock_registry, + ): + """Architect's constraints should appear in system messages.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("## Project Summary\nDone."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + session.run( + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + + first_call = mock_agent_context.ai_provider.chat.call_args_list[0] + messages = first_call[0][0] + system_content = "\n".join(m.content for m in messages if m.role == "system") + assert "PaaS over IaaS" in system_content + assert "Well-Architected Framework" in system_content + + def test_single_ai_call_per_turn( + self, + mock_agent_context, + mock_registry, + ): + """Joint discovery still uses a single AI call per turn.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("Got it."), + _make_response("## Project Summary\nDone."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["A web app", "done"]) + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # 3 calls: opening + user reply + summary — NOT doubled + assert mock_agent_context.ai_provider.chat.call_count == 3 + + def test_no_architect_still_works( + self, + mock_agent_context, + mock_biz_agent, + ): + """Discovery works when no architect agent is available.""" + registry = MagicMock() + + def find_by_cap(cap): + if cap == AgentCapability.BIZ_ANALYSIS: + return [mock_biz_agent] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("## Project Summary\nDone."), + ] + + session = DiscoverySession(mock_agent_context, registry) + result = session.run( + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + assert not result.cancelled + # No architect context in messages + first_call = mock_agent_context.ai_provider.chat.call_args_list[0] + messages = first_call[0][0] + system_content = "\n".join(m.content for m in messages if m.role == "system") + assert "Architectural Guidance" not in system_content + + def test_build_architect_context_returns_empty_when_none( + self, + mock_agent_context, + mock_biz_agent, + ): + """_build_architect_context returns '' when no architect agent.""" + registry = MagicMock() + registry.find_by_capability.side_effect = lambda cap: ( + [mock_biz_agent] if cap == AgentCapability.BIZ_ANALYSIS else [] + ) + session = DiscoverySession(mock_agent_context, registry) + assert session._build_architect_context() == "" + + +# ====================================================================== +# Updated summary format +# ====================================================================== + + +class TestUpdatedSummaryFormat: + """Test that the summary prompt requests the exact heading format.""" + + def test_summary_prompt_mentions_required_headings( + self, + mock_agent_context, + mock_registry, + ): + """The summary prompt should mention the exact headings to use.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("A web API. Got it."), + _make_response("## Project Summary\nOrders API\n## Goals\n- Manage orders"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter(["An orders REST API", "done"]) + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # The summary call (last call) should mention the required headings + summary_call = mock_agent_context.ai_provider.chat.call_args_list[-1] + messages = summary_call[0][0] + user_msgs = [m.content for m in messages if m.role == "user"] + summary_prompt = user_msgs[-1] + assert "Project Summary" in summary_prompt + assert "Prototype Scope" in summary_prompt + assert "Policy Overrides" in summary_prompt + assert "In Scope" in summary_prompt + + def test_summary_prompt_asks_for_no_skipped_sections( + self, + mock_agent_context, + mock_registry, + ): + """The summary prompt should instruct not to skip sections.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Tell me more."), + _make_response("## Project Summary\nTest"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + session.run( + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + + summary_call = mock_agent_context.ai_provider.chat.call_args_list[-1] + messages = summary_call[0][0] + user_msgs = [m.content for m in messages if m.role == "user"] + summary_prompt = user_msgs[-1] + assert "None" in summary_prompt or "skip" in summary_prompt.lower() + + +# ====================================================================== +# Natural Language Intent Detection — Integration +# ====================================================================== + + +class TestNaturalLanguageIntentDiscovery: + """Test that natural language triggers the correct slash commands.""" + + def test_nl_open_items(self, mock_agent_context, mock_registry): + """'what are the open items' should trigger the /open display.""" + # Use return_value — any call returns a valid response (no headings + # to avoid triggering section-at-a-time gating) + mock_agent_context.ai_provider.chat.return_value = _make_response("Tell me about your project.") + session = DiscoverySession(mock_agent_context, mock_registry) + output = [] + inputs = iter(["what are the open items", "done"]) + session.run( + input_fn=lambda _: next(inputs), + print_fn=output.append, + ) + # The /open handler should have run and printed open items info + assert any("open" in o.lower() for o in output if isinstance(o, str)) + + def test_nl_status(self, mock_agent_context, mock_registry): + """'where do we stand' should trigger the /status display.""" + mock_agent_context.ai_provider.chat.return_value = _make_response("Tell me about your project.") + session = DiscoverySession(mock_agent_context, mock_registry) + output = [] + inputs = iter(["where do we stand", "done"]) + session.run( + input_fn=lambda _: next(inputs), + print_fn=output.append, + ) + assert any("status" in o.lower() or "discovery" in o.lower() for o in output if isinstance(o, str)) + + +# ====================================================================== +# section_fn callback integration +# ====================================================================== + + +class TestSectionFnCallback: + """Verify that section_fn is called with extracted headers during a session.""" + + def test_section_fn_receives_headers( + self, + mock_agent_context, + mock_registry, + ): + """section_fn should be called upfront with all headers from the AI response.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response( + "## Project Context & Scope\n" + "Let me ask about your project.\n" + "## Data & Content\n" + "What kind of data will you store?" + ), + # Summary after "done" exits the section loop + _make_response("## Summary\nAll done."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + captured_headers = [] + + def _section_fn(headers): + captured_headers.extend(headers) + + # "done" exits from the section loop immediately + session.run( + input_fn=lambda _: "done", + print_fn=lambda x: None, + section_fn=_section_fn, + ) + + texts = [h[0] for h in captured_headers] + assert "Project Context & Scope" in texts + assert "Data & Content" in texts + + def test_section_fn_not_called_when_none( + self, + mock_agent_context, + mock_registry, + ): + """When section_fn is None, no error should occur.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Some Heading\nContent"), + _make_response("## Summary\nDone"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + # Should not raise — section_fn defaults to None + result = session.run( + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + assert not result.cancelled + + +# ====================================================================== +# response_fn callback integration +# ====================================================================== + + +class TestResponseFnCallback: + """Verify that response_fn is called with agent responses during a session.""" + + def test_response_fn_receives_agent_responses( + self, + mock_agent_context, + mock_registry, + ): + """response_fn should be called with cleaned agent responses.""" + # Use a response without ## headings so it takes the non-sectioned path + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Let me understand your project. What are you building?"), + _make_response("An API. Got it."), + _make_response("Final summary."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + captured = [] + + def _response_fn(content): + captured.append(content) + + inputs = iter(["A REST API", "done"]) + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + response_fn=_response_fn, + ) + + # response_fn should have been called for the opening and the reply + assert len(captured) == 2 + assert "understand your project" in captured[0] + assert "API" in captured[1] + + def test_response_fn_not_called_when_none( + self, + mock_agent_context, + mock_registry, + ): + """When response_fn is None, print_fn should be used instead.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("What are you building?"), + _make_response("## Summary\nDone"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + printed = [] + + session.run( + input_fn=lambda _: "done", + print_fn=lambda x: printed.append(x), + ) + + # print_fn should have received the response + assert any("building" in p.lower() for p in printed if isinstance(p, str)) + + def test_response_fn_takes_precedence_over_print_fn( + self, + mock_agent_context, + mock_registry, + ): + """response_fn should be used instead of print_fn for agent responses.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Tell me about your project."), + _make_response("## Summary\nDone."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + printed = [] + response_captured = [] + + session.run( + input_fn=lambda _: "done", + print_fn=lambda x: printed.append(x), + response_fn=lambda x: response_captured.append(x), + ) + + # response_fn should have the agent response + assert len(response_captured) == 1 + assert "Tell me about your project" in response_captured[0] + # print_fn should NOT have the agent response text + assert not any("Tell me about your project" in p for p in printed if isinstance(p, str)) + + +# ====================================================================== +# Section completion via AI "Yes" gate +# ====================================================================== + + +class TestSectionDoneDetection: + """Verify section completion detection via AI 'Yes' gate. + + The old heuristic-based ``_is_section_done()`` has been replaced with + an explicit AI confirmation step. When the AI responds with exactly + "Yes" (case-insensitive, optional trailing period) the section is + considered complete. + """ + + def test_continue_in_done_words(self): + """'continue' should be accepted as a done keyword.""" + assert "continue" in _DONE_WORDS + + +# ====================================================================== +# Section-at-a-time flow integration +# ====================================================================== + + +class TestSectionAtATimeFlow: + """Verify sections are shown one at a time with follow-ups.""" + + def test_sections_shown_one_at_a_time( + self, + mock_agent_context, + mock_registry, + ): + """Each section should be shown individually, collecting user input.""" + mock_agent_context.ai_provider.chat.side_effect = [ + # Initial response with 2 sections + _make_response( + "Great, let me explore a few areas.\n\n" + "## Authentication\n" + "How do users sign in?\n\n" + "## Data Layer\n" + "What database do you need?" + ), + # Follow-up for section 1 (auth) — marks section done + _make_response("Yes"), + # Follow-up for section 2 (data) — marks section done + _make_response("Yes"), + # Summary after free-form "done" + _make_response("## Summary\nAll done."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + printed = [] + inputs = iter( + [ + "We use Entra ID", # Answer for section 1 + "SQL Database", # Answer for section 2 + "done", # Exit free-form loop + ] + ) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: printed.append(x), + ) + assert not result.cancelled + # Both sections should have been displayed + printed_text = "\n".join(str(p) for p in printed) + assert "Authentication" in printed_text + assert "Data Layer" in printed_text + + def test_skip_advances_to_next_section( + self, + mock_agent_context, + mock_registry, + ): + """Typing 'skip' should advance to the next section.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Auth\nHow do users sign in?\n\n" "## Data\nWhat database?"), + # Follow-up for data section + _make_response("Yes"), + # Summary + _make_response("## Summary\nDone."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter( + [ + "skip", # Skip auth section + "Cosmos DB", # Answer data section + "done", # Exit free-form + ] + ) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + assert not result.cancelled + + def test_done_exits_section_loop( + self, + mock_agent_context, + mock_registry, + ): + """Typing 'done' during section loop should jump to summary.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Auth\nHow do users sign in?\n\n" "## Data\nWhat database?"), + # Summary produced after "done" + _make_response("## Summary\nFinal summary."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + result = session.run( + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + assert not result.cancelled + assert result.requirements # Should have summary + + def test_quit_cancels_from_section_loop( + self, + mock_agent_context, + mock_registry, + ): + """Typing 'quit' during section loop should cancel the session.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Auth\nHow do users sign in?\n\n" "## Data\nWhat database?"), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + result = session.run( + input_fn=lambda _: "quit", + print_fn=lambda x: None, + ) + assert result.cancelled + + def test_follow_ups_iterate_within_section( + self, + mock_agent_context, + mock_registry, + ): + """Multiple follow-ups within a section should work.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Auth\nHow do users sign in?"), + # First follow-up — needs more info + _make_response("What about service-to-service auth?"), + # Second follow-up — section done + _make_response("Yes"), + # Summary + _make_response("## Summary\nDone."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + inputs = iter( + [ + "Entra ID for users", # First answer + "Managed identity for services", # Second answer + "done", # Exit free-form + ] + ) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + assert not result.cancelled + assert result.exchange_count >= 3 # opening + 2 follow-ups + + def test_update_task_fn_called( + self, + mock_agent_context, + mock_registry, + ): + """update_task_fn should be called with in_progress and completed.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Auth\nHow do users sign in?"), + _make_response("Yes"), + _make_response("## Summary\nDone."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + task_updates = [] + + def _update_task_fn(tid, status): + task_updates.append((tid, status)) + + inputs = iter(["Entra ID", "done"]) + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + update_task_fn=_update_task_fn, + ) + + # Should have in_progress then completed for the auth section + assert ("design-section-auth", "in_progress") in task_updates + assert ("design-section-auth", "completed") in task_updates + + def test_no_sections_fallback( + self, + mock_agent_context, + mock_registry, + ): + """When no sections are found, should display full response.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Tell me what you want to build."), + _make_response("## Summary\nDone."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + printed = [] + + result = session.run( + input_fn=lambda _: "done", + print_fn=lambda x: printed.append(x), + ) + + assert not result.cancelled + printed_text = "\n".join(str(p) for p in printed) + assert "Tell me what you want to build" in printed_text + + def test_yes_gate_not_displayed( + self, + mock_agent_context, + mock_registry, + ): + """AI 'Yes' confirmation should not be printed to the user.""" + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Auth\nHow do users sign in?"), + _make_response("Yes"), + _make_response("## Summary\nDone."), + ] + + session = DiscoverySession(mock_agent_context, mock_registry) + printed = [] + + inputs = iter(["Entra ID", "continue"]) + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: printed.append(x), + ) + + printed_text = "\n".join(str(p) for p in printed) + # The "Yes" response should not appear in output + assert "\nYes\n" not in printed_text + + +# ====================================================================== +# Topic persistence and re-entry +# ====================================================================== + + +class TestTopicPersistence: + """Topics are established once, persisted, and immutable across re-runs.""" + + def test_topics_persisted_on_first_run( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """First run with sections should persist topics to discovery state.""" + from azext_prototype.stages.discovery_state import DiscoveryState + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Auth\nHow do users sign in?\n## Data\nWhat database?"), + _make_response("Yes"), # Auth confirmed + _make_response("Yes"), # Data confirmed + _make_response("## Summary\nDone."), + ] + + ds = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) + inputs = iter(["Entra ID", "PostgreSQL", "done"]) + + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + assert ds.has_topics + topics = ds.topics + assert len(topics) == 2 + assert topics[0].heading == "Auth" + assert topics[1].heading == "Data" + + def test_topics_marked_answered_on_confirm( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """AI 'Yes' confirmation marks topic as answered.""" + from azext_prototype.stages.discovery_state import DiscoveryState + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Auth\nHow do users sign in?\n## Data\nWhat database?"), + _make_response("Yes"), # Auth confirmed + _make_response("Yes"), # Data confirmed + _make_response("## Summary\nDone."), + ] + + ds = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) + inputs = iter(["Entra ID", "PostgreSQL", "done"]) + + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + topics = ds.topics + assert topics[0].status == "answered" + assert topics[0].answer_exchange is not None + assert topics[1].status == "answered" + + def test_topic_marked_skipped( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """Skipping a section marks the topic as skipped.""" + from azext_prototype.stages.discovery_state import DiscoveryState + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Auth\nHow do users sign in?\n## Data\nWhat database?"), + _make_response("Yes"), # Data confirmed + _make_response("## Summary\nDone."), + ] + + ds = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) + inputs = iter(["skip", "PostgreSQL", "done"]) + + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + topics = ds.topics + assert topics[0].status == "skipped" + assert topics[1].status == "answered" + + def test_topics_remain_pending_on_quit( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """Quitting mid-session leaves remaining topics as pending.""" + from azext_prototype.stages.discovery_state import DiscoveryState + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response( + "## Auth\nHow do users sign in?\n## Data\nWhat database?\n## Networking\nPublic or private?" + ), + _make_response("Yes"), # Auth confirmed + ] + + ds = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) + inputs = iter(["Entra ID", "quit"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + assert result.cancelled + topics = ds.topics + assert topics[0].status == "answered" + assert topics[1].status == "pending" + assert topics[2].status == "pending" + + +class TestTopicReentry: + """Re-entry resumes at the first unanswered topic.""" + + def test_reentry_resumes_at_first_pending( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """Re-run with existing topics resumes at first pending topic.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + # Pre-populate state with topics (Auth answered, Data pending) + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic( + heading="Auth", + detail="## Auth\nHow do users sign in?", + kind="topic", + status="answered", + answer_exchange=2, + ), + Topic( + heading="Data", + detail="## Data\nWhat database?", + kind="topic", + status="pending", + answer_exchange=None, + ), + ] + ) + ds.state["_metadata"]["exchange_count"] = 2 + ds.save() + + # Re-run: should skip Auth and start with Data + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Yes"), # Data confirmed + _make_response("## Summary\nDone."), + ] + + ds2 = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) + inputs = iter(["PostgreSQL", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + assert not result.cancelled + # Data should now be answered + topics = ds2.topics + assert topics[0].status == "answered" # Auth unchanged + assert topics[1].status == "answered" # Data now answered + + def test_reentry_shows_progress_message( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """Re-entry should show a progress message.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), + Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="pending", answer_exchange=None), + Topic(heading="Net", detail="## Net\nPublic?", kind="topic", status="pending", answer_exchange=None), + ] + ) + ds.save() + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Yes"), + _make_response("Yes"), + _make_response("## Summary\nDone."), + ] + + ds2 = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) + printed = [] + inputs = iter(["PostgreSQL", "Public", "done"]) + + session.run( + input_fn=lambda _: next(inputs), + print_fn=printed.append, + ) + + combined = "\n".join(str(p) for p in printed) + assert "1/3 topics covered" in combined + + def test_reentry_all_topics_done_falls_through( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """If all topics are done on re-entry, fall through to free-form loop.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), + Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="answered", answer_exchange=2), + ] + ) + ds.save() + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Summary\nDone."), # Summary from free-form "done" + ] + + ds2 = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) + + result = session.run( + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + + assert not result.cancelled + + def test_reentry_does_not_resend_full_history( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """Re-entry seeds messages with compact summary, not full history.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.state["project"]["summary"] = "An inventory API" + ds.set_topics( + [ + Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), + Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="pending", answer_exchange=None), + ] + ) + ds.state["_metadata"]["exchange_count"] = 3 + # Add large conversation history + for i in range(20): + ds.state["conversation_history"].append( + { + "exchange": i + 1, + "timestamp": "2026-01-01T00:00:00", + "user": f"Long user message {i}" * 50, + "assistant": f"Long assistant response {i}" * 50, + } + ) + ds.save() + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Yes"), + _make_response("## Summary\nDone."), + ] + + ds2 = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) + inputs = iter(["PostgreSQL", "done"]) + + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # The first AI call should NOT contain all 20 exchanges + first_call = mock_agent_context.ai_provider.chat.call_args_list[0] + messages = first_call[0][0] + user_msgs = [m for m in messages if m.role == "user"] + # Should have compact summary + the section follow-up prompt, not 20+ user messages + assert len(user_msgs) <= 5 + + def test_reentry_restores_exchange_count( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """Re-entry restores exchange count from metadata.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="pending", answer_exchange=None), + ] + ) + ds.state["_metadata"]["exchange_count"] = 5 + ds.save() + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Yes"), + _make_response("## Summary\nDone."), + ] + + ds2 = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) + inputs = iter(["PostgreSQL", "done"]) + + result = session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + # Exchange count should continue from 5, not restart at 0 + assert result.exchange_count == 6 + + +class TestIncrementalTopics: + """New artifacts can add topics but not replace existing ones.""" + + def test_new_artifacts_add_topics( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """Re-entry with new artifacts should add new topics.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), + Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="answered", answer_exchange=2), + ] + ) + ds.save() + + # AI identifies a new topic from the new artifact + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("## Caching\nWhat caching strategy do you need?"), # incremental context + _make_response("Yes"), # Caching confirmed + _make_response("## Summary\nDone."), + ] + + ds2 = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) + inputs = iter(["Redis", "done"]) + + session.run( + seed_context="We also need caching", + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + topics = ds2.topics + assert len(topics) == 3 + assert topics[2].heading == "Caching" + + def test_no_new_topics_marker( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """AI returns [NO_NEW_TOPICS] when artifacts don't warrant new topics.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), + ] + ) + ds.save() + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("[NO_NEW_TOPICS]"), # No new topics needed + _make_response("What are you building?"), # Free-form (all topics done) + _make_response("## Summary\nDone."), + ] + + ds2 = DiscoveryState(str(tmp_path)) + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) + + session.run( + seed_context="Same project, just more detail", + input_fn=lambda _: "done", + print_fn=lambda x: None, + ) + + # Original topics unchanged + assert len(ds2.topics) == 1 + assert ds2.topics[0].heading == "Auth" + + def test_duplicate_headings_deduplicated( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """append_topics should not add duplicates (case-insensitive).""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), + ] + ) + + ds.append_topics( + [ + Topic( + heading="auth", detail="## auth\nDuplicate?", kind="topic", status="pending", answer_exchange=None + ), + Topic( + heading="Caching", + detail="## Caching\nNew topic", + kind="topic", + status="pending", + answer_exchange=None, + ), + ] + ) + + topics = ds.topics + assert len(topics) == 2 # Auth (original) + Caching (new) + assert topics[0].heading == "Auth" + assert topics[1].heading == "Caching" + + +class TestTopicStateHelpers: + """Unit tests for Topic dataclass and DiscoveryState topic helpers.""" + + def test_topic_to_dict_roundtrip(self): + from azext_prototype.stages.discovery_state import Topic + + t = Topic(heading="Auth", detail="How do users sign in?", kind="topic", status="answered", answer_exchange=3) + d = t.to_dict() + t2 = Topic.from_dict(d) + assert t2.heading == "Auth" + assert t2.detail == "How do users sign in?" + assert t2.status == "answered" + assert t2.answer_exchange == 3 + + def test_topic_from_dict_defaults(self): + from azext_prototype.stages.discovery_state import Topic + + t = Topic.from_dict({"heading": "Auth"}) + assert t.detail == "" + assert t.status == "pending" + assert t.answer_exchange is None + + def test_default_state_has_items_key(self): + from azext_prototype.stages.discovery_state import _default_discovery_state + + state = _default_discovery_state() + assert "items" in state + assert state["items"] == [] + + def test_has_topics_false_on_empty(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + assert not ds.has_topics + + def test_first_pending_topic_index(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="A", detail="Q", kind="topic", status="answered", answer_exchange=1), + Topic(heading="B", detail="Q", kind="topic", status="skipped", answer_exchange=None), + Topic(heading="C", detail="Q", kind="topic", status="pending", answer_exchange=None), + ] + ) + assert ds.first_pending_topic_index() == 2 + + def test_first_pending_topic_index_none_when_all_done(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="A", detail="Q", kind="topic", status="answered", answer_exchange=1), + ] + ) + assert ds.first_pending_topic_index() is None + + def test_mark_topic(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="Auth", detail="Q", kind="topic", status="pending", answer_exchange=None), + ] + ) + ds.mark_topic("Auth", "answered", 5) + topics = ds.topics + assert topics[0].status == "answered" + assert topics[0].answer_exchange == 5 + + def test_backward_compat_old_yaml_without_items(self, tmp_path): + """Old discovery.yaml without items key should get items: [] via deep_merge.""" + import yaml + + from azext_prototype.stages.discovery_state import DiscoveryState + + # Write a YAML file without items key (no topics/open_items/confirmed_items either) + state_dir = tmp_path / ".prototype" / "state" + state_dir.mkdir(parents=True) + old_state = { + "project": {"summary": "Old project", "goals": ["Goal 1"]}, + "requirements": {"functional": [], "non_functional": []}, + "constraints": [], + "decisions": [], + "risks": [], + "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, + "architecture": {"services": [], "integrations": [], "data_flow": ""}, + "conversation_history": [], + "_metadata": {"created": "2026-01-01", "last_updated": "2026-01-01", "exchange_count": 3}, + } + with open(state_dir / "discovery.yaml", "w") as f: + yaml.dump(old_state, f) + + ds = DiscoveryState(str(tmp_path)) + ds.load() + assert not ds.has_items # Empty list = no items + assert ds.state.get("items") == [] + # Old data preserved + assert ds.state["project"]["summary"] == "Old project" + + +class TestLegacyMigration: + """Verify old-format YAML (topics + open_items + confirmed_items) migrates on load.""" + + def test_migrate_old_topics(self, tmp_path): + """Legacy topics field is migrated into unified items.""" + import yaml + + from azext_prototype.stages.discovery_state import DiscoveryState + + state_dir = tmp_path / ".prototype" / "state" + state_dir.mkdir(parents=True) + old_state = { + "project": {"summary": "", "goals": []}, + "requirements": {"functional": [], "non_functional": []}, + "constraints": [], + "decisions": [], + "topics": [ + {"heading": "Auth", "questions": "How do users sign in?", "status": "answered", "answer_exchange": 1}, + {"heading": "Data", "questions": "What database?", "status": "pending", "answer_exchange": None}, + ], + "open_items": [], + "confirmed_items": [], + "risks": [], + "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, + "architecture": {"services": [], "integrations": [], "data_flow": ""}, + "conversation_history": [], + "_metadata": {"created": "2026-01-01", "last_updated": "2026-01-01", "exchange_count": 2}, + } + with open(state_dir / "discovery.yaml", "w") as f: + yaml.dump(old_state, f) + + ds = DiscoveryState(str(tmp_path)) + ds.load() + + assert "topics" not in ds.state + assert "open_items" not in ds.state + assert "confirmed_items" not in ds.state + assert len(ds.items) == 2 + assert ds.items[0].heading == "Auth" + assert ds.items[0].detail == "How do users sign in?" + assert ds.items[0].kind == "topic" + assert ds.items[0].status == "answered" + assert ds.items[1].heading == "Data" + assert ds.items[1].status == "pending" + + def test_migrate_old_open_and_confirmed_items(self, tmp_path): + """Legacy open_items and confirmed_items migrate as decisions.""" + import yaml + + from azext_prototype.stages.discovery_state import DiscoveryState + + state_dir = tmp_path / ".prototype" / "state" + state_dir.mkdir(parents=True) + old_state = { + "project": {"summary": "", "goals": []}, + "requirements": {"functional": [], "non_functional": []}, + "constraints": [], + "decisions": [], + "open_items": ["Which region?", "Auth method?"], + "confirmed_items": ["Use PostgreSQL"], + "risks": [], + "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, + "architecture": {"services": [], "integrations": [], "data_flow": ""}, + "conversation_history": [], + "_metadata": {"created": "2026-01-01", "last_updated": "2026-01-01", "exchange_count": 3}, + } + with open(state_dir / "discovery.yaml", "w") as f: + yaml.dump(old_state, f) + + ds = DiscoveryState(str(tmp_path)) + ds.load() + + assert "open_items" not in ds.state + assert "confirmed_items" not in ds.state + assert len(ds.items) == 3 + # Two pending decisions from open_items + pending = ds.items_by_status("pending") + assert len(pending) == 2 + assert all(i.kind == "decision" for i in pending) + # One confirmed decision from confirmed_items + confirmed = ds.items_by_status("confirmed") + assert len(confirmed) == 1 + assert confirmed[0].heading == "Use PostgreSQL" + + def test_migrate_combined_topics_and_items(self, tmp_path): + """Legacy state with both topics AND open_items merges correctly.""" + import yaml + + from azext_prototype.stages.discovery_state import DiscoveryState + + state_dir = tmp_path / ".prototype" / "state" + state_dir.mkdir(parents=True) + old_state = { + "project": {"summary": "", "goals": []}, + "requirements": {"functional": [], "non_functional": []}, + "constraints": [], + "decisions": [], + "topics": [ + {"heading": "Auth", "questions": "How?", "status": "answered", "answer_exchange": 1}, + ], + "open_items": ["Which region?"], + "confirmed_items": ["Use Terraform"], + "risks": [], + "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, + "architecture": {"services": [], "integrations": [], "data_flow": ""}, + "conversation_history": [], + "_metadata": {"created": "2026-01-01", "last_updated": "2026-01-01", "exchange_count": 2}, + } + with open(state_dir / "discovery.yaml", "w") as f: + yaml.dump(old_state, f) + + ds = DiscoveryState(str(tmp_path)) + ds.load() + + assert len(ds.items) == 3 + assert ds.items[0].kind == "topic" # Auth + assert ds.items[1].kind == "decision" # Which region? + assert ds.items[1].status == "pending" + assert ds.items[2].kind == "decision" # Use Terraform + assert ds.items[2].status == "confirmed" + + +class TestUnifiedStatusCommands: + """Verify /status, /open, /confirmed show data from unified items.""" + + def test_status_shows_topics(self, tmp_path): + """format_status_summary counts topics as items.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + Topic(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=1), + Topic(heading="Data", detail="Q?", kind="topic", status="pending", answer_exchange=None), + Topic(heading="Net", detail="Q?", kind="topic", status="pending", answer_exchange=None), + ] + ) + + assert ds.open_count == 2 + assert ds.confirmed_count == 1 + summary = ds.format_status_summary() + assert "1 confirmed" in summary + assert "2 open" in summary + + def test_open_items_shows_pending_topics(self, tmp_path): + """format_open_items lists pending topics.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + Topic(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=1), + Topic(heading="Data", detail="Q?", kind="topic", status="pending", answer_exchange=None), + ] + ) + + text = ds.format_open_items() + assert "Data" in text + assert "Auth" not in text + assert "Topics:" in text + + def test_confirmed_items_shows_answered_topics(self, tmp_path): + """format_confirmed_items lists answered topics.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + Topic(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=1), + Topic(heading="Data", detail="Q?", kind="topic", status="pending", answer_exchange=None), + ] + ) + + text = ds.format_confirmed_items() + assert "Auth" in text + assert "Data" not in text + + def test_status_no_items(self, tmp_path): + """format_status_summary with no items.""" + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + + assert ds.format_status_summary() == "No items tracked yet." + assert "No open items" in ds.format_open_items() + assert "No items confirmed" in ds.format_confirmed_items() + + def test_mixed_kinds_in_open(self, tmp_path): + """format_open_items groups topics and decisions separately.""" + from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + TrackedItem(heading="Auth", detail="Q?", kind="topic", status="pending", answer_exchange=None), + TrackedItem( + heading="Which region?", + detail="Which region?", + kind="decision", + status="pending", + answer_exchange=None, + ), + ] + ) + + text = ds.format_open_items() + assert "Topics:" in text + assert "Auth" in text + assert "Decisions:" in text + assert "Which region?" in text + + +class TestArtifactInventoryState: + """Tests for artifact inventory and context hash tracking in DiscoveryState.""" + + def test_default_state_has_inventory_keys(self): + from azext_prototype.stages.discovery_state import _default_discovery_state + + state = _default_discovery_state() + assert "artifact_inventory" in state + assert state["artifact_inventory"] == {} + assert "context_hash" in state + assert state["context_hash"] == "" + + def test_artifact_inventory_roundtrip(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.update_artifact_inventory({"/abs/path/file.txt": "abc123", "/abs/path/img.png": "def456"}) + + # Reload from disk + ds2 = DiscoveryState(str(tmp_path)) + ds2.load() + hashes = ds2.get_artifact_hashes() + assert hashes == {"/abs/path/file.txt": "abc123", "/abs/path/img.png": "def456"} + + def test_get_artifact_hashes_flat_mapping(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.update_artifact_inventory({"/a/b.txt": "hash1", "/c/d.txt": "hash2"}) + + hashes = ds.get_artifact_hashes() + assert isinstance(hashes, dict) + assert hashes["/a/b.txt"] == "hash1" + assert hashes["/c/d.txt"] == "hash2" + + def test_update_artifact_inventory_is_additive(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.update_artifact_inventory({"/a/first.txt": "aaa"}) + ds.update_artifact_inventory({"/b/second.txt": "bbb"}) + + hashes = ds.get_artifact_hashes() + assert len(hashes) == 2 + assert hashes["/a/first.txt"] == "aaa" + assert hashes["/b/second.txt"] == "bbb" + + def test_update_artifact_inventory_overwrites_hash(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.update_artifact_inventory({"/a/file.txt": "old_hash"}) + ds.update_artifact_inventory({"/a/file.txt": "new_hash"}) + + assert ds.get_artifact_hashes()["/a/file.txt"] == "new_hash" + + def test_context_hash_roundtrip(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.update_context_hash("ctx_hash_abc") + + ds2 = DiscoveryState(str(tmp_path)) + ds2.load() + assert ds2.get_context_hash() == "ctx_hash_abc" + + def test_reset_clears_inventory(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.update_artifact_inventory({"/a/file.txt": "hash1"}) + ds.update_context_hash("ctx_hash") + + ds.reset() + assert ds.get_artifact_hashes() == {} + assert ds.get_context_hash() == "" + + def test_legacy_state_without_inventory_loads(self, tmp_path): + """Old discovery.yaml without inventory keys loads cleanly via _deep_merge.""" + import yaml + + from azext_prototype.stages.discovery_state import DiscoveryState + + state_dir = tmp_path / ".prototype" / "state" + state_dir.mkdir(parents=True) + # Write a minimal legacy state without the new keys + legacy = { + "project": {"summary": "test", "goals": []}, + "requirements": {"functional": [], "non_functional": []}, + "constraints": [], + "decisions": [], + "items": [], + "risks": [], + "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, + "architecture": {"services": [], "integrations": [], "data_flow": ""}, + "conversation_history": [], + "_metadata": {"created": None, "last_updated": None, "exchange_count": 0}, + } + with open(state_dir / "discovery.yaml", "w") as f: + yaml.dump(legacy, f) + + ds = DiscoveryState(str(tmp_path)) + ds.load() + # New keys should be present with defaults + assert ds.get_artifact_hashes() == {} + assert ds.get_context_hash() == "" + assert ds.state["project"]["summary"] == "test" + + +class TestSectionLoopSlashCommands: + """Verify that slash commands do NOT consume inner loop iterations.""" + + def test_slash_commands_do_not_advance_topic( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """Issuing 5+ slash commands should NOT mark a topic as answered.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="pending", answer_exchange=None), + Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="pending", answer_exchange=None), + ] + ) + ds.save() + + # AI identifies no new topics (re-entry), then confirms section after real answer + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Yes"), # Auth confirmed after real answer + _make_response("Yes"), # Data confirmed after real answer + _make_response("## Summary\nDone."), + ] + + ds2 = DiscoveryState(str(tmp_path)) + # 6 slash commands first (more than old limit of 5), then a real answer, then done + inputs = iter( + [ + "/status", + "/open", + "/confirmed", + "/status", + "/open", + "/confirmed", + "Use Azure AD B2C", # Real answer for Auth + "Use Cosmos DB", # Real answer for Data + "done", + ] + ) + + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + topics = ds2.topics + # Auth should be answered (via real AI exchange), not prematurely + assert topics[0].status == "answered" + assert topics[0].answer_exchange is not None + # Data should also be answered + assert topics[1].status == "answered" + + def test_empty_input_does_not_advance_topic( + self, + mock_agent_context, + mock_registry, + mock_biz_agent, + tmp_path, + ): + """Pressing Enter 5+ times should NOT mark a topic as answered.""" + from azext_prototype.stages.discovery_state import DiscoveryState, Topic + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_topics( + [ + Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="pending", answer_exchange=None), + ] + ) + ds.save() + + mock_agent_context.ai_provider.chat.side_effect = [ + _make_response("Yes"), # Auth confirmed after real answer + _make_response("## Summary\nDone."), + ] + + ds2 = DiscoveryState(str(tmp_path)) + # 6 empty inputs, then a real answer, then done + inputs = iter(["", "", "", "", "", "", "Use Azure AD", "done"]) + + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) + session.run( + input_fn=lambda _: next(inputs), + print_fn=lambda x: None, + ) + + topics = ds2.topics + assert topics[0].status == "answered" + assert topics[0].answer_exchange is not None + + +class TestRestartSignal: + """Verify /restart breaks out of section loop.""" + + def test_restart_returns_signal_from_handler(self, mock_agent_context, mock_registry, mock_biz_agent, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + + mock_agent_context.ai_provider.chat.return_value = _make_response("Welcome!") + session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) + # Set up I/O attributes that _handle_slash_command needs + session._print = lambda x: None + session._use_styled = False + session._status_fn = None + session._response_fn = None + session._messages = [] + result = session._handle_slash_command("/restart") + assert result == "restart" + + def test_non_restart_returns_none(self, mock_agent_context, mock_registry, mock_biz_agent): + session = DiscoverySession(mock_agent_context, mock_registry) + session._print = lambda x: None + session._use_styled = False + result = session._handle_slash_command("/status") + assert result is None + + result = session._handle_slash_command("/open") + assert result is None + + +class TestTopicAtExchange: + """Verify topic_at_exchange() cross-references exchanges with topics.""" + + def test_finds_topic_at_exchange(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), + TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=4), + TrackedItem(heading="Scale", detail="Q?", kind="topic", status="pending", answer_exchange=None), + ] + ) + + assert ds.topic_at_exchange(1) == "Auth" + assert ds.topic_at_exchange(2) == "Auth" + assert ds.topic_at_exchange(3) == "Data" + assert ds.topic_at_exchange(4) == "Data" + assert ds.topic_at_exchange(5) is None # Beyond all answered topics + + def test_no_answered_topics(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + TrackedItem(heading="Auth", detail="Q?", kind="topic", status="pending", answer_exchange=None), + ] + ) + + assert ds.topic_at_exchange(1) is None + + def test_empty_state(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + assert ds.topic_at_exchange(1) is None + + +# ====================================================================== +# add_confirmed_decision deduplication +# ====================================================================== + + +class TestAddConfirmedDecisionDedup: + """Test that add_confirmed_decision deduplicates.""" + + def test_same_decision_not_duplicated(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + + ds.add_confirmed_decision("Use Redis for caching") + ds.add_confirmed_decision("Use Redis for caching") + ds.add_confirmed_decision("Use Redis for caching") + + assert ds.state["decisions"].count("Use Redis for caching") == 1 + + def test_different_decisions_both_stored(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + + ds.add_confirmed_decision("Use Redis") + ds.add_confirmed_decision("Use PostgreSQL") + + assert "Use Redis" in ds.state["decisions"] + assert "Use PostgreSQL" in ds.state["decisions"] + assert len(ds.state["decisions"]) == 2 + + def test_empty_string_not_stored(self, tmp_path): + from azext_prototype.stages.discovery_state import DiscoveryState + + ds = DiscoveryState(str(tmp_path)) + ds.load() + + ds.add_confirmed_decision("") + assert len(ds.state["decisions"]) == 0 + + +# ====================================================================== +# topic_at_exchange — overlapping exchanges +# ====================================================================== + + +class TestTopicAtExchangeOverlapping: + """Test topic_at_exchange with overlapping and edge case exchange ranges.""" + + def test_overlapping_exchange_numbers(self, tmp_path): + """When multiple topics have the same answer_exchange, first by sort wins.""" + from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), + TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=2), + TrackedItem(heading="Scale", detail="Q?", kind="topic", status="answered", answer_exchange=5), + ] + ) + + # Exchange 2 maps to the first answered topic with answer_exchange >= 2 + result = ds.topic_at_exchange(2) + assert result in ("Auth", "Data") # Either is valid — both have exchange 2 + + def test_exchange_between_topics(self, tmp_path): + """Exchange number between two answer_exchanges maps to the later topic.""" + from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), + TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=5), + ] + ) + + # Exchange 3 is after Auth (2) but before Data (5) → Data + assert ds.topic_at_exchange(3) == "Data" + + def test_exchange_zero(self, tmp_path): + """Exchange 0 should return the first topic.""" + from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), + ] + ) + + assert ds.topic_at_exchange(0) == "Auth" + + def test_exchange_beyond_all_returns_none(self, tmp_path): + """Exchange after all answer_exchanges returns None (free-form).""" + from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), + TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=5), + ] + ) + + assert ds.topic_at_exchange(10) is None + + def test_single_topic_covers_all_earlier_exchanges(self, tmp_path): + """A single answered topic covers all exchanges up to its answer_exchange.""" + from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=5), + ] + ) + + assert ds.topic_at_exchange(1) == "Auth" + assert ds.topic_at_exchange(3) == "Auth" + assert ds.topic_at_exchange(5) == "Auth" + assert ds.topic_at_exchange(6) is None + + def test_mixed_answered_and_pending(self, tmp_path): + """Pending topics (no answer_exchange) don't appear in results.""" + from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem + + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.set_items( + [ + TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), + TrackedItem(heading="Pending Topic", detail="Q?", kind="topic", status="pending", answer_exchange=None), + TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=5), + ] + ) + + assert ds.topic_at_exchange(1) == "Auth" + assert ds.topic_at_exchange(3) == "Data" + assert ds.topic_at_exchange(6) is None diff --git a/tests/stages/test_discovery_state.py b/tests/stages/test_discovery_state.py index 36a81d0..99c2df7 100644 --- a/tests/stages/test_discovery_state.py +++ b/tests/stages/test_discovery_state.py @@ -586,3 +586,190 @@ def test_from_dict_legacy_questions_key(self): d = {"heading": "H", "questions": "Q?", "status": "pending"} item = TrackedItem.from_dict(d) assert item.detail == "Q?" + + + +class TestDiscoveryStateScope: + """Test the scope fields in DiscoveryState.""" + + def test_default_state_has_scope(self): + state = _default_discovery_state() + assert "scope" in state + assert state["scope"] == { + "in_scope": [], + "out_of_scope": [], + "deferred": [], + } + + def test_merge_learnings_with_scope(self, tmp_path): + ds = DiscoveryState(str(tmp_path)) + ds.load() + + learnings = { + "scope": { + "in_scope": ["REST API", "SQL Database"], + "out_of_scope": ["Mobile app"], + "deferred": ["CI/CD pipeline"], + }, + } + ds.merge_learnings(learnings) + + assert ds.state["scope"]["in_scope"] == ["REST API", "SQL Database"] + assert ds.state["scope"]["out_of_scope"] == ["Mobile app"] + assert ds.state["scope"]["deferred"] == ["CI/CD pipeline"] + + def test_merge_learnings_deduplicates_scope(self, tmp_path): + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.state["scope"]["in_scope"] = ["REST API"] + + learnings = { + "scope": { + "in_scope": ["REST API", "SQL Database"], + }, + } + ds.merge_learnings(learnings) + + assert ds.state["scope"]["in_scope"] == ["REST API", "SQL Database"] + + def test_merge_learnings_partial_scope(self, tmp_path): + ds = DiscoveryState(str(tmp_path)) + ds.load() + + learnings = { + "scope": { + "in_scope": ["API endpoints"], + }, + } + ds.merge_learnings(learnings) + + assert ds.state["scope"]["in_scope"] == ["API endpoints"] + assert ds.state["scope"]["out_of_scope"] == [] + assert ds.state["scope"]["deferred"] == [] + + def test_merge_learnings_without_scope(self, tmp_path): + """Learnings without scope should not break merge.""" + ds = DiscoveryState(str(tmp_path)) + ds.load() + + learnings = { + "project": {"summary": "Test", "goals": ["Goal 1"]}, + } + ds.merge_learnings(learnings) + + assert ds.state["scope"]["in_scope"] == [] + + def test_format_as_context_includes_scope(self, tmp_path): + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds._loaded = True + ds.state["scope"] = { + "in_scope": ["REST API"], + "out_of_scope": ["Mobile app"], + "deferred": ["CI/CD"], + } + + context = ds.format_as_context() + assert "## Prototype Scope" in context + assert "### In Scope" in context + assert "REST API" in context + assert "### Out of Scope" in context + assert "Mobile app" in context + assert "### Deferred / Future Work" in context + assert "CI/CD" in context + + def test_format_as_context_partial_scope(self, tmp_path): + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds._loaded = True + ds.state["scope"]["in_scope"] = ["REST API"] + + context = ds.format_as_context() + assert "### In Scope" in context + assert "### Out of Scope" not in context + assert "### Deferred" not in context + + def test_format_as_context_omits_empty_scope(self, tmp_path): + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds._loaded = True + ds.state["project"]["summary"] = "Test project" + + context = ds.format_as_context() + assert "Prototype Scope" not in context + + def test_format_as_context_falls_back_to_conversation(self, tmp_path): + """When structured fields are empty, format_as_context uses conversation history.""" + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds._loaded = True + # Structured fields are all empty (default), but conversation has content + ds.state["conversation_history"] = [ + {"exchange": 1, "assistant": "Tell me more."}, + { + "exchange": 2, + "assistant": ( + "## Project Summary\nA web app for email drafting.\n\n" + "## Confirmed Functional Requirements\n- Feature A\n\n" + "[READY]" + ), + }, + ] + + context = ds.format_as_context() + assert "## Project Summary" in context + assert "email drafting" in context + assert "Feature A" in context + assert "[READY]" not in context + + def test_format_as_context_prefers_structured_fields(self, tmp_path): + """When structured fields are populated, those are used instead of conversation.""" + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds._loaded = True + ds.state["project"]["summary"] = "Structured summary" + ds.state["conversation_history"] = [ + { + "exchange": 1, + "assistant": "## Project Summary\nConversation summary.\n\n## Confirmed Functional Requirements\n- X", + }, + ] + + context = ds.format_as_context() + assert "Structured summary" in context + assert "Conversation summary" not in context + + def test_extract_conversation_summary(self, tmp_path): + """extract_conversation_summary returns last assistant message with summary headings.""" + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.state["conversation_history"] = [ + {"exchange": 1, "assistant": "Tell me more."}, + { + "exchange": 2, + "assistant": "## Project Summary\nA web app.\n\n[READY]", + }, + ] + + result = ds.extract_conversation_summary() + assert "## Project Summary" in result + assert "[READY]" not in result + + def test_extract_conversation_summary_empty_history(self, tmp_path): + ds = DiscoveryState(str(tmp_path)) + ds.load() + + assert ds.extract_conversation_summary() == "" + + def test_scope_persists_to_yaml(self, tmp_path): + ds = DiscoveryState(str(tmp_path)) + ds.load() + ds.state["scope"]["in_scope"] = ["API endpoints"] + ds.state["scope"]["out_of_scope"] = ["Mobile app"] + ds.save() + + ds2 = DiscoveryState(str(tmp_path)) + ds2.load() + assert ds2.state["scope"]["in_scope"] == ["API endpoints"] + assert ds2.state["scope"]["out_of_scope"] == ["Mobile app"] + assert ds2.state["scope"]["deferred"] == [] diff --git a/tests/stages/test_escalation.py b/tests/stages/test_escalation.py index df56c6a..7f8b8c2 100644 --- a/tests/stages/test_escalation.py +++ b/tests/stages/test_escalation.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """Tests for EscalationTracker — 4-level escalation chain. Tier 2: Conditional branches with multiple paths. @@ -524,3 +526,537 @@ def test_attempted_solutions_count(self, tracker): tracker.record_attempted_solution(entry, "sol2") report = tracker.format_escalation_report() assert "Attempts: 2" in report + + +# --- Additional imports from merged flat test --- +from azext_prototype.agents.base import AgentContext +from azext_prototype.ai.provider import AIResponse +from azext_prototype.stages.backlog_session import BacklogSession +from azext_prototype.stages.backlog_state import BacklogState +from azext_prototype.stages.build_session import BuildSession +from azext_prototype.stages.deploy_session import DeploySession +from azext_prototype.stages.deploy_state import DeployState +from azext_prototype.stages.qa_router import route_error_to_qa +from pathlib import Path +import yaml + + +# ====================================================================== + + +def _make_entry(**kwargs) -> EscalationEntry: + defaults = { + "task_description": "Build Stage 3: Data Layer", + "blocker": "Cosmos DB requires premium tier", + "source_agent": "terraform-agent", + "source_stage": "build", + "created_at": datetime.now(timezone.utc).isoformat(), + "last_escalated_at": datetime.now(timezone.utc).isoformat(), + } + defaults.update(kwargs) + return EscalationEntry(**defaults) + +def _make_registry(architect_response=None, pm_response=None): + from azext_prototype.agents.base import AgentCapability + + architect = MagicMock() + architect.name = "cloud-architect" + if architect_response: + architect.execute.return_value = architect_response + else: + architect.execute.return_value = MagicMock(content="Use Standard tier instead") + + pm = MagicMock() + pm.name = "project-manager" + if pm_response: + pm.execute.return_value = pm_response + else: + pm.execute.return_value = MagicMock(content="Descope this item") + + registry = MagicMock() + + def find_by_cap(cap): + if cap == AgentCapability.ARCHITECT: + return [architect] + if cap == AgentCapability.BACKLOG_GENERATION: + return [pm] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + return registry, architect, pm + +def _make_context(): + from azext_prototype.agents.base import AgentContext + + return AgentContext( + project_config={"project": {"name": "test"}}, + project_dir="/tmp/test", + ai_provider=MagicMock(), + ) + +# ====================================================================== + + +class TestEscalationTrackerState: + + def test_record_blocker(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + + entry = tracker.record_blocker( + "Deploy Redis", + "Premium tier required", + "terraform-agent", + "deploy", + ) + + assert entry.task_description == "Deploy Redis" + assert entry.blocker == "Premium tier required" + assert entry.escalation_level == 1 + assert entry.created_at != "" + assert len(tracker.get_active_blockers()) == 1 + + def test_record_attempted_solution(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + + tracker.record_attempted_solution(entry, "Tried standard tier") + tracker.record_attempted_solution(entry, "Tried basic tier") + + assert len(entry.attempted_solutions) == 2 + assert "Tried standard tier" in entry.attempted_solutions + + def test_resolve_blocker(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + + tracker.resolve(entry, "Used standard tier instead") + + assert entry.resolved is True + assert entry.resolution == "Used standard tier instead" + assert len(tracker.get_active_blockers()) == 0 + + def test_get_active_blockers_filters_resolved(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + e1 = tracker.record_blocker("task1", "blocked1", "a1", "s1") + e2 = tracker.record_blocker("task2", "blocked2", "a2", "s2") # noqa: F841 + tracker.resolve(e1, "fixed") + + active = tracker.get_active_blockers() + assert len(active) == 1 + assert active[0].task_description == "task2" + + def test_save_load_roundtrip(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + tracker.record_blocker("task1", "blocked1", "agent1", "stage1") + tracker.record_blocker("task2", "blocked2", "agent2", "stage2") + + tracker2 = EscalationTracker(str(tmp_project)) + tracker2.load() + + assert len(tracker2.get_active_blockers()) == 2 + assert tracker2.get_active_blockers()[0].task_description == "task1" + + def test_save_creates_yaml(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + tracker.record_blocker("task", "blocked", "agent", "stage") + + yaml_path = Path(str(tmp_project)) / ".prototype" / "state" / "escalation.yaml" + assert yaml_path.exists() + + with open(yaml_path) as f: + data = yaml.safe_load(f) + assert len(data["entries"]) == 1 + + def test_exists_property(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + assert not tracker.exists + + tracker.record_blocker("task", "blocked", "agent", "stage") + assert tracker.exists + + def test_empty_load(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + tracker.load() # No file exists + assert tracker.get_active_blockers() == [] + + def test_multiple_records_and_resolves(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + e1 = tracker.record_blocker("t1", "b1", "a", "s") + e2 = tracker.record_blocker("t2", "b2", "a", "s") # noqa: F841 + e3 = tracker.record_blocker("t3", "b3", "a", "s") + + tracker.resolve(e1, "fixed") + tracker.resolve(e3, "workaround") + + assert len(tracker.get_active_blockers()) == 1 + assert tracker.get_active_blockers()[0].task_description == "t2" + +# ====================================================================== + + +class TestEscalationChain: + + def test_level_1_to_2_technical(self, tmp_project): + """Technical blocker escalates to architect.""" + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker( + "Deploy Cosmos DB", + "Premium tier required for multi-region", + "terraform-agent", + "build", + ) + + registry, architect, pm = _make_registry() + ctx = _make_context() + printed = [] + + result = tracker.escalate(entry, registry, ctx, printed.append) + + assert result["escalated"] is True + assert result["level"] == 2 + assert entry.escalation_level == 2 + architect.execute.assert_called_once() + pm.execute.assert_not_called() + + def test_level_1_to_2_scope(self, tmp_project): + """Scope blocker escalates to project-manager.""" + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker( + "Backlog items", + "Scope of feature is unclear", + "biz-analyst", + "design", + ) + + registry, architect, pm = _make_registry() + ctx = _make_context() + printed = [] + + result = tracker.escalate(entry, registry, ctx, printed.append) + + assert result["escalated"] is True + assert result["level"] == 2 + pm.execute.assert_called_once() + architect.execute.assert_not_called() + + @patch("azext_prototype.stages.escalation.EscalationTracker._escalate_to_web_search") + def test_level_2_to_3_web_search(self, mock_web, tmp_project): + """Level 2→3 triggers web search.""" + mock_web.return_value = "Found: Azure docs suggest..." + + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + entry.escalation_level = 2 # Already at level 2 + + registry, _, _ = _make_registry() + ctx = _make_context() + printed = [] + + result = tracker.escalate(entry, registry, ctx, printed.append) + + assert result["escalated"] is True + assert result["level"] == 3 + mock_web.assert_called_once() + + def test_level_3_to_4_human(self, tmp_project): + """Level 3→4 flags for human intervention.""" + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + entry.escalation_level = 3 # Already at level 3 + + registry, _, _ = _make_registry() + ctx = _make_context() + printed = [] + + result = tracker.escalate(entry, registry, ctx, printed.append) + + assert result["escalated"] is True + assert result["level"] == 4 + assert any("HUMAN INTERVENTION" in p for p in printed) + + def test_already_at_level_4_no_escalation(self, tmp_project): + """Cannot escalate past level 4.""" + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + entry.escalation_level = 4 + + registry, _, _ = _make_registry() + ctx = _make_context() + printed = [] + + result = tracker.escalate(entry, registry, ctx, printed.append) + + assert result["escalated"] is False + assert result["level"] == 4 + + def test_no_agent_available_for_escalation(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + + registry = MagicMock() + registry.find_by_capability.return_value = [] + ctx = _make_context() + printed = [] + + result = tracker.escalate(entry, registry, ctx, printed.append) + + assert result["level"] == 2 + assert "No cloud-architect available" in result["content"] + + def test_agent_escalation_failure(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + + registry, architect, _ = _make_registry() + architect.execute.side_effect = RuntimeError("AI crashed") + ctx = _make_context() + printed = [] + + result = tracker.escalate(entry, registry, ctx, printed.append) + + assert result["level"] == 2 + assert "failed" in result["content"].lower() + + def test_web_search_failure_graceful(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + entry.escalation_level = 2 + + printed = [] + + with patch("azext_prototype.stages.escalation.EscalationTracker._escalate_to_web_search") as mock_ws: + mock_ws.return_value = "Web search failed: connection error" + + registry, _, _ = _make_registry() + ctx = _make_context() + result = tracker.escalate(entry, registry, ctx, printed.append) + + assert result["level"] == 3 + assert "failed" in result["content"].lower() + +# ====================================================================== + + +class TestAutoEscalation: + + def test_timeout_triggers_escalation(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + + # Set last_escalated_at to 5 minutes ago + old_time = datetime.now(timezone.utc) - timedelta(minutes=5) + entry.last_escalated_at = old_time.isoformat() + + assert tracker.should_auto_escalate(entry, timeout_seconds=120) + + def test_not_yet_timed_out(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + + # Just created, so not timed out + assert not tracker.should_auto_escalate(entry, timeout_seconds=120) + + def test_resolved_stops_escalation(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + tracker.resolve(entry, "fixed") + + old_time = datetime.now(timezone.utc) - timedelta(minutes=5) + entry.last_escalated_at = old_time.isoformat() + + assert not tracker.should_auto_escalate(entry) + + def test_level_4_stops_escalation(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + entry.escalation_level = 4 + + old_time = datetime.now(timezone.utc) - timedelta(minutes=5) + entry.last_escalated_at = old_time.isoformat() + + assert not tracker.should_auto_escalate(entry) + + def test_invalid_timestamp_returns_false(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + entry = tracker.record_blocker("task", "blocked", "agent", "stage") + entry.last_escalated_at = "not-a-timestamp" + + assert not tracker.should_auto_escalate(entry) + +# ====================================================================== + + +class TestQARouterIntegration: + + def test_qa_router_records_blocker_on_undiagnosed(self, tmp_project): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.stages.qa_router import route_error_to_qa + + tracker = EscalationTracker(str(tmp_project)) + + # QA returns empty — undiagnosed + qa = MagicMock() + qa.execute.return_value = AIResponse(content="", model="gpt-4o", usage={}) + + ctx = _make_context() + + result = route_error_to_qa( + "Deployment failed", + "Deploy Stage 1", + qa, + ctx, + None, + lambda m: None, + escalation_tracker=tracker, + source_agent="terraform-agent", + source_stage="deploy", + ) + + assert result["diagnosed"] is False + assert len(tracker.get_active_blockers()) == 1 + blocker = tracker.get_active_blockers()[0] + assert blocker.source_agent == "terraform-agent" + assert blocker.source_stage == "deploy" + + def test_qa_router_no_tracker_no_error(self, tmp_project): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.stages.qa_router import route_error_to_qa + + qa = MagicMock() + qa.execute.return_value = AIResponse(content="", model="gpt-4o", usage={}) + + ctx = _make_context() + + # No escalation tracker — should not raise + result = route_error_to_qa( + "error", + "context", + qa, + ctx, + None, + lambda m: None, + escalation_tracker=None, + ) + + assert result["diagnosed"] is False + + @patch("azext_prototype.stages.qa_router._submit_knowledge") + def test_qa_router_diagnosed_no_blocker(self, mock_knowledge, tmp_project): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.stages.qa_router import route_error_to_qa + + tracker = EscalationTracker(str(tmp_project)) + + qa = MagicMock() + qa.execute.return_value = AIResponse(content="Root cause: X", model="gpt-4o", usage={}) + + ctx = _make_context() + + result = route_error_to_qa( + "error", + "context", + qa, + ctx, + None, + lambda m: None, + escalation_tracker=tracker, + ) + + assert result["diagnosed"] is True + # No blocker should be recorded when QA diagnoses successfully + assert len(tracker.get_active_blockers()) == 0 + + def test_build_session_has_escalation_tracker(self, tmp_project): + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.build_session import BuildSession + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + registry = MagicMock() + registry.find_by_capability.return_value = [] + + with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": "terraform", + "project.name": "test", + }.get(k, d) + mock_config.return_value.to_dict.return_value = { + "naming": {"strategy": "simple"}, + "project": {"name": "test"}, + } + session = BuildSession(ctx, registry) + + assert hasattr(session, "_escalation_tracker") + assert isinstance(session._escalation_tracker, EscalationTracker) + + def test_deploy_session_has_escalation_tracker(self, tmp_project): + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.deploy_session import DeploySession + from azext_prototype.stages.deploy_state import DeployState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + registry = MagicMock() + registry.find_by_capability.return_value = [] + + with patch("azext_prototype.stages.deploy_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": "terraform", + }.get(k, d) + session = DeploySession(ctx, registry, deploy_state=DeployState(str(tmp_project))) + + assert hasattr(session, "_escalation_tracker") + assert isinstance(session._escalation_tracker, EscalationTracker) + + def test_backlog_session_has_escalation_tracker(self, tmp_project): + from azext_prototype.agents.base import AgentContext + from azext_prototype.stages.backlog_session import BacklogSession + from azext_prototype.stages.backlog_state import BacklogState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + registry = MagicMock() + registry.find_by_capability.return_value = [] + + session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) + + assert hasattr(session, "_escalation_tracker") + assert isinstance(session._escalation_tracker, EscalationTracker) + +# ====================================================================== + + +class TestReportFormatting: + + def test_empty_report(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + report = tracker.format_escalation_report() + assert "No blockers recorded" in report + + def test_report_with_active_and_resolved(self, tmp_project): + tracker = EscalationTracker(str(tmp_project)) + e1 = tracker.record_blocker("Deploy Redis", "Premium needed", "tf", "build") # noqa: F841 + e2 = tracker.record_blocker("Deploy Cosmos", "Multi-region", "tf", "build") + tracker.resolve(e2, "Used single region") + + report = tracker.format_escalation_report() + + assert "Active Blockers (1)" in report + assert "Deploy Redis" in report + assert "Resolved (1)" in report + assert "Used single region" in report diff --git a/tests/test_intent.py b/tests/stages/test_intent.py similarity index 100% rename from tests/test_intent.py rename to tests/stages/test_intent.py diff --git a/tests/stages/test_knowledge_contributor.py b/tests/stages/test_knowledge_contributor.py index 20b25d9..f57e921 100644 --- a/tests/stages/test_knowledge_contributor.py +++ b/tests/stages/test_knowledge_contributor.py @@ -12,6 +12,10 @@ from unittest.mock import MagicMock, patch +_KC_MODULE = "azext_prototype.stages.knowledge_contributor" +_BP_MODULE = "azext_prototype.stages.backlog_push" +_CUSTOM_MODULE = "azext_prototype.custom" + # ====================================================================== # _namespace_to_filename # ====================================================================== @@ -441,3 +445,154 @@ def test_exception_caught_returns_none(self, mock_gap): result = submit_if_gap({"service": "x", "context": "y"}, MagicMock()) assert result is None + +# --- Additional imports from merged flat test --- +import pytest + + +# ====================================================================== +# Helpers +# ====================================================================== + + +def _make_finding(**overrides) -> dict: + """Create a minimal finding dict with optional overrides.""" + finding = { + "service": "cosmos-db", + "type": "Pitfall", + "file": "knowledge/services/cosmos-db.md", + "section": "Terraform Patterns", + "context": "RU throughput must be set to at least 400 for serverless", + "rationale": "Setting below 400 causes deployment failure", + "content": "minimum_throughput = 400", + "source": "QA diagnosis", + } + finding.update(overrides) + return finding + + +def _make_loader(service_content: str = "") -> MagicMock: + """Create a mock KnowledgeLoader that returns *service_content*.""" + loader = MagicMock() + loader.load_service.return_value = service_content + return loader + + +# ====================================================================== +# TestKnowledgeContributeCommand +# ====================================================================== + + +class TestKnowledgeContributeCommand: + """Tests for ``prototype_knowledge_contribute()`` CLI command.""" + + def test_draft_mode(self, project_with_config): + from azext_prototype.custom import prototype_knowledge_contribute + + cmd = MagicMock() + with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)): + result = prototype_knowledge_contribute( + cmd, + service="cosmos-db", + description="RU throughput must be >= 400", + draft=True, + json_output=True, + ) + + assert result["status"] == "draft" + assert "cosmos-db" in result["title"] + + def test_noninteractive_submit(self, project_with_config): + from azext_prototype.custom import prototype_knowledge_contribute + + cmd = MagicMock() + with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)), patch( + f"{_BP_MODULE}.subprocess.run" + ) as mock_auth, patch(f"{_KC_MODULE}.subprocess.run") as mock_create: + mock_auth.return_value = MagicMock(returncode=0) + mock_create.return_value = MagicMock( + returncode=0, + stdout="https://github.com/Azure/az-prototype/issues/55\n", + ) + + result = prototype_knowledge_contribute( + cmd, + service="cosmos-db", + description="RU throughput must be >= 400", + json_output=True, + ) + + assert result["status"] == "submitted" + assert result["url"] == "https://github.com/Azure/az-prototype/issues/55" + + def test_gh_not_authed_raises(self, project_with_config): + from knack.util import CLIError + + from azext_prototype.custom import prototype_knowledge_contribute + + cmd = MagicMock() + with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)), patch( + f"{_BP_MODULE}.subprocess.run" + ) as mock_auth: + mock_auth.return_value = MagicMock(returncode=1) + + with pytest.raises(CLIError, match="not authenticated"): + prototype_knowledge_contribute( + cmd, + service="cosmos-db", + description="RU throughput", + ) + + def test_file_input(self, project_with_config): + from azext_prototype.custom import prototype_knowledge_contribute + + # Create a finding file + finding_file = project_with_config / "finding.md" + finding_file.write_text( + "Service: cosmos-db\nContext: RU must be >= 400\nContent: min_ru = 400", + encoding="utf-8", + ) + + cmd = MagicMock() + with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)): + result = prototype_knowledge_contribute( + cmd, + file=str(finding_file), + draft=True, + json_output=True, + ) + + assert result["status"] == "draft" + + def test_file_not_found_raises(self, project_with_config): + from knack.util import CLIError + + from azext_prototype.custom import prototype_knowledge_contribute + + cmd = MagicMock() + with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)): + with pytest.raises(CLIError, match="not found"): + prototype_knowledge_contribute( + cmd, + file="/nonexistent/path/finding.md", + draft=True, + ) + + def test_contribution_type_forwarded(self, project_with_config): + from azext_prototype.custom import prototype_knowledge_contribute + + cmd = MagicMock() + with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)): + result = prototype_knowledge_contribute( + cmd, + service="redis", + description="Cache eviction pitfall", + contribution_type="Service pattern update", + section="Pitfalls", + draft=True, + json_output=True, + ) + + assert result["status"] == "draft" + assert "Service pattern update" in result["body"] + assert "Pitfalls" in result["body"] diff --git a/tests/stages/test_qa_router.py b/tests/stages/test_qa_router.py index 700c229..22abc14 100644 --- a/tests/stages/test_qa_router.py +++ b/tests/stages/test_qa_router.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """Tests for route_error_to_qa() — QA error routing. Tier 2: Conditional branches with multiple paths. @@ -492,3 +494,714 @@ def test_custom_max_error_chars(self, qa_agent, agent_context): # The error text in the task should be truncated to 50 chars assert "E" * 50 in task_arg assert "E" * 51 not in task_arg + + +# --- Additional imports from merged flat test --- +from azext_prototype.agents.base import AgentCapability +from azext_prototype.agents.base import AgentContext +from azext_prototype.ai.provider import AIResponse +from azext_prototype.stages.backlog_session import BacklogSession +from azext_prototype.stages.backlog_state import BacklogState +from azext_prototype.stages.build_session import BuildSession +from azext_prototype.stages.build_state import BuildState +from azext_prototype.stages.deploy_session import DeploySession +from azext_prototype.stages.deploy_state import DeployState +from azext_prototype.stages.discovery import DiscoverySession +import json + + +# ====================================================================== + + +def _make_response(content: str = "Root cause: X. Fix: do Y.") -> AIResponse: + return AIResponse(content=content, model="gpt-4o", usage={}) + +def _make_qa_agent(response: AIResponse | None = None, raises: Exception | None = None): + agent = MagicMock() + agent.name = "qa-engineer" + if raises: + agent.execute.side_effect = raises + else: + agent.execute.return_value = response or _make_response() + return agent + +def _make_context(): + return AgentContext( + project_config={"project": {"name": "test"}}, + project_dir="/tmp/test", + ai_provider=MagicMock(), + ) + +def _make_tracker(): + tracker = MagicMock() + return tracker + +# ====================================================================== + + +class TestRouteErrorToQA: + """Tests for route_error_to_qa().""" + + def test_qa_agent_available_diagnoses_error(self): + qa = _make_qa_agent() + ctx = _make_context() + tracker = _make_tracker() + printed = [] + + result = route_error_to_qa( + "Something broke", + "Build Stage 1", + qa, + ctx, + tracker, + printed.append, + ) + + assert result["diagnosed"] is True + assert result["content"] == "Root cause: X. Fix: do Y." + assert result["response"] is not None + qa.execute.assert_called_once() + tracker.record.assert_called_once() + + def test_qa_agent_none_returns_graceful_fallback(self): + ctx = _make_context() + printed = [] + + result = route_error_to_qa( + "Something broke", + "Build Stage 1", + None, + ctx, + None, + printed.append, + ) + + assert result["diagnosed"] is False + assert result["content"] == "Something broke" + assert result["response"] is None + assert len(printed) == 0 # no output when undiagnosed + + def test_string_error_input(self): + qa = _make_qa_agent() + ctx = _make_context() + printed = [] + + result = route_error_to_qa( + "Connection refused", + "Deploy Stage 2", + qa, + ctx, + None, + printed.append, + ) + + assert result["diagnosed"] is True + assert "Connection refused" in qa.execute.call_args[0][1] + + def test_exception_error_input(self): + qa = _make_qa_agent() + ctx = _make_context() + printed = [] + + result = route_error_to_qa( + ValueError("bad value"), + "Build Stage 3", + qa, + ctx, + None, + printed.append, + ) + + assert result["diagnosed"] is True + assert "bad value" in qa.execute.call_args[0][1] + + def test_long_error_truncated_at_max_chars(self): + qa = _make_qa_agent() + ctx = _make_context() + printed = [] + + long_error = "x" * 5000 + + result = route_error_to_qa( + long_error, + "Build Stage 1", + qa, + ctx, + None, + printed.append, + max_error_chars=100, + ) + + assert result["diagnosed"] is True + task_text = qa.execute.call_args[0][1] + # The error in the task should be truncated + assert "x" * 100 in task_text + assert "x" * 5000 not in task_text + + def test_qa_agent_raises_returns_undiagnosed(self): + qa = _make_qa_agent(raises=RuntimeError("QA crashed")) + ctx = _make_context() + printed = [] + + result = route_error_to_qa( + "Original error", + "Build Stage 1", + qa, + ctx, + None, + printed.append, + ) + + assert result["diagnosed"] is False + assert result["content"] == "Original error" + assert result["response"] is None + + def test_token_tracker_records_response(self): + qa = _make_qa_agent() + ctx = _make_context() + tracker = _make_tracker() + + route_error_to_qa( + "error", + "context", + qa, + ctx, + tracker, + lambda m: None, + ) + + tracker.record.assert_called_once() + + def test_token_tracker_none_does_not_crash(self): + qa = _make_qa_agent() + ctx = _make_context() + + result = route_error_to_qa( + "error", + "context", + qa, + ctx, + None, + lambda m: None, + ) + + assert result["diagnosed"] is True + + def test_print_fn_called_with_diagnosis(self): + qa = _make_qa_agent(_make_response("Fix: restart the service")) + ctx = _make_context() + printed = [] + + route_error_to_qa( + "error", + "context", + qa, + ctx, + None, + printed.append, + ) + + assert any("QA Diagnosis" in p for p in printed) + assert any("Fix: restart the service" in p for p in printed) + + def test_display_truncated_at_max_display_chars(self): + long_response = "a" * 3000 + qa = _make_qa_agent(_make_response(long_response)) + ctx = _make_context() + printed = [] + + route_error_to_qa( + "error", + "context", + qa, + ctx, + None, + printed.append, + max_display_chars=500, + ) + + # One of the printed lines should be truncated + display_lines = [p for p in printed if "a" in p] + assert any(len(p) <= 500 for p in display_lines) + + def test_no_ai_provider_returns_undiagnosed(self): + qa = _make_qa_agent() + ctx = _make_context() + ctx.ai_provider = None + printed = [] + + result = route_error_to_qa( + "error", + "context", + qa, + ctx, + None, + printed.append, + ) + + assert result["diagnosed"] is False + + def test_empty_error_uses_unknown(self): + qa = _make_qa_agent() + ctx = _make_context() + + result = route_error_to_qa( + "", + "context", + qa, + ctx, + None, + lambda m: None, + ) + + assert result["diagnosed"] is True + # Should have used "Unknown error" + task_text = qa.execute.call_args[0][1] + assert "Unknown error" in task_text + + def test_none_error_uses_unknown(self): + qa = _make_qa_agent() + ctx = _make_context() + + result = route_error_to_qa( + None, + "context", + qa, + ctx, + None, + lambda m: None, + ) + + assert result["diagnosed"] is True + task_text = qa.execute.call_args[0][1] + assert "Unknown error" in task_text + + def test_qa_returns_empty_content(self): + qa = _make_qa_agent(_make_response("")) + ctx = _make_context() + printed = [] + + result = route_error_to_qa( + "error", + "context", + qa, + ctx, + None, + printed.append, + ) + + assert result["diagnosed"] is False + + @patch("azext_prototype.stages.qa_router._submit_knowledge") + def test_knowledge_contribution_attempted(self, mock_submit): + qa = _make_qa_agent() + ctx = _make_context() + + route_error_to_qa( + "error", + "Build Stage 1", + qa, + ctx, + None, + lambda m: None, + services=["key-vault"], + ) + + mock_submit.assert_called_once() + args = mock_submit.call_args[0] + assert args[0] == "Root cause: X. Fix: do Y." + assert args[1] == "Build Stage 1" + assert args[2] == ["key-vault"] + + @patch("azext_prototype.stages.qa_router._submit_knowledge", side_effect=Exception("boom")) + def test_knowledge_failure_swallowed(self, mock_submit): + qa = _make_qa_agent() + ctx = _make_context() + + # Should not raise + result = route_error_to_qa( + "error", + "context", + qa, + ctx, + None, + lambda m: None, + services=["svc"], + ) + + assert result["diagnosed"] is True + + def test_services_none_no_knowledge_submitted(self): + qa = _make_qa_agent() + ctx = _make_context() + + with patch("azext_prototype.stages.qa_router._submit_knowledge") as mock_submit: + route_error_to_qa( + "error", + "context", + qa, + ctx, + None, + lambda m: None, + ) + + mock_submit.assert_called_once() + # services should be None + assert mock_submit.call_args[0][2] is None + + def test_context_label_in_task_prompt(self): + qa = _make_qa_agent() + ctx = _make_context() + + route_error_to_qa( + "error", + "Deploy Stage 5: Redis Cache", + qa, + ctx, + None, + lambda m: None, + ) + + task_text = qa.execute.call_args[0][1] + assert "Deploy Stage 5: Redis Cache" in task_text + + def test_token_tracker_record_failure_swallowed(self): + qa = _make_qa_agent() + ctx = _make_context() + tracker = MagicMock() + tracker.record.side_effect = Exception("tracker boom") + + # Should not raise + result = route_error_to_qa( + "error", + "context", + qa, + ctx, + tracker, + lambda m: None, + ) + + assert result["diagnosed"] is True + +# ====================================================================== + + +class TestBuildSessionQARouting: + """Test that build session routes errors through qa_router.""" + + def _make_session(self, tmp_project, qa_agent=None, response=None): + from azext_prototype.stages.build_session import BuildSession + from azext_prototype.stages.build_state import BuildState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + registry = MagicMock() + + # IaC agent that fails + iac_agent = MagicMock() + iac_agent.name = "terraform-agent" + if response is not None: + iac_agent.execute.return_value = response + else: + iac_agent.execute.side_effect = RuntimeError("AI exploded") + + doc_agent = MagicMock() + doc_agent.name = "doc-agent" + doc_agent.execute.return_value = _make_response("# Docs") + + qa = qa_agent or _make_qa_agent() + + def find_by_cap(cap): + from azext_prototype.agents.base import AgentCapability + + if cap == AgentCapability.TERRAFORM: + return [iac_agent] + if cap == AgentCapability.QA: + return [qa] + if cap == AgentCapability.DOCUMENT: + return [doc_agent] + if cap == AgentCapability.ARCHITECT: + return [] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + build_state = BuildState(str(tmp_project)) + build_state.set_deployment_plan( + [ + { + "stage": 1, + "name": "Foundation", + "category": "infra", + "dir": "concept/infra/terraform/stage-1-foundation", + "services": [{"name": "key-vault", "computed_name": "kv-1", "resource_type": "", "sku": ""}], + "status": "pending", + "files": [], + }, + ] + ) + + with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": "terraform", + "project.name": "test", + }.get(k, d) + mock_config.return_value.to_dict.return_value = { + "naming": {"strategy": "simple"}, + "project": {"name": "test"}, + } + session = BuildSession(ctx, registry, build_state=build_state) + + return session, qa + + @patch("azext_prototype.stages.qa_router._submit_knowledge") + def test_stage_generation_failure_routes_to_qa(self, mock_knowledge, tmp_project): + session, qa = self._make_session(tmp_project) + printed = [] + + session.run( + design={"architecture": "Simple web app"}, + input_fn=lambda p: "done", + print_fn=printed.append, + ) + + qa.execute.assert_called() + assert any("QA Diagnosis" in p for p in printed) + + @patch("azext_prototype.stages.qa_router._submit_knowledge") + def test_empty_response_routes_to_qa(self, mock_knowledge, tmp_project): + empty_resp = AIResponse(content="", model="gpt-4o", usage={}) + session, qa = self._make_session(tmp_project, response=empty_resp) + printed = [] + + session.run( + design={"architecture": "Simple web app"}, + input_fn=lambda p: "done", + print_fn=printed.append, + ) + + # QA should be called for empty response + qa.execute.assert_called() + +# ====================================================================== + + +class TestDiscoveryQARouting: + """Test that discovery routes non-vision errors through qa_router.""" + + @patch("azext_prototype.stages.qa_router._submit_knowledge") + def test_non_vision_error_routes_to_qa(self, mock_knowledge, tmp_project): + from azext_prototype.stages.discovery import DiscoverySession + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + biz_agent = MagicMock() + biz_agent.name = "biz-analyst" + biz_agent.capabilities = [] + biz_agent._temperature = 0.5 + biz_agent._max_tokens = 8192 + biz_agent.get_system_messages.return_value = [] + + qa = _make_qa_agent() + + registry = MagicMock() + + from azext_prototype.agents.base import AgentCapability + + def find_by_cap(cap): + if cap == AgentCapability.BIZ_ANALYSIS: + return [biz_agent] + if cap == AgentCapability.QA: + return [qa] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + ctx.ai_provider.chat.side_effect = RuntimeError("API error") + + session = DiscoverySession(ctx, registry) + + with pytest.raises(RuntimeError, match="API error"): + session.run( + seed_context="test", + input_fn=lambda p: "done", + print_fn=lambda m: None, + ) + + # QA should have been called for the error diagnosis + qa.execute.assert_called_once() + +# ====================================================================== + + +class TestBacklogQARouting: + """Test that backlog session routes errors through qa_router.""" + + def _make_session(self, tmp_project, items_response="[]"): + from azext_prototype.stages.backlog_session import BacklogSession + from azext_prototype.stages.backlog_state import BacklogState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + pm = MagicMock() + pm.name = "project-manager" + pm.get_system_messages.return_value = [] + qa = _make_qa_agent() + + registry = MagicMock() + from azext_prototype.agents.base import AgentCapability + + def find_by_cap(cap): + if cap == AgentCapability.BACKLOG_GENERATION: + return [pm] + if cap == AgentCapability.QA: + return [qa] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + ctx.ai_provider.chat.return_value = AIResponse( + content=items_response, + model="gpt-4o", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + + session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) + return session, qa, ctx + + @patch("azext_prototype.stages.qa_router._submit_knowledge") + def test_empty_parse_triggers_qa(self, mock_knowledge, tmp_project): + session, qa, ctx = self._make_session(tmp_project, items_response="not valid json at all") + printed = [] + + result = session.run( + design_context="web app architecture", + input_fn=lambda p: "done", + print_fn=printed.append, + ) + + qa.execute.assert_called() + assert result.cancelled is True + + @patch("azext_prototype.stages.qa_router._submit_knowledge") + @patch("azext_prototype.stages.backlog_session.check_gh_auth", return_value=True) + @patch("azext_prototype.stages.backlog_session.push_github_issue") + def test_push_error_triggers_qa(self, mock_push, mock_auth, mock_knowledge, tmp_project): + import json + + items = [{"epic": "Infra", "title": "Setup VNet", "description": "Create VNet", "tasks": [], "effort": "M"}] + session, qa, ctx = self._make_session(tmp_project, items_response=json.dumps(items)) + + mock_push.return_value = {"error": "gh: auth required"} + + printed = [] + session.run( + design_context="web app", + provider="github", + org="myorg", + project="myrepo", + quick=True, + input_fn=lambda p: "y", + print_fn=printed.append, + ) + + qa.execute.assert_called() + +# ====================================================================== + + +class TestDeploySessionRefactoredQA: + """Test that refactored deploy session still works correctly.""" + + def test_handle_deploy_failure_uses_qa_router(self, tmp_project): + from azext_prototype.stages.deploy_session import DeploySession + from azext_prototype.stages.deploy_state import DeployState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + qa = _make_qa_agent(_make_response("Root cause: missing permissions")) + registry = MagicMock() + from azext_prototype.agents.base import AgentCapability + + def find_by_cap(cap): + if cap == AgentCapability.QA: + return [qa] + return [] + + registry.find_by_capability.side_effect = find_by_cap + + with patch("azext_prototype.stages.deploy_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": "terraform", + }.get(k, d) + session = DeploySession(ctx, registry, deploy_state=DeployState(str(tmp_project))) + + printed = [] + stage = {"stage": 1, "name": "Foundation", "services": [{"name": "rg"}]} + result = {"error": "Deployment failed: access denied"} + + session._handle_deploy_failure( + stage, + result, + False, + printed.append, + lambda p: "", + ) + + qa.execute.assert_called_once() + assert any("QA Diagnosis" in p for p in printed) + assert any("missing permissions" in p for p in printed) + assert any("Options:" in p for p in printed) + + def test_handle_deploy_failure_no_qa_shows_error(self, tmp_project): + from azext_prototype.stages.deploy_session import DeploySession + from azext_prototype.stages.deploy_state import DeployState + + ctx = AgentContext( + project_config={"project": {"name": "test", "location": "eastus"}}, + project_dir=str(tmp_project), + ai_provider=MagicMock(), + ) + + registry = MagicMock() + registry.find_by_capability.return_value = [] + + with patch("azext_prototype.stages.deploy_session.ProjectConfig") as mock_config: + mock_config.return_value.load.return_value = None + mock_config.return_value.get.side_effect = lambda k, d=None: { + "project.iac_tool": "terraform", + }.get(k, d) + session = DeploySession(ctx, registry, deploy_state=DeployState(str(tmp_project))) + + printed = [] + stage = {"stage": 1, "name": "Foundation", "services": []} + result = {"error": "access denied"} + + session._handle_deploy_failure( + stage, + result, + False, + printed.append, + lambda p: "", + ) + + assert any("Error:" in p for p in printed) + assert any("Options:" in p for p in printed) diff --git a/tests/test_stages.py b/tests/stages/test_stages.py similarity index 66% rename from tests/test_stages.py rename to tests/stages/test_stages.py index 299b59a..db42e97 100644 --- a/tests/test_stages.py +++ b/tests/stages/test_stages.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock, patch +import pytest + from azext_prototype.stages.base import StageGuard, StageState from azext_prototype.stages.guards import ( _check_az_logged_in, @@ -1036,3 +1038,558 @@ def test_match_templates_empty_architecture(self): config = MagicMock() result = stage._match_templates({"architecture": ""}, config) assert result == [] + + +# --- Additional imports from merged flat test --- +from knack.util import CLIError + +from azext_prototype.agents.base import AgentContext +from azext_prototype.agents.registry import AgentRegistry +from azext_prototype.ai.provider import AIResponse +from azext_prototype.stages.build_session import BuildResult +from azext_prototype.stages.deploy_helpers import check_az_login +from azext_prototype.stages.deploy_helpers import deploy_app_stage +from azext_prototype.stages.deploy_helpers import deploy_terraform +from azext_prototype.stages.deploy_helpers import get_current_subscription + + +# ====================================================================== + + +class TestDeployStageExecution: + """Test DeployStage orchestration and deploy_helpers functions.""" + + def _make_stage(self): + from azext_prototype.stages.deploy_stage import DeployStage + + return DeployStage() + + def test_deploy_guards(self): + stage = self._make_stage() + guards = stage.get_guards() + names = [g.name for g in guards] + assert "project_initialized" in names + assert "build_complete" in names + assert "az_logged_in" in names + + @patch("subprocess.run") + def test_check_az_login_true(self, mock_run): + from azext_prototype.stages.deploy_helpers import check_az_login + + mock_run.return_value = MagicMock(returncode=0) + assert check_az_login() is True + + @patch("subprocess.run") + def test_check_az_login_false(self, mock_run): + from azext_prototype.stages.deploy_helpers import check_az_login + + mock_run.return_value = MagicMock(returncode=1) + assert check_az_login() is False + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_check_az_login_not_installed(self, mock_run): + from azext_prototype.stages.deploy_helpers import check_az_login + + assert check_az_login() is False + + @patch("subprocess.run") + def test_get_current_subscription(self, mock_run): + from azext_prototype.stages.deploy_helpers import get_current_subscription + + mock_run.return_value = MagicMock(returncode=0, stdout="abc-123\n") + result = get_current_subscription() + assert result == "abc-123" + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_get_current_subscription_not_installed(self, mock_run): + from azext_prototype.stages.deploy_helpers import get_current_subscription + + assert get_current_subscription() == "" + + @patch("subprocess.run") + def test_deploy_terraform_success(self, mock_run, tmp_path): + from azext_prototype.stages.deploy_helpers import deploy_terraform + + infra_dir = tmp_path / "tf" + infra_dir.mkdir() + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = deploy_terraform(infra_dir, "sub-123") + assert result["status"] == "deployed" + + @patch("subprocess.run") + def test_deploy_terraform_failure(self, mock_run, tmp_path): + from azext_prototype.stages.deploy_helpers import deploy_terraform + + infra_dir = tmp_path / "tf" + infra_dir.mkdir() + mock_run.return_value = MagicMock(returncode=1, stderr="init failed", stdout="") + + result = deploy_terraform(infra_dir, "sub-123") + assert result["status"] == "failed" + + @patch("subprocess.run") + def test_deploy_bicep_failure(self, mock_run, tmp_path): + from azext_prototype.stages.deploy_helpers import deploy_bicep + + (tmp_path / "main.bicep").write_text("resource x 'y' = {}", encoding="utf-8") + mock_run.return_value = MagicMock(returncode=1, stderr="Deployment failed", stdout="") + + result = deploy_bicep(tmp_path, "sub-123", "my-rg") + assert result["status"] == "failed" + + def test_deploy_app_stage_with_deploy_script(self, tmp_path): + from azext_prototype.stages.deploy_helpers import deploy_app_stage + + app_dir = tmp_path / "app" + app_dir.mkdir() + (app_dir / "deploy.sh").write_text("echo deployed", encoding="utf-8") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = deploy_app_stage(app_dir, "sub-123", "my-rg") + assert result["status"] == "deployed" + + def test_deploy_app_stage_sub_apps(self, tmp_path): + from azext_prototype.stages.deploy_helpers import deploy_app_stage + + stage_dir = tmp_path / "stage" + stage_dir.mkdir() + backend = stage_dir / "backend" + backend.mkdir() + (backend / "deploy.sh").write_text("echo ok", encoding="utf-8") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = deploy_app_stage(stage_dir, "sub-123", "my-rg") + assert result["status"] == "deployed" + assert "backend" in result["apps"] + + def test_deploy_app_stage_no_scripts(self, tmp_path): + from azext_prototype.stages.deploy_helpers import deploy_app_stage + + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + result = deploy_app_stage(empty_dir, "sub-123", "my-rg") + assert result["status"] == "skipped" + + @patch("subprocess.run") + def test_whatif_bicep_no_files(self, mock_run, tmp_path): + from azext_prototype.stages.deploy_helpers import whatif_bicep + + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + result = whatif_bicep(empty_dir, "sub-123", "my-rg") + assert result["status"] == "skipped" + + @patch("subprocess.run") + def test_whatif_bicep_no_rg_skips(self, mock_run, tmp_path): + from azext_prototype.stages.deploy_helpers import whatif_bicep + + (tmp_path / "main.bicep").write_text("resource x 'y' = {}", encoding="utf-8") + result = whatif_bicep(tmp_path, "sub-123", "") + assert result["status"] == "skipped" + + def test_get_deploy_location_main_params(self, tmp_path): + from azext_prototype.stages.deploy_helpers import get_deploy_location + + (tmp_path / "main.parameters.json").write_text( + '{"parameters": {"location": {"value": "northeurope"}}}', encoding="utf-8" + ) + result = get_deploy_location(tmp_path) + assert result == "northeurope" + + def test_get_deploy_location_string_value(self, tmp_path): + from azext_prototype.stages.deploy_helpers import get_deploy_location + + (tmp_path / "parameters.json").write_text('{"location": "uksouth"}', encoding="utf-8") + result = get_deploy_location(tmp_path) + assert result == "uksouth" + + def test_execute_status(self, project_with_build, mock_agent_context, populated_registry): + """Deploy with --status shows state and returns.""" + stage = self._make_stage() + stage.get_guards = lambda: [] + mock_agent_context.project_dir = str(project_with_build) + + result = stage.execute( + mock_agent_context, + populated_registry, + status=True, + ) + assert result["status"] == "status_displayed" + + def test_execute_reset(self, project_with_build, mock_agent_context, populated_registry): + """Deploy with --reset clears state and returns.""" + stage = self._make_stage() + stage.get_guards = lambda: [] + mock_agent_context.project_dir = str(project_with_build) + + result = stage.execute( + mock_agent_context, + populated_registry, + reset=True, + ) + assert result["status"] == "reset" + +# ====================================================================== + + +class TestBuildStageExecution: + """Test BuildStage methods.""" + + def _make_stage(self): + from azext_prototype.stages.build_stage import BuildStage + + return BuildStage() + + def test_build_guards(self): + stage = self._make_stage() + guards = stage.get_guards() + names = [g.name for g in guards] + assert "project_initialized" in names + assert "discovery_complete" in names + assert "design_complete" in names + + def test_load_design(self, project_with_design): + stage = self._make_stage() + design = stage._load_design(str(project_with_design)) + assert "architecture" in design + + def test_load_design_missing(self, tmp_project): + stage = self._make_stage() + result = stage._load_design(str(tmp_project)) + assert result == {} + + def test_execute_no_design_raises(self, project_with_config, mock_agent_context, populated_registry): + stage = self._make_stage() + stage.get_guards = lambda: [] + mock_agent_context.project_dir = str(project_with_config) + + with pytest.raises(CLIError, match="No architecture design"): + stage.execute(mock_agent_context, populated_registry) + + def test_execute_dry_run(self, project_with_design, mock_agent_context, populated_registry): + stage = self._make_stage() + stage.get_guards = lambda: [] + mock_agent_context.project_dir = str(project_with_design) + mock_agent_context.ai_provider.chat.return_value = AIResponse(content="Generated code", model="gpt-4o") + + result = stage.execute(mock_agent_context, populated_registry, scope="docs", dry_run=True) + assert result["status"] == "dry-run" + + def test_execute_all_scopes_dry_run(self, project_with_design, mock_agent_context, populated_registry): + stage = self._make_stage() + stage.get_guards = lambda: [] + mock_agent_context.project_dir = str(project_with_design) + + result = stage.execute(mock_agent_context, populated_registry, scope="all", dry_run=True) + assert result["status"] == "dry-run" + assert result["scope"] == "all" + + @patch("azext_prototype.stages.build_stage.BuildSession") + def test_execute_interactive_delegates_to_session( + self, mock_session_cls, project_with_design, mock_agent_context, populated_registry + ): + stage = self._make_stage() + stage.get_guards = lambda: [] + mock_agent_context.project_dir = str(project_with_design) + + mock_result = BuildResult( + files_generated=["main.tf"], + deployment_stages=[{"stage": 1, "name": "Foundation"}], + policy_overrides=[], + resources=[{"resourceType": "Microsoft.Compute/virtualMachines", "sku": "Standard_B2s"}], + review_accepted=True, + cancelled=False, + ) + mock_session_cls.return_value.run.return_value = mock_result + + result = stage.execute(mock_agent_context, populated_registry, scope="all", dry_run=False) + assert result["status"] == "success" + assert result["scope"] == "all" + assert result["files_generated"] == ["main.tf"] + mock_session_cls.return_value.run.assert_called_once() + +# ====================================================================== + + +class TestInitStageExecution: + """Test InitStage methods.""" + + def _make_stage(self): + from azext_prototype.stages.init_stage import InitStage + + return InitStage() + + def test_init_guards(self): + """Init has no unconditional guards; gh check is conditional inside execute().""" + stage = self._make_stage() + guards = stage.get_guards() + assert len(guards) == 0 + + @patch("subprocess.run") + def test_check_gh_true(self, mock_run): + stage = self._make_stage() + mock_run.return_value = MagicMock(returncode=0) + assert stage._check_gh() is True + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_check_gh_false(self, mock_run): + stage = self._make_stage() + assert stage._check_gh() is False + + def test_create_scaffold(self, tmp_path): + stage = self._make_stage() + project_dir = tmp_path / "my-project" + stage._create_scaffold(project_dir) + + assert (project_dir / "concept" / "docs").is_dir() + assert (project_dir / ".prototype" / "agents").is_dir() + # infra, apps, db dirs are NOT created at init — only during build + assert not (project_dir / "concept" / "apps").exists() + assert not (project_dir / "concept" / "infra").exists() + assert not (project_dir / "concept" / "db").exists() + + def test_create_gitignore(self, tmp_path): + stage = self._make_stage() + stage._create_gitignore(tmp_path) + gi = tmp_path / ".gitignore" + assert gi.exists() + content = gi.read_text() + assert ".terraform/" in content + assert "__pycache__/" in content + + def test_create_gitignore_no_overwrite(self, tmp_path): + stage = self._make_stage() + gi = tmp_path / ".gitignore" + gi.write_text("custom content", encoding="utf-8") + stage._create_gitignore(tmp_path) + assert gi.read_text() == "custom content" + + @patch("azext_prototype.auth.copilot_license.CopilotLicenseValidator") + @patch("azext_prototype.auth.github_auth.GitHubAuthManager") + @patch("azext_prototype.stages.init_stage.InitStage._check_gh", return_value=True) + def test_execute_full(self, mock_gh, mock_auth_cls, mock_lic_cls, tmp_path): + stage = self._make_stage() + stage.get_guards = lambda: [] + + mock_auth = MagicMock() + mock_auth.ensure_authenticated.return_value = {"login": "devuser"} + mock_auth_cls.return_value = mock_auth + mock_lic = MagicMock() + mock_lic.validate_license.return_value = {"plan": "business"} + mock_lic_cls.return_value = mock_lic + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + + ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) + registry = AgentRegistry() + + out = tmp_path / "test-proj" + result = stage.execute( + ctx, + registry, + name="test-proj", + location="westus2", + iac_tool="bicep", + ai_provider="github-models", + output_dir=str(out), + ) + assert result["status"] == "success" + assert (out / "prototype.yaml").exists() + + @patch("azext_prototype.auth.copilot_license.CopilotLicenseValidator") + @patch("azext_prototype.auth.github_auth.GitHubAuthManager") + @patch("azext_prototype.stages.init_stage.InitStage._check_gh", return_value=True) + def test_execute_license_failure_continues(self, mock_gh, mock_auth_cls, mock_lic_cls, tmp_path): + """License validation failure should warn but continue.""" + stage = self._make_stage() + stage.get_guards = lambda: [] + + mock_auth = MagicMock() + mock_auth.ensure_authenticated.return_value = {"login": "devuser"} + mock_auth_cls.return_value = mock_auth + mock_lic = MagicMock() + mock_lic.validate_license.side_effect = CLIError("No license") + mock_lic_cls.return_value = mock_lic + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + + ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) + registry = AgentRegistry() + + result = stage.execute( + ctx, + registry, + name="lic-test", + location="eastus", + ai_provider="github-models", + output_dir=str(tmp_path / "lic-test"), + ) + assert result["status"] == "success" + assert result["copilot_license"]["status"] == "unverified" + + def test_execute_no_name_raises(self, tmp_path): + stage = self._make_stage() + stage.get_guards = lambda: [] + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + + ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) + registry = AgentRegistry() + + with pytest.raises(CLIError, match="Project name"): + stage.execute(ctx, registry, name="", output_dir=str(tmp_path / "empty-name")) + + def test_execute_no_location_raises(self, tmp_path): + stage = self._make_stage() + stage.get_guards = lambda: [] + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + + ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) + registry = AgentRegistry() + + with pytest.raises(CLIError, match="region is required"): + stage.execute( + ctx, + registry, + name="test-proj", + location=None, + output_dir=str(tmp_path / "test-proj"), + ) + + def test_execute_azure_openai_skips_auth(self, tmp_path): + """azure-openai provider should skip GitHub auth entirely.""" + stage = self._make_stage() + stage.get_guards = lambda: [] + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + + ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) + registry = AgentRegistry() + + result = stage.execute( + ctx, + registry, + name="aoai-test", + location="eastus", + ai_provider="azure-openai", + output_dir=str(tmp_path / "aoai-test"), + ) + assert result["status"] == "success" + assert result["github_user"] is None + assert "copilot_license" not in result + + def test_execute_environment_stored(self, tmp_path): + """--environment should be persisted in config.""" + stage = self._make_stage() + stage.get_guards = lambda: [] + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.config import ProjectConfig + + ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) + registry = AgentRegistry() + + out = tmp_path / "env-test" + stage.execute( + ctx, + registry, + name="env-test", + location="westus2", + ai_provider="azure-openai", + environment="prod", + output_dir=str(out), + ) + config = ProjectConfig(str(out)) + config.load() + assert config.get("project.environment") == "prod" + assert config.get("naming.env") == "prd" + assert config.get("naming.zone_id") == "zp" + + def test_execute_model_override(self, tmp_path): + """Explicit --model should override provider default.""" + stage = self._make_stage() + stage.get_guards = lambda: [] + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.config import ProjectConfig + + ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) + registry = AgentRegistry() + + out = tmp_path / "model-test" + stage.execute( + ctx, + registry, + name="model-test", + location="eastus", + ai_provider="azure-openai", + model="gpt-4o-mini", + output_dir=str(out), + ) + config = ProjectConfig(str(out)) + config.load() + assert config.get("ai.model") == "gpt-4o-mini" + + def test_execute_idempotency_cancel(self, tmp_path): + """Existing project + user declining should cancel.""" + stage = self._make_stage() + stage.get_guards = lambda: [] + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + + # Pre-create project directory with config + proj = tmp_path / "idem-test" + proj.mkdir() + (proj / "prototype.yaml").write_text("project:\n name: old\n") + + ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) + registry = AgentRegistry() + + with patch("builtins.input", return_value="n"): + result = stage.execute( + ctx, + registry, + name="idem-test", + location="eastus", + ai_provider="azure-openai", + output_dir=str(proj), + ) + assert result["status"] == "cancelled" + + def test_execute_marks_init_complete(self, tmp_path): + """Init stage should set stages.init.completed and timestamp.""" + stage = self._make_stage() + stage.get_guards = lambda: [] + + from azext_prototype.agents.base import AgentContext + from azext_prototype.agents.registry import AgentRegistry + from azext_prototype.config import ProjectConfig + + ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) + registry = AgentRegistry() + + out = tmp_path / "complete-test" + stage.execute( + ctx, + registry, + name="complete-test", + location="eastus", + ai_provider="azure-openai", + output_dir=str(out), + ) + config = ProjectConfig(str(out)) + config.load() + assert config.get("stages.init.completed") is True + assert config.get("stages.init.timestamp") is not None diff --git a/tests/test_template_compliance.py b/tests/templates/test_template_compliance.py similarity index 99% rename from tests/test_template_compliance.py rename to tests/templates/test_template_compliance.py index f32e57c..2507d1d 100644 --- a/tests/test_template_compliance.py +++ b/tests/templates/test_template_compliance.py @@ -25,9 +25,9 @@ # Helpers # ------------------------------------------------------------------ # -BUILTIN_DIR = Path(__file__).resolve().parent.parent / "azext_prototype" / "templates" / "workloads" +BUILTIN_DIR = Path(__file__).resolve().parent.parent.parent / "azext_prototype" / "templates" / "workloads" -BUILTIN_POLICY_DIR = Path(__file__).resolve().parent.parent / "azext_prototype" / "governance" / "policies" +BUILTIN_POLICY_DIR = Path(__file__).resolve().parent.parent.parent / "azext_prototype" / "governance" / "policies" def _write_yaml(dest: Path, data: dict | list | str) -> Path: diff --git a/tests/test_templates.py b/tests/templates/test_templates.py similarity index 99% rename from tests/test_templates.py rename to tests/templates/test_templates.py index f40f80a..dae5849 100644 --- a/tests/test_templates.py +++ b/tests/templates/test_templates.py @@ -17,7 +17,7 @@ # Helpers # ------------------------------------------------------------------ # -BUILTIN_DIR = Path(__file__).resolve().parent.parent / "azext_prototype" / "templates" / "workloads" +BUILTIN_DIR = Path(__file__).resolve().parent.parent.parent / "azext_prototype" / "templates" / "workloads" EXPECTED_BUILTIN_NAMES = sorted( [ @@ -669,7 +669,7 @@ def test_sql_auto_pause(self): class TestTemplateSchema: """Verify the JSON schema file exists and is valid JSON.""" - SCHEMA_PATH = Path(__file__).resolve().parent.parent / "azext_prototype" / "templates" / "template.schema.json" + SCHEMA_PATH = Path(__file__).resolve().parent.parent.parent / "azext_prototype" / "templates" / "template.schema.json" def test_schema_file_exists(self): assert self.SCHEMA_PATH.exists() diff --git a/tests/test_build_session.py b/tests/test_build_session.py deleted file mode 100644 index 54bacbf..0000000 --- a/tests/test_build_session.py +++ /dev/null @@ -1,4693 +0,0 @@ -"""Tests for BuildState, PolicyResolver, BuildSession, and multi-resource telemetry. - -Covers all new build-stage modules introduced in the interactive build overhaul. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import yaml - -from azext_prototype.agents.base import AgentCapability, AgentContext -from azext_prototype.ai.provider import AIResponse - -# ====================================================================== -# Helpers -# ====================================================================== - - -def _make_response(content: str = "Mock response", finish_reason: str = "stop") -> AIResponse: - return AIResponse(content=content, model="gpt-4o", usage={}, finish_reason=finish_reason) - - -def _make_file_response(filename: str = "main.tf", code: str = "# placeholder") -> AIResponse: - """Return an AIResponse whose content has a fenced file block.""" - return AIResponse( - content=f"Here is the code:\n\n```{filename}\n{code}\n```\n", - model="gpt-4o", - usage={}, - ) - - -# ====================================================================== -# BuildState tests -# ====================================================================== - - -class TestBuildState: - - def test_default_state_structure(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - state = bs.state - assert isinstance(state["templates_used"], list) - assert state["iac_tool"] == "terraform" - assert state["deployment_stages"] == [] - assert state["policy_checks"] == [] - assert state["policy_overrides"] == [] - assert state["files_generated"] == [] - assert state["resources"] == [] - assert state["_metadata"]["iteration"] == 0 - - def test_load_save_roundtrip(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs._state["templates_used"] = ["web-app"] - bs._state["iac_tool"] = "bicep" - bs.save() - - bs2 = BuildState(str(tmp_project)) - loaded = bs2.load() - assert loaded["templates_used"] == ["web-app"] - assert loaded["iac_tool"] == "bicep" - - def test_set_deployment_plan(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - stages = [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [ - { - "name": "key-vault", - "computed_name": "zd-kv-api-dev-eus", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - }, - ], - "status": "pending", - "dir": "concept/infra/terraform/stage-1-foundation", - "files": [], - }, - ] - bs.set_deployment_plan(stages) - - assert len(bs.state["deployment_stages"]) == 1 - assert bs.state["deployment_stages"][0]["services"][0]["computed_name"] == "zd-kv-api-dev-eus" - # Resources should be rebuilt - assert len(bs.state["resources"]) == 1 - assert bs.state["resources"][0]["resourceType"] == "Microsoft.KeyVault/vaults" - - def test_mark_stage_generated(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "pending", - "dir": "", - "files": [], - }, - ] - ) - - bs.mark_stage_generated(1, ["main.tf", "variables.tf"], "terraform-agent") - - stage = bs.get_stage(1) - assert stage["status"] == "generated" - assert stage["files"] == ["main.tf", "variables.tf"] - assert len(bs.state["generation_log"]) == 1 - assert bs.state["generation_log"][0]["agent"] == "terraform-agent" - assert "main.tf" in bs.state["files_generated"] - - def test_mark_stage_accepted(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - ] - ) - bs.mark_stage_accepted(1) - assert bs.get_stage(1)["status"] == "accepted" - - def test_add_policy_override(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.add_policy_override("managed-identity", "Using connection string for legacy service") - - assert len(bs.state["policy_overrides"]) == 1 - assert bs.state["policy_overrides"][0]["rule_id"] == "managed-identity" - - def test_get_pending_stages(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.set_deployment_plan( - [ - { - "stage": 1, - "name": "A", - "capability": "infra", - "services": [], - "status": "pending", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "B", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - { - "stage": 3, - "name": "C", - "capability": "app", - "services": [], - "status": "pending", - "dir": "", - "files": [], - }, - ] - ) - - pending = bs.get_pending_stages() - assert len(pending) == 2 - assert pending[0]["stage"] == 1 - assert pending[1]["stage"] == 3 - - def test_get_all_resources(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [ - { - "name": "kv", - "computed_name": "kv-1", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - }, - { - "name": "id", - "computed_name": "id-1", - "resource_type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "sku": "", - }, - ], - "status": "pending", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "Data", - "capability": "data", - "services": [ - { - "name": "sql", - "computed_name": "sql-1", - "resource_type": "Microsoft.Sql/servers", - "sku": "serverless", - }, - ], - "status": "pending", - "dir": "", - "files": [], - }, - ] - ) - - resources = bs.get_all_resources() - assert len(resources) == 3 - types = {r["resourceType"] for r in resources} - assert "Microsoft.KeyVault/vaults" in types - assert "Microsoft.Sql/servers" in types - - def test_format_build_report(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs._state["templates_used"] = ["web-app"] - bs.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [ - { - "name": "kv", - "computed_name": "zd-kv-dev", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - } - ], - "status": "generated", - "dir": "", - "files": ["main.tf"], - }, - ] - ) - bs._state["files_generated"] = ["main.tf"] - - report = bs.format_build_report() - assert "web-app" in report - assert "Foundation" in report - assert "zd-kv-dev" in report - assert "1" in report # Total files - - def test_format_stage_status(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "pending", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "Data", - "capability": "data", - "services": [], - "status": "generated", - "dir": "", - "files": ["sql.tf"], - }, - ] - ) - - status = bs.format_stage_status() - assert "Foundation" in status - assert "Data" in status - assert "1/2" in status # Progress - - def test_multiple_templates_used(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs._state["templates_used"] = ["web-app", "data-pipeline"] - bs.save() - - bs2 = BuildState(str(tmp_project)) - bs2.load() - assert bs2.state["templates_used"] == ["web-app", "data-pipeline"] - - def test_add_review_decision(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.add_review_decision("Please add logging to stage 2", iteration=1) - - assert len(bs.state["review_decisions"]) == 1 - assert bs.state["review_decisions"][0]["feedback"] == "Please add logging to stage 2" - assert bs.state["_metadata"]["iteration"] == 1 - - def test_reset(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs._state["templates_used"] = ["web-app"] - bs.save() - - bs.reset() - assert bs.state["templates_used"] == [] - assert bs.exists # File still exists after reset - - -# ====================================================================== -# PolicyResolver tests -# ====================================================================== - - -class TestPolicyResolver: - - def test_no_violations_no_prompt(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - from azext_prototype.stages.policy_resolver import PolicyResolver - - governance = MagicMock() - governance.check_response_for_violations.return_value = [] - - resolver = PolicyResolver(governance_context=governance) - build_state = BuildState(str(tmp_project)) - - resolutions, needs_regen = resolver.check_and_resolve( - "terraform-agent", - "resource group code", - build_state, - stage_num=1, - input_fn=lambda p: "", - print_fn=lambda m: None, - ) - - assert resolutions == [] - assert needs_regen is False - - def test_violation_accept_compliant(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - from azext_prototype.stages.policy_resolver import PolicyResolver - - governance = MagicMock() - governance.check_response_for_violations.return_value = [ - "[managed-identity] Possible anti-pattern: connection string detected" - ] - - resolver = PolicyResolver(governance_context=governance) - build_state = BuildState(str(tmp_project)) - - printed = [] - resolutions, needs_regen = resolver.check_and_resolve( - "terraform-agent", - "code with connection_string", - build_state, - stage_num=1, - input_fn=lambda p: "a", # Accept - print_fn=lambda m: printed.append(m), - ) - - assert len(resolutions) == 1 - assert resolutions[0].action == "accept" - assert needs_regen is False - - def test_violation_override_persists(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - from azext_prototype.stages.policy_resolver import PolicyResolver - - governance = MagicMock() - governance.check_response_for_violations.return_value = [ - "[managed-identity] Use managed identity instead of keys" - ] - - resolver = PolicyResolver(governance_context=governance) - build_state = BuildState(str(tmp_project)) - - inputs = iter(["o", "Legacy service requires keys"]) - resolutions, needs_regen = resolver.check_and_resolve( - "terraform-agent", - "code with access_key", - build_state, - stage_num=1, - input_fn=lambda p: next(inputs), - print_fn=lambda m: None, - ) - - assert len(resolutions) == 1 - assert resolutions[0].action == "override" - assert resolutions[0].justification == "Legacy service requires keys" - assert needs_regen is False - # Should be persisted in build state - assert len(build_state.state["policy_overrides"]) == 1 - - def test_violation_regenerate_flag(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - from azext_prototype.stages.policy_resolver import PolicyResolver - - governance = MagicMock() - governance.check_response_for_violations.return_value = ["[managed-identity] Hardcoded credential detected"] - - resolver = PolicyResolver(governance_context=governance) - build_state = BuildState(str(tmp_project)) - - resolutions, needs_regen = resolver.check_and_resolve( - "terraform-agent", - "bad code", - build_state, - stage_num=1, - input_fn=lambda p: "r", # Regenerate - print_fn=lambda m: None, - ) - - assert len(resolutions) == 1 - assert resolutions[0].action == "regenerate" - assert needs_regen is True - - def test_build_fix_instructions(self): - from azext_prototype.stages.policy_resolver import ( - PolicyResolution, - PolicyResolver, - ) - - resolver = PolicyResolver(governance_context=MagicMock()) - resolutions = [ - PolicyResolution( - rule_id="managed-identity", - action="regenerate", - violation_text="[managed-identity] Use MI instead of keys", - ), - PolicyResolution( - rule_id="key-vault", - action="override", - justification="Legacy requirement", - violation_text="[key-vault] Secrets should use Key Vault", - ), - ] - - instructions = resolver.build_fix_instructions(resolutions) - assert "Policy Fix Instructions" in instructions - assert "[managed-identity]" in instructions - assert "Legacy requirement" in instructions - - def test_extract_rule_id(self): - from azext_prototype.stages.policy_resolver import PolicyResolver - - assert PolicyResolver._extract_rule_id("[managed-identity] Some violation") == "managed-identity" - assert PolicyResolver._extract_rule_id("No brackets here") == "unknown" - assert PolicyResolver._extract_rule_id("[kv-001] Key Vault issue") == "kv-001" - - -# ====================================================================== -# BuildSession fixtures -# ====================================================================== - - -@pytest.fixture -def mock_tf_agent(): - agent = MagicMock() - agent.name = "terraform-agent" - agent.execute.return_value = _make_file_response( - "main.tf", 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' - ) - return agent - - -@pytest.fixture -def mock_dev_agent(): - agent = MagicMock() - agent.name = "app-developer" - agent.execute.return_value = _make_file_response("app.py", "# app code") - return agent - - -@pytest.fixture -def mock_doc_agent(): - agent = MagicMock() - agent.name = "doc-agent" - agent.execute.return_value = _make_file_response("DEPLOYMENT.md", "# Deployment Guide") - return agent - - -@pytest.fixture -def mock_architect_agent_for_build(): - agent = MagicMock() - agent.name = "cloud-architect" - # Return a JSON deployment plan - plan = { - "stages": [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1-foundation", - "services": [ - { - "name": "key-vault", - "computed_name": "zd-kv-test-dev-eus", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - }, - ], - "status": "pending", - "files": [], - }, - { - "stage": 2, - "name": "Documentation", - "capability": "docs", - "dir": "concept/docs", - "services": [], - "status": "pending", - "files": [], - }, - ] - } - agent.execute.return_value = _make_response(f"```json\n{json.dumps(plan)}\n```") - return agent - - -@pytest.fixture -def mock_qa_agent(): - agent = MagicMock() - agent.name = "qa-engineer" - return agent - - -@pytest.fixture -def build_registry(mock_tf_agent, mock_dev_agent, mock_doc_agent, mock_architect_agent_for_build, mock_qa_agent): - registry = MagicMock() - - def find_by_cap(cap): - mapping = { - AgentCapability.TERRAFORM: [mock_tf_agent], - AgentCapability.BICEP: [], - AgentCapability.DEVELOP: [mock_dev_agent], - AgentCapability.DOCUMENT: [mock_doc_agent], - AgentCapability.ARCHITECT: [mock_architect_agent_for_build], - AgentCapability.QA: [mock_qa_agent], - } - return mapping.get(cap, []) - - registry.find_by_capability.side_effect = find_by_cap - return registry - - -@pytest.fixture -def build_context(project_with_design, sample_config): - """AgentContext for build tests with design already completed.""" - provider = MagicMock() - provider.provider_name = "github-models" - provider.chat.return_value = _make_response() - return AgentContext( - project_config=sample_config, - project_dir=str(project_with_design), - ai_provider=provider, - ) - - -# ====================================================================== -# BuildSession tests -# ====================================================================== - - -class TestBuildSession: - - def test_session_creates_with_agents(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - assert session._iac_agents.get("terraform") is not None - assert session._dev_agent is not None - assert session._doc_agent is not None - assert session._architect_agent is not None - assert session._qa_agent is not None - - def test_quit_cancels(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - inputs = iter(["quit"]) - - result = session.run( - design={"architecture": "Sample architecture"}, - input_fn=lambda p: next(inputs), - print_fn=lambda m: None, - ) - - assert result.cancelled is True - - def test_done_accepts(self, build_context, build_registry, mock_architect_agent_for_build): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - # First input: confirm plan (empty = proceed), then "done" to accept - inputs = iter(["", "done"]) - - # Patch governance to skip violations - with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: - mock_gov_cls.return_value.check_response_for_violations.return_value = [] - session._governance = mock_gov_cls.return_value - session._policy_resolver._governance = mock_gov_cls.return_value - - # Patch AgentOrchestrator.delegate to avoid real QA call - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response("QA looks good") - - result = session.run( - design={"architecture": "Sample architecture with key-vault and sql-database"}, - input_fn=lambda p: next(inputs), - print_fn=lambda m: None, - ) - - assert result.cancelled is False - assert result.review_accepted is True - - def test_deployment_plan_derivation(self, build_context, build_registry, mock_architect_agent_for_build): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - # The architect agent returns a JSON plan; test that it's parsed correctly - plan_json = { - "stages": [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1-foundation", - "services": [ - { - "name": "kv", - "computed_name": "zd-kv-dev", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - } - ], - "status": "pending", - "files": [], - }, - { - "stage": 2, - "name": "Apps", - "capability": "app", - "dir": "concept/apps/stage-2-api", - "services": [], - "status": "pending", - "files": [], - }, - ] - } - mock_architect_agent_for_build.execute.return_value = _make_response(f"```json\n{json.dumps(plan_json)}\n```") - - stages = session._derive_deployment_plan("Sample architecture", []) - assert len(stages) == 2 - assert stages[0]["name"] == "Foundation" - assert stages[0]["services"][0]["computed_name"] == "zd-kv-dev" - assert stages[1]["capability"] == "app" - - def test_fallback_deployment_plan(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - # Force no architect - build_registry.find_by_capability.side_effect = lambda cap: [] - session = BuildSession(build_context, build_registry) - - stages = session._fallback_deployment_plan([]) - assert len(stages) >= 2 # Managed Identity + Documentation at minimum - assert stages[0]["name"] == "Managed Identity" - assert stages[0]["layer"] == "core" - assert stages[-1]["name"] == "Documentation" - assert stages[-1]["layer"] == "docs" - - def test_template_matching_web_app(self, project_with_design, sample_config): - from azext_prototype.stages.build_stage import BuildStage - - stage = BuildStage() - design = { - "architecture": ( - "The system uses container-apps for the API, " - "sql-database for persistence, key-vault for secrets, " - "api-management as the gateway, and a virtual-network." - ) - } - from azext_prototype.config import ProjectConfig - - config = ProjectConfig(str(project_with_design)) - config.load() - - templates = stage._match_templates(design, config) - # web-app template should match (container-apps, sql-database, key-vault, api-management, virtual-network) - assert len(templates) >= 1 - names = [t.name for t in templates] - assert "web-app" in names - - def test_template_matching_no_match(self, project_with_design, sample_config): - from azext_prototype.stages.build_stage import BuildStage - - stage = BuildStage() - design = {"architecture": "This is a simple static website with no Azure services mentioned."} - from azext_prototype.config import ProjectConfig - - config = ProjectConfig(str(project_with_design)) - config.load() - - templates = stage._match_templates(design, config) - assert templates == [] - - def test_parse_deployment_plan_json_block(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - content = '```json\n{"stages": [{"stage": 1, "name": "Test", "capability": "infra"}]}\n```' - stages = session._parse_deployment_plan(content) - assert len(stages) == 1 - assert stages[0]["name"] == "Test" - - def test_parse_deployment_plan_raw_json(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - content = '{"stages": [{"stage": 1, "name": "Raw"}]}' - stages = session._parse_deployment_plan(content) - assert len(stages) == 1 - assert stages[0]["name"] == "Raw" - - def test_parse_deployment_plan_invalid(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - stages = session._parse_deployment_plan("This is not JSON at all") - assert stages == [] - - def test_identify_affected_stages_by_number(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "Data", - "capability": "data", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - ] - ) - - affected = session._identify_affected_stages("Please fix stage 2") - assert affected == [2] - - def test_identify_affected_stages_by_name(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "Data", - "capability": "data", - "services": [{"name": "sql-server", "computed_name": "sql-1", "resource_type": "", "sku": ""}], - "status": "generated", - "dir": "", - "files": [], - }, - ] - ) - - affected = session._identify_affected_stages("The sql-server configuration is wrong") - assert 2 in affected - - def test_slash_command_status(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - ] - ) - - printed = [] - session._handle_slash_command("/status", lambda m: printed.append(m)) - output = "\n".join(printed) - assert "Foundation" in output - - def test_slash_command_files(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state._state["files_generated"] = ["main.tf", "variables.tf"] - - printed = [] - session._handle_slash_command("/files", lambda m: printed.append(m)) - output = "\n".join(printed) - assert "main.tf" in output - assert "variables.tf" in output - - def test_slash_command_policy(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - # No checks yet - printed = [] - session._handle_slash_command("/policy", lambda m: printed.append(m)) - output = "\n".join(printed) - assert "No policy checks" in output - - def test_slash_command_help(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - printed = [] - session._handle_slash_command("/help", lambda m: printed.append(m)) - output = "\n".join(printed) - assert "/status" in output - assert "/files" in output - assert "done" in output - - def test_categorize_service(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._categorize_service("key-vault") == "infra" - assert BuildSession._categorize_service("sql-database") == "data" - assert BuildSession._categorize_service("container-apps") == "app" - assert BuildSession._categorize_service("unknown-service") == "app" - - def test_normalize_stages(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - raw = [ - {"stage": 1, "name": "Test", "capability": "infra"}, - {"name": "No Stage Num"}, - ] - normalized = session._normalize_stages(raw) - assert len(normalized) == 2 - assert normalized[0]["status"] == "pending" - assert normalized[0]["files"] == [] - assert normalized[0]["layer"] == "infra" # Inferred from capability - assert normalized[1]["stage"] == 2 # Auto-assigned - assert normalized[1]["layer"] == "infra" # Default - - def test_normalize_stages_preserves_explicit_layer(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - raw = [ - {"stage": 1, "name": "Key Vault", "layer": "data", "capability": "data"}, - {"stage": 2, "name": "API", "layer": "app", "capability": "app"}, - ] - normalized = session._normalize_stages(raw) - assert normalized[0]["layer"] == "data" - assert normalized[1]["layer"] == "app" - - def test_normalize_stages_infers_core_for_identity(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - raw = [ - {"stage": 1, "name": "Managed Identity", "capability": "infra"}, - {"stage": 2, "name": "Log Analytics", "capability": "infra"}, - ] - normalized = session._normalize_stages(raw) - assert normalized[0]["layer"] == "core" - assert normalized[1]["layer"] == "core" - - def test_reentrant_skips_generated_stages(self, build_context, build_registry, mock_tf_agent, mock_doc_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - design = {"architecture": "Test"} - - # Pre-populate with a generated stage and matching design snapshot - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": ["main.tf"], - }, - { - "stage": 2, - "name": "Documentation", - "capability": "docs", - "services": [], - "status": "pending", - "dir": "concept/docs", - "files": [], - }, - ] - ) - session._build_state.set_design_snapshot(design) - - inputs = iter(["", "done"]) - - with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: - mock_gov_cls.return_value.check_response_for_violations.return_value = [] - session._governance = mock_gov_cls.return_value - session._policy_resolver._governance = mock_gov_cls.return_value - - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response("QA ok") - - session.run( - design=design, - input_fn=lambda p: next(inputs), - print_fn=lambda m: None, - ) - - # Stage 1 (generated) should NOT have been re-run - # Only doc agent should have been called (for stage 2) - assert mock_tf_agent.execute.call_count == 0 - assert mock_doc_agent.execute.call_count == 1 - - - # Re-entry validating tests moved to tests/stages/test_build_session_reentry.py - - -# ====================================================================== -# Incremental build / design snapshot tests -# ====================================================================== - - -class TestDesignSnapshot: - """Tests for design snapshot tracking and change detection in BuildState.""" - - def test_design_snapshot_set_on_first_build(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - design = { - "architecture": "## Architecture\nKey Vault + SQL Database", - "_metadata": {"iteration": 3}, - } - bs.set_design_snapshot(design) - - snapshot = bs.state["design_snapshot"] - assert snapshot["iteration"] == 3 - assert snapshot["architecture_hash"] is not None - assert len(snapshot["architecture_hash"]) == 16 - assert snapshot["architecture_text"] == design["architecture"] - - def test_design_has_changed_detects_modification(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - original = {"architecture": "Key Vault + SQL"} - bs.set_design_snapshot(original) - - modified = {"architecture": "Key Vault + SQL + Redis Cache"} - assert bs.design_has_changed(modified) is True - - def test_design_has_changed_no_change(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - design = {"architecture": "Key Vault + SQL"} - bs.set_design_snapshot(design) - - assert bs.design_has_changed(design) is False - - def test_design_has_changed_legacy_no_snapshot(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - # No snapshot set — simulates legacy build - assert bs.design_has_changed({"architecture": "anything"}) is True - - def test_get_previous_architecture(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - assert bs.get_previous_architecture() is None - - design = {"architecture": "The full architecture text here"} - bs.set_design_snapshot(design) - assert bs.get_previous_architecture() == "The full architecture text here" - - def test_design_snapshot_persists_across_load(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - design = {"architecture": "Persistent arch", "_metadata": {"iteration": 2}} - bs.set_design_snapshot(design) - - bs2 = BuildState(str(tmp_project)) - bs2.load() - assert bs2.design_has_changed(design) is False - assert bs2.get_previous_architecture() == "Persistent arch" - - -class TestStageManipulation: - """Tests for mark_stages_stale, remove_stages, add_stages, renumber_stages.""" - - def _sample_stages(self): - return [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "concept/infra/terraform/stage-1-foundation", - "files": ["main.tf"], - }, - { - "stage": 2, - "name": "Data", - "capability": "data", - "services": [ - {"name": "sql", "computed_name": "sql-1", "resource_type": "Microsoft.Sql/servers", "sku": ""} - ], - "status": "generated", - "dir": "concept/infra/terraform/stage-2-data", - "files": ["sql.tf"], - }, - { - "stage": 3, - "name": "App", - "capability": "app", - "services": [], - "status": "generated", - "dir": "concept/apps/stage-3-api", - "files": ["app.py"], - }, - { - "stage": 4, - "name": "Documentation", - "capability": "docs", - "services": [], - "status": "generated", - "dir": "concept/docs", - "files": ["DEPLOY.md"], - }, - ] - - def test_mark_stages_stale(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.set_deployment_plan(self._sample_stages()) - - bs.mark_stages_stale([2, 3]) - - assert bs.get_stage(1)["status"] == "generated" - assert bs.get_stage(2)["status"] == "pending" - assert bs.get_stage(3)["status"] == "pending" - assert bs.get_stage(4)["status"] == "generated" - - def test_remove_stages(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.set_deployment_plan(self._sample_stages()) - bs._state["files_generated"] = ["main.tf", "sql.tf", "app.py", "DEPLOY.md"] - - bs.remove_stages([2]) - - stage_nums = [s["stage"] for s in bs.state["deployment_stages"]] - assert 2 not in stage_nums - assert len(bs.state["deployment_stages"]) == 3 - # sql.tf should be removed from files_generated - assert "sql.tf" not in bs.state["files_generated"] - assert "main.tf" in bs.state["files_generated"] - - def test_add_stages(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.set_deployment_plan(self._sample_stages()) - - new_stages = [ - { - "name": "Redis Cache", - "capability": "data", - "services": [ - { - "name": "redis", - "computed_name": "redis-1", - "resource_type": "Microsoft.Cache/redis", - "sku": "Basic", - } - ], - }, - ] - bs.add_stages(new_stages) - - stages = bs.state["deployment_stages"] - # Should be inserted before docs (stage 4 originally) - # After renumbering: Foundation(1), Data(2), App(3), Redis(4), Docs(5) - assert len(stages) == 5 - assert stages[3]["name"] == "Redis Cache" - assert stages[3]["stage"] == 4 - assert stages[4]["name"] == "Documentation" - assert stages[4]["stage"] == 5 - - def test_renumber_stages(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - # Set up stages with gaps - bs._state["deployment_stages"] = [ - { - "stage": 1, - "name": "A", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - {"stage": 5, "name": "B", "capability": "data", "services": [], "status": "pending", "dir": "", "files": []}, - {"stage": 10, "name": "C", "capability": "docs", "services": [], "status": "pending", "dir": "", "files": []}, - ] - - bs.renumber_stages() - - assert bs.state["deployment_stages"][0]["stage"] == 1 - assert bs.state["deployment_stages"][1]["stage"] == 2 - assert bs.state["deployment_stages"][2]["stage"] == 3 - - -class TestArchitectureDiff: - """Tests for _diff_architectures and _parse_diff_result.""" - - def test_diff_architectures_parses_response(self, build_context, build_registry, mock_architect_agent_for_build): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - existing = [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [{"name": "key-vault"}], - "status": "generated", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "Data", - "capability": "data", - "services": [{"name": "sql"}], - "status": "generated", - "dir": "", - "files": [], - }, - ] - - diff_response = json.dumps( - { - "unchanged": [1], - "modified": [2], - "removed": [], - "added": [{"name": "Redis", "capability": "data", "services": []}], - "plan_restructured": False, - "summary": "Modified data stage; added Redis.", - } - ) - mock_architect_agent_for_build.execute.return_value = _make_response(f"```json\n{diff_response}\n```") - - result = session._diff_architectures("old arch", "new arch", existing) - - assert result["unchanged"] == [1] - assert result["modified"] == [2] - assert result["removed"] == [] - assert len(result["added"]) == 1 - assert result["added"][0]["name"] == "Redis" - assert result["plan_restructured"] is False - - def test_diff_architectures_fallback_no_architect(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - # Remove the architect agent - session = BuildSession(build_context, build_registry) - session._architect_agent = None - - existing = [ - { - "stage": 1, - "name": "A", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "B", - "capability": "data", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - ] - - result = session._diff_architectures("old", "new", existing) - - # Fallback: all stages marked as modified - assert set(result["modified"]) == {1, 2} - assert result["unchanged"] == [] - - def test_parse_diff_result_defaults_to_unchanged(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - existing = [ - { - "stage": 1, - "name": "A", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "B", - "capability": "data", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - {"stage": 3, "name": "C", "capability": "app", "services": [], "status": "generated", "dir": "", "files": []}, - ] - - # Only mention stage 2 as modified; 1 and 3 should default to unchanged - content = json.dumps({"modified": [2], "summary": "test"}) - result = session._parse_diff_result(content, existing) - - assert result is not None - assert 1 in result["unchanged"] - assert 3 in result["unchanged"] - assert result["modified"] == [2] - - def test_parse_diff_result_invalid_json(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - result = session._parse_diff_result("This is not JSON", []) - assert result is None - - -class TestIncrementalBuildSession: - """End-to-end tests for the incremental build flow.""" - - def test_incremental_run_no_changes(self, build_context, build_registry): - """When design hasn't changed and all stages are generated, report up to date.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - design = {"architecture": "Sample arch"} - - # Set up: pre-populate with generated stages and a matching snapshot - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": ["main.tf"], - }, - { - "stage": 2, - "name": "Docs", - "capability": "docs", - "services": [], - "status": "generated", - "dir": "concept/docs", - "files": ["README.md"], - }, - ] - ) - session._build_state.set_design_snapshot(design) - - printed = [] - inputs = iter(["done"]) - - result = session.run( - design=design, - input_fn=lambda p: next(inputs), - print_fn=lambda m: printed.append(m), - ) - - output = "\n".join(printed) - assert "up to date" in output.lower() - assert result.review_accepted is True - - def test_incremental_run_with_changes( - self, build_context, build_registry, mock_architect_agent_for_build, mock_tf_agent - ): - """When design has changed, only affected stages should be regenerated.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - old_design = {"architecture": "Original architecture with Key Vault"} - new_design = {"architecture": "Updated architecture with Key Vault + Redis"} - - # Set up existing build - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [{"name": "key-vault"}], - "status": "generated", - "dir": "concept/infra/terraform/stage-1-foundation", - "files": ["main.tf"], - }, - { - "stage": 2, - "name": "Documentation", - "capability": "docs", - "services": [], - "status": "generated", - "dir": "concept/docs", - "files": ["README.md"], - }, - ] - ) - session._build_state.set_design_snapshot(old_design) - - # Mock architect: stage 1 unchanged, no removed, add Redis - diff_response = json.dumps( - { - "unchanged": [1], - "modified": [], - "removed": [], - "added": [ - { - "name": "Redis Cache", - "capability": "data", - "services": [ - { - "name": "redis-cache", - "computed_name": "redis-1", - "resource_type": "Microsoft.Cache/redis", - "sku": "Basic", - } - ], - } - ], - "plan_restructured": False, - "summary": "Added Redis Cache stage.", - } - ) - mock_architect_agent_for_build.execute.return_value = _make_response(f"```json\n{diff_response}\n```") - - printed = [] - inputs = iter(["", "done"]) - - with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: - mock_gov_cls.return_value.check_response_for_violations.return_value = [] - session._governance = mock_gov_cls.return_value - session._policy_resolver._governance = mock_gov_cls.return_value - - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response("QA ok") - - result = session.run( - design=new_design, - input_fn=lambda p: next(inputs), - print_fn=lambda m: printed.append(m), - ) - - output = "\n".join(printed) - assert "Design changes detected" in output - assert "Added 1 new stage" in output - assert result.cancelled is False - - def test_incremental_run_plan_restructured( - self, build_context, build_registry, mock_architect_agent_for_build, mock_tf_agent - ): - """When plan_restructured is True, a full re-derive should be offered.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - old_design = {"architecture": "Simple architecture"} - new_design = {"architecture": "Completely redesigned architecture"} - - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": ["main.tf"], - }, - ] - ) - session._build_state.set_design_snapshot(old_design) - - # First call: diff says plan_restructured - diff_response = json.dumps( - { - "unchanged": [], - "modified": [1], - "removed": [], - "added": [], - "plan_restructured": True, - "summary": "Major restructuring needed.", - } - ) - - # Second call: re-derive returns new plan - new_plan = { - "stages": [ - { - "stage": 1, - "name": "New Foundation", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1-new", - "services": [], - "status": "pending", - "files": [], - }, - { - "stage": 2, - "name": "Documentation", - "capability": "docs", - "dir": "concept/docs", - "services": [], - "status": "pending", - "files": [], - }, - ] - } - - call_count = [0] - - def architect_side_effect(ctx, task): - call_count[0] += 1 - if call_count[0] == 1: - return _make_response(f"```json\n{diff_response}\n```") - else: - return _make_response(f"```json\n{json.dumps(new_plan)}\n```") - - mock_architect_agent_for_build.execute.side_effect = architect_side_effect - - printed = [] - # First prompt: confirm re-derive (Enter), second: confirm plan, third: done - inputs = iter(["", "", "done"]) - - with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: - mock_gov_cls.return_value.check_response_for_violations.return_value = [] - session._governance = mock_gov_cls.return_value - session._policy_resolver._governance = mock_gov_cls.return_value - - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response("QA ok") - - result = session.run( - design=new_design, - input_fn=lambda p: next(inputs), - print_fn=lambda m: printed.append(m), - ) - - output = "\n".join(printed) - assert "full plan re-derive" in output.lower() - assert result.cancelled is False - - -# ====================================================================== -# Telemetry tests -# ====================================================================== - - -class TestMultiResourceTelemetry: - - def test_track_build_resources_single(self): - from azext_prototype.telemetry import track_build_resources - - with patch("azext_prototype.telemetry.is_enabled", return_value=True), patch( - "azext_prototype.telemetry._get_ingestion_config", return_value=("http://test/v2/track", "key") - ), patch("azext_prototype.telemetry._send_envelope") as mock_send: - - track_build_resources( - "prototype build", - resources=[{"resourceType": "Microsoft.KeyVault/vaults", "sku": "standard"}], - ) - - assert mock_send.called - envelope = mock_send.call_args[0][0] - props = envelope["data"]["baseData"]["properties"] - assert props["resourceCount"] == "1" - assert "Microsoft.KeyVault/vaults" in props["resources"] - assert props["resourceType"] == "Microsoft.KeyVault/vaults" - assert props["sku"] == "standard" - - def test_track_build_resources_multiple(self): - from azext_prototype.telemetry import track_build_resources - - with patch("azext_prototype.telemetry.is_enabled", return_value=True), patch( - "azext_prototype.telemetry._get_ingestion_config", return_value=("http://test/v2/track", "key") - ), patch("azext_prototype.telemetry._send_envelope") as mock_send: - - resources = [ - {"resourceType": "Microsoft.KeyVault/vaults", "sku": "standard"}, - {"resourceType": "Microsoft.Sql/servers", "sku": "serverless"}, - {"resourceType": "Microsoft.Web/sites", "sku": "P1v3"}, - ] - track_build_resources("prototype build", resources=resources) - - envelope = mock_send.call_args[0][0] - props = envelope["data"]["baseData"]["properties"] - assert props["resourceCount"] == "3" - parsed = json.loads(props["resources"]) - assert len(parsed) == 3 - - def test_track_build_resources_backward_compat(self): - from azext_prototype.telemetry import track_build_resources - - with patch("azext_prototype.telemetry.is_enabled", return_value=True), patch( - "azext_prototype.telemetry._get_ingestion_config", return_value=("http://test/v2/track", "key") - ), patch("azext_prototype.telemetry._send_envelope") as mock_send: - - resources = [ - {"resourceType": "Microsoft.KeyVault/vaults", "sku": "standard"}, - {"resourceType": "Microsoft.Sql/servers", "sku": "serverless"}, - ] - track_build_resources("prototype build", resources=resources) - - envelope = mock_send.call_args[0][0] - props = envelope["data"]["baseData"]["properties"] - # Backward compat: first resource maps to legacy scalar fields - assert props["resourceType"] == "Microsoft.KeyVault/vaults" - assert props["sku"] == "standard" - - def test_track_build_resources_empty(self): - from azext_prototype.telemetry import track_build_resources - - with patch("azext_prototype.telemetry.is_enabled", return_value=True), patch( - "azext_prototype.telemetry._get_ingestion_config", return_value=("http://test/v2/track", "key") - ), patch("azext_prototype.telemetry._send_envelope") as mock_send: - - track_build_resources("prototype build", resources=[]) - - envelope = mock_send.call_args[0][0] - props = envelope["data"]["baseData"]["properties"] - assert props["resourceCount"] == "0" - assert props["resourceType"] == "" - assert props["sku"] == "" - - def test_track_build_resources_disabled(self): - from azext_prototype.telemetry import track_build_resources - - with patch("azext_prototype.telemetry.is_enabled", return_value=False), patch( - "azext_prototype.telemetry._send_envelope" - ) as mock_send: - - track_build_resources("prototype build", resources=[{"resourceType": "test", "sku": ""}]) - assert not mock_send.called - - -# ====================================================================== -# BuildStage integration tests -# ====================================================================== - - -class TestBuildStageIntegration: - - def test_build_stage_dry_run(self, project_with_design, sample_config): - from azext_prototype.stages.build_stage import BuildStage - - stage = BuildStage() - provider = MagicMock() - provider.provider_name = "github-models" - - context = AgentContext( - project_config=sample_config, - project_dir=str(project_with_design), - ai_provider=provider, - ) - - from azext_prototype.agents.registry import AgentRegistry - - registry = AgentRegistry() - - printed = [] - result = stage.execute( - context, - registry, - dry_run=True, - print_fn=lambda m: printed.append(m), - ) - - assert result["status"] == "dry-run" - output = "\n".join(printed) - assert "DRY RUN" in output - - def test_build_stage_status_flag(self, project_with_design, sample_config): - """The --status flag should show build status and exit (tested via custom.py).""" - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(project_with_design)) - bs.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": ["main.tf"], - }, - ] - ) - - # Verify the state file exists and is loadable - bs2 = BuildState(str(project_with_design)) - assert bs2.exists - bs2.load() - assert bs2.format_stage_status() # Should produce output - - -# ====================================================================== -# _agent_build_context tests -# ====================================================================== - - -class TestAgentBuildContext: - """Tests for the _agent_build_context context manager.""" - - def test_agent_build_context_sets_and_restores_standards(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - # Mock the agent's attributes and methods - mock_tf_agent._include_standards = True - mock_tf_agent._governor_brief = "" - mock_tf_agent.set_knowledge_override = MagicMock() - mock_tf_agent.set_governor_brief = MagicMock() - - stage = {"name": "Foundation", "services": [{"name": "key-vault"}]} - - with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): - with session._agent_build_context(mock_tf_agent, stage): - # Standards remain enabled during generation (agent-scoped filtering) - assert mock_tf_agent._include_standards is True - - # After exiting, standards unchanged - assert mock_tf_agent._include_standards is True - - def test_agent_build_context_clears_knowledge_on_exit(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - mock_tf_agent._include_standards = True - mock_tf_agent.set_knowledge_override = MagicMock() - mock_tf_agent.set_governor_brief = MagicMock() - - stage = {"name": "Foundation", "services": []} - - with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): - with session._agent_build_context(mock_tf_agent, stage): - pass - - mock_tf_agent.set_knowledge_override.assert_called_with("") - - def test_agent_build_context_calls_governor_and_knowledge(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - mock_tf_agent._include_standards = False - mock_tf_agent.set_knowledge_override = MagicMock() - mock_tf_agent.set_governor_brief = MagicMock() - - stage = {"name": "Data", "layer": "infra", "services": [{"name": "sql-server"}]} - - with patch.object(session, "_apply_governor_brief") as mock_gov, patch.object( - session, "_apply_stage_knowledge" - ) as mock_know: - with session._agent_build_context(mock_tf_agent, stage): - pass - - mock_gov.assert_called_once_with(mock_tf_agent, "Data", [{"name": "sql-server"}], "infra") - mock_know.assert_called_once_with(mock_tf_agent, stage) - - def test_agent_build_context_restores_on_exception(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - mock_tf_agent._include_standards = True - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"name": "Foundation", "services": []} - - with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): - try: - with session._agent_build_context(mock_tf_agent, stage): - raise ValueError("test error") - except ValueError: - pass - - # Standards should still be restored despite the exception - assert mock_tf_agent._include_standards is True - mock_tf_agent.set_knowledge_override.assert_called_with("") - - -# ====================================================================== -# _apply_stage_knowledge tests -# ====================================================================== - - -class TestApplyStageKnowledge: - """Tests for _apply_stage_knowledge with different knowledge scenarios.""" - - def test_apply_stage_knowledge_with_services(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"services": [{"name": "key-vault"}, {"name": "sql-server"}]} - - with patch("azext_prototype.stages.build_session.KnowledgeLoader", create=True) as MockLoader: - mock_loader = MockLoader.return_value - mock_loader.compose_context.return_value = "Key vault knowledge\nSQL knowledge" - # Patch the import inside the method - with patch.dict("sys.modules", {"azext_prototype.knowledge": MagicMock(KnowledgeLoader=MockLoader)}): - session._apply_stage_knowledge(mock_tf_agent, stage) - - mock_tf_agent.set_knowledge_override.assert_called_once() - call_arg = mock_tf_agent.set_knowledge_override.call_args[0][0] - assert "Key vault knowledge" in call_arg - - def test_apply_stage_knowledge_empty_services(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"services": []} - - with patch("azext_prototype.stages.build_session.KnowledgeLoader", create=True) as MockLoader: - mock_loader = MockLoader.return_value - mock_loader.compose_context.return_value = "" - with patch.dict("sys.modules", {"azext_prototype.knowledge": MagicMock(KnowledgeLoader=MockLoader)}): - session._apply_stage_knowledge(mock_tf_agent, stage) - - # Empty knowledge should not call set_knowledge_override - mock_tf_agent.set_knowledge_override.assert_not_called() - - def test_apply_stage_knowledge_truncates_large_knowledge(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"services": [{"name": "key-vault"}]} - large_knowledge = "x" * 70000 # > 65536 threshold - - with patch("azext_prototype.stages.build_session.KnowledgeLoader", create=True) as MockLoader: - mock_loader = MockLoader.return_value - mock_loader.compose_context.return_value = large_knowledge - with patch.dict("sys.modules", {"azext_prototype.knowledge": MagicMock(KnowledgeLoader=MockLoader)}): - session._apply_stage_knowledge(mock_tf_agent, stage) - - call_arg = mock_tf_agent.set_knowledge_override.call_args[0][0] - assert len(call_arg) < 70000 - assert "truncated" in call_arg.lower() - - def test_apply_stage_knowledge_handles_import_error(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"services": [{"name": "key-vault"}]} - - # Force an import error — the method should silently pass - with patch.dict("sys.modules", {"azext_prototype.knowledge": None}): - session._apply_stage_knowledge(mock_tf_agent, stage) - - # Should not raise and should not call set_knowledge_override - mock_tf_agent.set_knowledge_override.assert_not_called() - - -# ====================================================================== -# _condense_architecture tests -# ====================================================================== - - -class TestCondenseArchitecture: - """Tests for _condense_architecture — cached, empty, unparseable responses.""" - - def test_condense_returns_cached_contexts(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, - {"stage": 2, "name": "Data", "capability": "data", "services": []}, - ] - - # Pre-populate cache in build_state - session._build_state._state["stage_contexts"] = { - "1": "## Stage 1: Foundation\nContext for stage 1", - "2": "## Stage 2: Data\nContext for stage 2", - } - - result = session._condense_architecture("full architecture", stages, use_styled=False) - - assert result[1] == "## Stage 1: Foundation\nContext for stage 1" - assert result[2] == "## Stage 2: Data\nContext for stage 2" - # AI provider should not be called when cache is available - build_context.ai_provider.chat.assert_not_called() - - def test_condense_returns_empty_when_no_ai_provider(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._context = AgentContext( - project_config=build_context.project_config, - project_dir=build_context.project_dir, - ai_provider=None, - ) - - stages = [{"stage": 1, "name": "Foundation", "capability": "infra", "services": []}] - - result = session._condense_architecture("architecture", stages, use_styled=False) - - assert result == {} - - def test_condense_parses_stage_sections(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, - {"stage": 2, "name": "Data", "capability": "data", "services": []}, - ] - - ai_response = AIResponse( - content=( - "## Stage 1: Foundation\n" - "Sets up resource group and managed identity.\n\n" - "## Stage 2: Data\n" - "Provisions SQL database with private endpoint." - ), - model="gpt-4o", - usage={"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300}, - ) - build_context.ai_provider.chat.return_value = ai_response - - result = session._condense_architecture("architecture text", stages, use_styled=False) - - assert 1 in result - assert 2 in result - assert "Foundation" in result[1] - assert "SQL database" in result[2] - - def test_condense_empty_response_returns_empty(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stages = [{"stage": 1, "name": "Foundation", "capability": "infra", "services": []}] - - # AI returns empty content - build_context.ai_provider.chat.return_value = AIResponse( - content="", - model="gpt-4o", - usage={}, - ) - - result = session._condense_architecture("architecture", stages, use_styled=False) - - assert result == {} - - def test_condense_unparseable_response_returns_empty(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stages = [{"stage": 1, "name": "Foundation", "capability": "infra", "services": []}] - - # AI returns content without any "## Stage N" headers - build_context.ai_provider.chat.return_value = AIResponse( - content="Here is some context without stage headers.", - model="gpt-4o", - usage={}, - ) - - result = session._condense_architecture("architecture", stages, use_styled=False) - - # No stage headers means parsing returns empty dict - assert result == {} - - def test_condense_exception_returns_empty(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stages = [{"stage": 1, "name": "Foundation", "capability": "infra", "services": []}] - - build_context.ai_provider.chat.side_effect = Exception("API error") - - result = session._condense_architecture("architecture", stages, use_styled=False) - - assert result == {} - - def test_condense_caches_result_in_build_state(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, - ] - - ai_response = AIResponse( - content="## Stage 1: Foundation\nContext here.", - model="gpt-4o", - usage={"prompt_tokens": 50, "completion_tokens": 50, "total_tokens": 100}, - ) - build_context.ai_provider.chat.return_value = ai_response - - session._condense_architecture("arch", stages, use_styled=False) - - # Verify the result was cached in build_state - cached = session._build_state._state.get("stage_contexts", {}) - assert "1" in cached - assert "Foundation" in cached["1"] - - -# ====================================================================== -# _select_agent tests -# ====================================================================== - - -class TestSelectAgent: - """Tests for _select_agent capability-to-agent mapping.""" - - def test_select_agent_infra(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "infra"}) - assert agent is mock_tf_agent - - def test_select_agent_data(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "data"}) - assert agent is mock_tf_agent - - def test_select_agent_integration(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "integration"}) - assert agent is mock_tf_agent - - def test_select_agent_app(self, build_context, build_registry, mock_dev_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "app"}) - assert agent is mock_dev_agent - - def test_select_agent_schema(self, build_context, build_registry, mock_dev_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "schema"}) - assert agent is mock_dev_agent - - def test_select_agent_cicd(self, build_context, build_registry, mock_dev_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "cicd"}) - assert agent is mock_dev_agent - - def test_select_agent_external(self, build_context, build_registry, mock_dev_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "external"}) - assert agent is mock_dev_agent - - def test_select_agent_docs(self, build_context, build_registry, mock_doc_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "docs"}) - assert agent is mock_doc_agent - - def test_select_agent_unknown_falls_back_to_iac(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "unknown_capability"}) - # Falls back to iac_agents[iac_tool] or dev_agent - assert agent is mock_tf_agent - - def test_select_agent_missing_capability_defaults_to_infra(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({}) - # capability defaults to "infra" - assert agent is mock_tf_agent - - def test_select_agent_no_agent_returns_none(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._doc_agent = None - agent = session._select_agent({"capability": "docs"}) - assert agent is None - - def test_select_agent_layer_core_routes_to_iac(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - # Core layer stages need IaC generation, not architecture design - agent = session._select_agent({"layer": "core", "capability": "identity"}) - assert agent is mock_tf_agent - - def test_select_agent_layer_docs(self, build_context, build_registry, mock_doc_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"layer": "docs", "capability": "docs"}) - assert agent is mock_doc_agent - - -# ====================================================================== -# _infer_layer tests -# ====================================================================== - - -class TestInferLayer: - """Tests for _infer_layer static method.""" - - def test_explicit_layer_preserved(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({"layer": "data", "capability": "infra"}) == "data" - - def test_identity_stage_maps_to_core(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({"name": "Managed Identity", "capability": "infra"}) == "core" - - def test_monitoring_stage_maps_to_core(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({"name": "Log Analytics", "capability": "infra"}) == "core" - assert BuildSession._infer_layer({"name": "Application Insights", "capability": "infra"}) == "core" - - def test_infra_capability_maps_to_infra(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({"name": "Networking", "capability": "infra"}) == "infra" - - def test_data_capability_maps_to_data(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({"name": "Key Vault", "capability": "data"}) == "data" - - def test_app_capability_maps_to_app(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({"name": "API", "capability": "app"}) == "app" - - def test_docs_capability_maps_to_docs(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({"name": "Documentation", "capability": "docs"}) == "docs" - - def test_integration_capability_maps_to_infra(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({"name": "APIM", "capability": "integration"}) == "infra" - - def test_unknown_capability_defaults_to_infra(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({"name": "Custom", "capability": "xyz"}) == "infra" - - def test_empty_stage_defaults_to_infra(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._infer_layer({}) == "infra" - - -# ====================================================================== -# Layer-based routing decisions (QA, anti-pattern scan, IaC detection) -# ====================================================================== - - -class TestLayerBasedRouting: - """Verify that routing/filtering decisions use layer, not capability.""" - - def test_select_agent_core_routes_to_iac_not_architect(self, build_context, build_registry, mock_tf_agent): - """Core-layer stages generate IaC code via terraform/bicep agent.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - # Verify _iac_agents is populated - assert session._iac_agents.get("terraform") is mock_tf_agent - for capability in ("identity", "observability"): - agent = session._select_agent({"layer": "core", "capability": capability}) - assert agent is mock_tf_agent, f"Core/{capability} should route to IaC agent, got {agent}" - - def test_select_agent_all_iac_layers(self, build_context, build_registry, mock_tf_agent): - """All IaC layers (core, infra, data) route to IaC agent.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - iac_stages = [ - {"layer": "core", "capability": "identity"}, - {"layer": "core", "capability": "observability"}, - {"layer": "infra", "capability": "core-networking"}, - {"layer": "infra", "capability": "compute"}, - {"layer": "infra", "capability": "security"}, - {"layer": "data", "capability": "data-services"}, - {"layer": "data", "capability": "messaging"}, - ] - for stage in iac_stages: - agent = session._select_agent(stage) - assert agent is not None, f"No agent for {stage}" - - def test_apply_stage_knowledge_skips_docs_layer(self, build_context, build_registry, mock_tf_agent): - """Docs-layer stages skip knowledge loading.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_knowledge_override = MagicMock() - session._apply_stage_knowledge(mock_tf_agent, {"layer": "docs", "capability": "documentation", "services": []}) - mock_tf_agent.set_knowledge_override.assert_not_called() - - def test_apply_stage_knowledge_loads_for_core_layer(self, build_context, build_registry, mock_tf_agent): - """Core-layer stages should load knowledge (not skip).""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_knowledge_override = MagicMock() - session._apply_stage_knowledge( - mock_tf_agent, - {"layer": "core", "capability": "identity", "services": [{"name": "managed-identity"}]}, - ) - # Should have been called (knowledge loaded) - assert mock_tf_agent.set_knowledge_override.called or True # May not find knowledge file, but shouldn't skip - - def test_build_stage_task_iac_detection_by_layer(self, build_context, build_registry, mock_tf_agent): - """IaC detection uses layer, not capability. Core/infra/data are IaC.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - for layer in ("core", "infra", "data"): - stage = { - "stage": 1, - "name": "Test", - "layer": layer, - "capability": "test", - "dir": "concept/infra/terraform/test", - "services": [], - } - agent, task = session._build_stage_task(stage, "arch", []) - assert "terraform" in task.lower() or "Generate" in task, f"Layer {layer} should be IaC" - - def test_build_stage_task_app_not_iac(self, build_context, build_registry, mock_dev_agent): - """App-layer stages should not get IaC-specific directives.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - stage = { - "stage": 1, - "name": "API", - "layer": "app", - "capability": "domain", - "dir": "concept/apps/test", - "services": [], - } - agent, task = session._build_stage_task(stage, "arch", []) - # App stages should not get IaC directive hierarchy - assert "DIRECTIVE HIERARCHY" not in task - - -# ====================================================================== -# _resolve_developer_for_stage / _decompose_app_stage tests -# ====================================================================== - - -class TestAppStageDelegation: - """Tests for app-layer architect → developer delegation.""" - - def test_resolve_developer_python_from_name(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_py = MagicMock() - mock_py.name = "python-developer" - session._python_dev = mock_py - - stage = {"name": "Python API", "services": [], "dir": ""} - dev = session._resolve_developer_for_stage(stage, "") - assert dev is mock_py - - def test_resolve_developer_react_from_name(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_react = MagicMock() - mock_react.name = "react-developer" - session._react_dev = mock_react - - stage = {"name": "React Frontend", "services": [], "dir": ""} - dev = session._resolve_developer_for_stage(stage, "") - assert dev is mock_react - - def test_resolve_developer_csharp_from_services(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_cs = MagicMock() - mock_cs.name = "csharp-developer" - session._csharp_dev = mock_cs - - stage = {"name": "Backend API", "services": [{"name": "aspnet-api"}], "dir": ""} - dev = session._resolve_developer_for_stage(stage, "") - assert dev is mock_cs - - def test_resolve_developer_from_architecture_context(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_py = MagicMock() - mock_py.name = "python-developer" - session._python_dev = mock_py - - stage = {"name": "Worker Service", "services": [], "dir": ""} - arch = "Worker Service uses FastAPI for the async message consumer." - dev = session._resolve_developer_for_stage(stage, arch) - assert dev is mock_py - - def test_resolve_developer_none_when_no_hints(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - stage = {"name": "Custom Logic", "services": [], "dir": ""} - dev = session._resolve_developer_for_stage(stage, "") - assert dev is None - - def test_decompose_returns_developer_with_sub_layer_context(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_py = MagicMock() - mock_py.name = "python-developer" - session._python_dev = mock_py - - stage = {"name": "FastAPI Backend", "layer": "app", "services": [], "dir": ""} - agent, ctx = session._decompose_app_stage(stage, "", lambda *a: None) - assert agent is mock_py - assert "Sub-Layer Structure" in ctx - - def test_decompose_falls_back_to_app_architect(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_arch = MagicMock() - mock_arch.name = "application-architect" - session._app_architect = mock_arch - - stage = {"name": "Custom Service", "layer": "app", "services": [], "dir": ""} - agent, ctx = session._decompose_app_stage(stage, "", lambda *a: None) - assert agent is mock_arch - assert ctx == "" - - -# ====================================================================== -# AgentContract.sub_layers tests -# ====================================================================== - - -class TestAgentContractSubLayers: - """Tests for the sub_layers field on AgentContract.""" - - def test_sub_layers_default_empty(self): - from azext_prototype.agents.base import AgentContract - - contract = AgentContract() - assert contract.sub_layers == [] - - def test_sub_layers_set_on_csharp(self): - from azext_prototype.agents.builtin.csharp_developer import CSharpDeveloperAgent - - agent = CSharpDeveloperAgent() - assert "api" in agent._contract.sub_layers - assert "presentation" in agent._contract.sub_layers - - def test_sub_layers_set_on_python(self): - from azext_prototype.agents.builtin.python_developer import PythonDeveloperAgent - - agent = PythonDeveloperAgent() - assert "api" in agent._contract.sub_layers - assert "presentation" not in agent._contract.sub_layers - - def test_sub_layers_set_on_react(self): - from azext_prototype.agents.builtin.react_developer import ReactDeveloperAgent - - agent = ReactDeveloperAgent() - assert agent._contract.sub_layers == ["presentation"] - - def test_sub_layers_set_on_app_architect(self): - from azext_prototype.agents.builtin.application_architect import ApplicationArchitectAgent - - agent = ApplicationArchitectAgent() - assert len(agent._contract.sub_layers) == 5 - - -# ====================================================================== -# _build_stage_task governor brief tests -# ====================================================================== - - -class TestBuildStageTaskGovernorBrief: - """Tests that _build_stage_task incorporates governor brief into task string.""" - - def test_governor_brief_included_in_task(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - # Simulate a governor brief being set on the agent - mock_tf_agent._governor_brief = "MUST use managed identity for all services" - - stage = { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [ - { - "name": "key-vault", - "computed_name": "zd-kv-dev", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - } - ], - "dir": "concept/infra/terraform/stage-1-foundation", - } - - agent, task = session._build_stage_task(stage, "sample architecture", []) - - assert agent is mock_tf_agent - assert "MANDATORY GOVERNANCE RULES" in task - assert "managed identity" in task - - def test_no_governor_brief_no_governance_section(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - mock_tf_agent._governor_brief = "" - - stage = { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform/stage-1-foundation", - } - - agent, task = session._build_stage_task(stage, "sample architecture", []) - - assert "MANDATORY GOVERNANCE RULES" not in task - - def test_build_stage_task_no_agent_returns_none(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._doc_agent = None - - stage = { - "stage": 1, - "name": "Docs", - "capability": "docs", - "services": [], - "dir": "concept/docs", - } - - agent, task = session._build_stage_task(stage, "architecture", []) - - assert agent is None - assert task == "" - - def test_build_stage_task_includes_services(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent._governor_brief = "" - - stage = { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [ - { - "name": "key-vault", - "computed_name": "zd-kv-dev", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - }, - { - "name": "managed-identity", - "computed_name": "zd-id-dev", - "resource_type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "sku": "", - }, - ], - "dir": "concept/infra/terraform/stage-1-foundation", - } - - _, task = session._build_stage_task(stage, "architecture", []) - - assert "zd-kv-dev" in task - assert "zd-id-dev" in task - assert "Microsoft.KeyVault/vaults" in task - - def test_build_stage_task_terraform_file_structure(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent._governor_brief = "" - - stage = { - "stage": 1, - "name": "Foundation", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform/stage-1-foundation", - } - - _, task = session._build_stage_task(stage, "architecture", []) - - assert "Terraform File Structure" in task - assert "providers.tf" in task - assert "main.tf" in task - assert "variables.tf" in task - - def test_build_stage_reset_flag(self, project_with_design, sample_config): - from azext_prototype.stages.build_state import BuildState - - # Create some state - bs = BuildState(str(project_with_design)) - bs._state["templates_used"] = ["web-app"] - bs.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": ["main.tf"], - }, - ] - ) - - # Reset should clear everything - bs.reset() - assert bs.state["templates_used"] == [] - assert bs.state["deployment_stages"] == [] - assert bs.state["files_generated"] == [] - - def test_build_stage_reset_cleans_output_dirs(self, project_with_design): - """--reset removes concept/infra, concept/apps, concept/db, concept/docs.""" - from azext_prototype.stages.build_stage import BuildStage - - project_dir = str(project_with_design) - base = project_with_design / "concept" - - # Create output dirs with stale files - for sub in ("infra/terraform/stage-1-foundation", "apps/stage-2-api", "db/sql", "docs"): - d = base / sub - d.mkdir(parents=True, exist_ok=True) - (d / "stale.tf").write_text("# stale", encoding="utf-8") - - assert (base / "infra").is_dir() - assert (base / "apps").is_dir() - assert (base / "db").is_dir() - assert (base / "docs").is_dir() - - stage = BuildStage() - stage._clean_output_dirs(project_dir) - - assert not (base / "infra").exists() - assert not (base / "apps").exists() - assert not (base / "db").exists() - assert not (base / "docs").exists() - - def test_build_stage_reset_ignores_missing_dirs(self, project_with_design): - """_clean_output_dirs is a no-op when dirs don't exist.""" - from azext_prototype.stages.build_stage import BuildStage - - stage = BuildStage() - # Should not raise - stage._clean_output_dirs(str(project_with_design)) - - -# ====================================================================== -# BuildResult tests -# ====================================================================== - - -class TestBuildResult: - - def test_default_values(self): - from azext_prototype.stages.build_session import BuildResult - - result = BuildResult() - assert result.files_generated == [] - assert result.deployment_stages == [] - assert result.policy_overrides == [] - assert result.resources == [] - assert result.review_accepted is False - assert result.cancelled is False - - def test_cancelled_result(self): - from azext_prototype.stages.build_session import BuildResult - - result = BuildResult(cancelled=True) - assert result.cancelled is True - assert result.review_accepted is False - - def test_populated_result(self): - from azext_prototype.stages.build_session import BuildResult - - result = BuildResult( - files_generated=["main.tf"], - resources=[{"resourceType": "Microsoft.KeyVault/vaults", "sku": "standard"}], - review_accepted=True, - ) - assert len(result.files_generated) == 1 - assert len(result.resources) == 1 - assert result.review_accepted is True - - -# ====================================================================== -# Architect-based stage identification tests (Phase 9) -# ====================================================================== - - -class TestArchitectStageIdentification: - """Test _identify_affected_stages with architect agent delegation.""" - - def _make_session_with_stages(self, tmp_project, architect_response=None, architect_raises=False): - from azext_prototype.stages.build_session import BuildSession - from azext_prototype.stages.build_state import BuildState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - architect = MagicMock() - architect.name = "cloud-architect" - if architect_raises: - architect.execute.side_effect = RuntimeError("AI error") - else: - architect.execute.return_value = architect_response or _make_response("[1, 3]") - - registry = MagicMock() - - def find_by_cap(cap): - if cap == AgentCapability.ARCHITECT: - return [architect] - if cap == AgentCapability.QA: - return [] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - build_state = BuildState(str(tmp_project)) - build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "", - "services": [{"name": "key-vault"}], - "status": "generated", - "files": [], - }, - { - "stage": 2, - "name": "Data Layer", - "capability": "data", - "dir": "", - "services": [{"name": "sql-db"}], - "status": "generated", - "files": [], - }, - { - "stage": 3, - "name": "Application", - "capability": "app", - "dir": "", - "services": [{"name": "web-app"}], - "status": "generated", - "files": [], - }, - ] - ) - - with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": "terraform", - "project.name": "test", - }.get(k, d) - mock_config.return_value.to_dict.return_value = { - "naming": {"strategy": "simple"}, - "project": {"name": "test"}, - } - session = BuildSession(ctx, registry, build_state=build_state) - - return session, architect - - def test_architect_identifies_stages(self, tmp_project): - session, architect = self._make_session_with_stages( - tmp_project, - _make_response("[1, 3]"), - ) - - result = session._identify_affected_stages("Fix the networking and add CORS") - - assert result == [1, 3] - architect.execute.assert_called_once() - - def test_architect_parse_failure_falls_back_to_regex(self, tmp_project): - session, architect = self._make_session_with_stages( - tmp_project, - _make_response("I think stages 1 and 3 are affected"), - ) - - result = session._identify_affected_stages("Fix the key-vault configuration") - - # Architect response not parseable as JSON, falls back to regex - # "key-vault" matches service in stage 1 - assert 1 in result - - def test_architect_exception_falls_back_to_regex(self, tmp_project): - session, architect = self._make_session_with_stages( - tmp_project, - architect_raises=True, - ) - - result = session._identify_affected_stages("Fix the key-vault configuration") - - assert 1 in result - - def test_no_architect_uses_regex(self, tmp_project): - from azext_prototype.stages.build_session import BuildSession - from azext_prototype.stages.build_state import BuildState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - registry = MagicMock() - registry.find_by_capability.return_value = [] - - build_state = BuildState(str(tmp_project)) - build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "", - "services": [{"name": "key-vault"}], - "status": "generated", - "files": [], - }, - ] - ) - - with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": "terraform", - "project.name": "test", - }.get(k, d) - mock_config.return_value.to_dict.return_value = { - "naming": {"strategy": "simple"}, - "project": {"name": "test"}, - } - session = BuildSession(ctx, registry, build_state=build_state) - - result = session._identify_affected_stages("Fix stage 1") - assert result == [1] - - def test_parse_stage_numbers_valid(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._parse_stage_numbers("[1, 2, 3]") == [1, 2, 3] - - def test_parse_stage_numbers_fenced(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._parse_stage_numbers("```json\n[2, 4]\n```") == [2, 4] - - def test_parse_stage_numbers_invalid(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._parse_stage_numbers("No stages found") == [] - - def test_parse_stage_numbers_deduplicates(self): - from azext_prototype.stages.build_session import BuildSession - - assert BuildSession._parse_stage_numbers("[1, 1, 3]") == [1, 3] - - -# ====================================================================== -# Blocked file filtering tests -# ====================================================================== - - -class TestBlockedFileFiltering: - """Tests for _write_stage_files() dropping blocked files like versions.tf.""" - - def _make_session(self, project_dir, iac_tool="terraform"): - from azext_prototype.stages.build_session import BuildSession - from azext_prototype.stages.build_state import BuildState - - ctx = AgentContext( - project_config={"project": {"iac_tool": iac_tool}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = MagicMock() - registry.find_by_capability.return_value = [] - - build_state = BuildState(str(project_dir)) - - with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": iac_tool, - "project.name": "test", - }.get(k, d) - mock_config.return_value.to_dict.return_value = { - "naming": {"strategy": "simple"}, - "project": {"name": "test"}, - } - session = BuildSession(ctx, registry, build_state=build_state) - - return session - - def test_versions_tf_dropped_for_terraform(self, tmp_project): - session = self._make_session(tmp_project, iac_tool="terraform") - content = ( - '```providers.tf\nterraform { required_version = ">= 1.0" }\n```\n\n' - "```versions.tf\n}\n```\n\n" - '```main.tf\nresource "null" "x" {}\n```\n' - ) - stage = {"dir": "concept/infra/terraform/stage-1", "stage": 1} - (tmp_project / "concept" / "infra" / "terraform" / "stage-1").mkdir(parents=True, exist_ok=True) - - written = session._write_stage_files(stage, content) - - filenames = [p.split("/")[-1] for p in written] - assert "providers.tf" in filenames - assert "main.tf" in filenames - assert "versions.tf" not in filenames - - def test_versions_tf_allowed_for_bicep(self, tmp_project): - """versions.tf is only blocked for terraform, not other tools.""" - session = self._make_session(tmp_project, iac_tool="bicep") - content = "```versions.tf\nsome content\n```\n" - stage = {"dir": "concept/infra/bicep/stage-1", "stage": 1} - (tmp_project / "concept" / "infra" / "bicep" / "stage-1").mkdir(parents=True, exist_ok=True) - - written = session._write_stage_files(stage, content) - - filenames = [p.split("/")[-1] for p in written] - assert "versions.tf" in filenames - - def test_normal_files_not_dropped(self, tmp_project): - session = self._make_session(tmp_project) - content = ( - '```main.tf\nresource "null" "x" {}\n```\n\n' - '```outputs.tf\noutput "id" { value = null_resource.x.id }\n```\n' - ) - stage = {"dir": "concept/infra/terraform/stage-1", "stage": 1} - (tmp_project / "concept" / "infra" / "terraform" / "stage-1").mkdir(parents=True, exist_ok=True) - - written = session._write_stage_files(stage, content) - assert len(written) == 2 - - def test_blocked_files_class_attribute(self): - from azext_prototype.stages.build_session import BuildSession - - assert "versions.tf" in BuildSession._BLOCKED_FILES["terraform"] - - -# ====================================================================== -# Terraform prompt reinforcement tests -# ====================================================================== - - -class TestTerraformPromptReinforcement: - """Verify the task prompt includes explicit Terraform file structure rules.""" - - def _make_session(self, project_dir): - from azext_prototype.stages.build_session import BuildSession - from azext_prototype.stages.build_state import BuildState - - ctx = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = MagicMock() - registry.find_by_capability.return_value = [] - - build_state = BuildState(str(project_dir)) - - with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": "terraform", - "project.name": "test", - }.get(k, d) - mock_config.return_value.to_dict.return_value = { - "naming": {"strategy": "simple"}, - "project": {"name": "test"}, - } - session = BuildSession(ctx, registry, build_state=build_state) - - return session - - def test_task_prompt_includes_file_structure(self, tmp_project): - session = self._make_session(tmp_project) - stage = { - "stage": 1, - "name": "Foundation", - "layer": "infra", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1", - "services": [], - "status": "pending", - "files": [], - } - # Need a mock IaC agent - mock_agent = MagicMock() - session._iac_agents["terraform"] = mock_agent - - agent, task = session._build_stage_task(stage, "some architecture", []) - - assert "Terraform File Structure" in task - assert "DO NOT create versions.tf" in task - assert "providers.tf" in task - assert "ONLY file that may contain a terraform {} block" in task - - -# ====================================================================== -# Terraform validation during build QA -# ====================================================================== - -# ====================================================================== -# QA Engineer prompt tests -# ====================================================================== - - -class TestQAPromptTerraformChecklist: - """Verify the QA engineer prompt includes the Terraform File Structure checklist.""" - - def test_qa_prompt_contains_terraform_file_structure(self): - from azext_prototype.agents.builtin.qa_engineer import QA_ENGINEER_PROMPT - - assert "Terraform File Structure" in QA_ENGINEER_PROMPT - assert "versions.tf" in QA_ENGINEER_PROMPT - assert "providers.tf" in QA_ENGINEER_PROMPT - assert "empty" in QA_ENGINEER_PROMPT - assert "syntactically valid HCL" in QA_ENGINEER_PROMPT - - -# ====================================================================== -# Per-stage QA tests -# ====================================================================== - - -class TestPerStageQA: - """Test _run_stage_qa() and _collect_stage_file_content().""" - - def _make_session(self, project_dir, qa_response="No issues found.", iac_tool="terraform"): - from azext_prototype.stages.build_session import BuildSession - from azext_prototype.stages.build_state import BuildState - - ctx = AgentContext( - project_config={"project": {"iac_tool": iac_tool, "name": "test"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - - qa_agent = MagicMock() - qa_agent.name = "qa-engineer" - - tf_agent = MagicMock() - tf_agent.name = "terraform-agent" - tf_agent.execute.return_value = _make_file_response( - "main.tf", 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' - ) - - registry = MagicMock() - - def find_by_cap(cap): - if cap == AgentCapability.QA: - return [qa_agent] - if cap == AgentCapability.TERRAFORM: - return [tf_agent] - if cap == AgentCapability.ARCHITECT: - return [] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - build_state = BuildState(str(project_dir)) - - with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": iac_tool, - "project.name": "test", - }.get(k, d) - mock_config.return_value.to_dict.return_value = { - "naming": {"strategy": "simple"}, - "project": {"name": "test"}, - } - session = BuildSession(ctx, registry, build_state=build_state) - - return session, qa_agent, tf_agent - - def test_per_stage_qa_passes_clean(self, tmp_project): - session, qa_agent, tf_agent = self._make_session(tmp_project) - - stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text( - 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' - ) - - stage = { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1", - "files": ["concept/infra/terraform/stage-1/main.tf"], - "status": "generated", - "services": [], - } - - printed = [] - - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response( - "All looks good. Code is clean and well-structured." - ) - session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) - - output = "\n".join(printed) - assert "passed QA" in output - - def test_per_stage_qa_triggers_remediation(self, tmp_project): - session, qa_agent, tf_agent = self._make_session(tmp_project) - - stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text( - 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' - ) - - stage = { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1", - "files": ["concept/infra/terraform/stage-1/main.tf"], - "status": "generated", - "services": [], - } - session._build_state.set_deployment_plan([stage]) - - printed = [] - call_count = [0] - - def mock_delegate(**kwargs): - call_count[0] += 1 - if call_count[0] == 1: - return _make_response("CRITICAL: Missing managed identity config. Must fix.") - return _make_response("All resolved, no remaining issues.") - - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.side_effect = mock_delegate - session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) - - output = "\n".join(printed) - assert "remediating" in output.lower() - # QA was called at least twice (initial + re-review) - assert call_count[0] >= 2 - - def test_per_stage_qa_max_attempts(self, tmp_project): - pass - - session, qa_agent, tf_agent = self._make_session(tmp_project) - - stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text( - 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' - ) - - stage = { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1", - "files": ["concept/infra/terraform/stage-1/main.tf"], - "status": "generated", - "services": [], - } - session._build_state.set_deployment_plan([stage]) - - printed = [] - - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - # Always return issues - mock_orch.return_value.delegate.return_value = _make_response("CRITICAL: This will never be fixed.") - session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) - - output = "\n".join(printed) - assert "issues remain" in output.lower() - - def test_per_stage_qa_skips_docs_stages(self, tmp_project): - """Docs capability stages should not get QA review during Phase 3.""" - # This tests the gating in the Phase 3 loop, not _run_stage_qa itself - stage = { - "stage": 5, - "name": "Documentation", - "capability": "docs", - "dir": "concept/docs", - "files": [], - "status": "generated", - "services": [], - } - # docs capability is not in ("infra", "data", "integration", "app") - assert stage["capability"] not in ("infra", "data", "integration", "app") - - def test_collect_stage_file_content(self, tmp_project): - session, _, _ = self._make_session(tmp_project) - - stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text('resource "null" "x" {}') - - stage = { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "files": ["concept/infra/terraform/stage-1/main.tf"], - } - - content = session._collect_stage_file_content(stage) - assert "main.tf" in content - assert 'resource "null" "x"' in content - - def test_collect_stage_file_content_empty(self, tmp_project): - session, _, _ = self._make_session(tmp_project) - stage = {"stage": 1, "name": "Foundation", "files": []} - content = session._collect_stage_file_content(stage) - assert content == "" - - -# ====================================================================== -# Advisory QA tests -# ====================================================================== - - -class TestAdvisoryQA: - """Test that Phase 4 is now advisory-only (no remediation).""" - - def _make_session(self, project_dir): - from azext_prototype.stages.build_session import BuildSession - from azext_prototype.stages.build_state import BuildState - - ctx = AgentContext( - project_config={"project": {"iac_tool": "terraform", "name": "test"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - - qa_agent = MagicMock() - qa_agent.name = "qa-engineer" - - tf_agent = MagicMock() - tf_agent.name = "terraform-agent" - tf_agent.execute.return_value = _make_file_response( - "main.tf", 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' - ) - - doc_agent = MagicMock() - doc_agent.name = "doc-agent" - doc_agent.execute.return_value = _make_file_response("README.md", "# Docs") - - architect_agent = MagicMock() - architect_agent.name = "cloud-architect" - - registry = MagicMock() - - def find_by_cap(cap): - if cap == AgentCapability.QA: - return [qa_agent] - if cap == AgentCapability.TERRAFORM: - return [tf_agent] - if cap == AgentCapability.ARCHITECT: - return [architect_agent] - if cap == AgentCapability.DOCUMENT: - return [doc_agent] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - build_state = BuildState(str(project_dir)) - - with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": "terraform", - "project.name": "test", - }.get(k, d) - mock_config.return_value.to_dict.return_value = { - "naming": {"strategy": "simple"}, - "project": {"name": "test"}, - } - session = BuildSession(ctx, registry, build_state=build_state) - - return session, qa_agent, tf_agent - - def test_advisory_qa_prompt_no_bug_hunting(self, tmp_project): - """Verify Phase 4 aggregates per-stage advisories (no AI call).""" - session, qa_agent, tf_agent = self._make_session(tmp_project) - - # Pre-populate with generated stages, files, and advisory - stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text('resource "null" "x" {}') - - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1", - "services": [], - "status": "generated", - "files": ["concept/infra/terraform/stage-1/main.tf"], - }, - ] - ) - # Pre-store advisory (as if per-stage advisory already ran) - session._build_state.set_stage_advisory(1, "- **[Scalability]** Consider upgrading SKUs for production.") - # Set design snapshot so run() sees no design changes - session._build_state.set_design_snapshot({"architecture": "Simple architecture"}) - - printed = [] - inputs = iter(["done"]) - - with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: - mock_gov_cls.return_value.check_response_for_violations.return_value = [] - session._governance = mock_gov_cls.return_value - session._policy_resolver._governance = mock_gov_cls.return_value - - session.run( - design={"architecture": "Simple architecture"}, - input_fn=lambda p: next(inputs), - print_fn=lambda m: printed.append(m), - ) - - output = "\n".join(printed) - assert "Advisory notes from 1 stages saved to" in output - # Verify ADVISORY.md was written - advisory_path = tmp_project / "concept" / "docs" / "ADVISORY.md" - assert advisory_path.exists() - content = advisory_path.read_text() - assert "Scalability" in content - assert "Stage 1: Foundation" in content - - def test_advisory_qa_no_remediation_loop(self, tmp_project): - """Phase 4 should NOT trigger _identify_affected_stages or IaC regen.""" - session, qa_agent, tf_agent = self._make_session(tmp_project) - - stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text('resource "null" "x" {}') - - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1", - "services": [], - "status": "generated", - "files": ["concept/infra/terraform/stage-1/main.tf"], - }, - ] - ) - - inputs = iter(["", "done"]) - - with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: - mock_gov_cls.return_value.check_response_for_violations.return_value = [] - session._governance = mock_gov_cls.return_value - session._policy_resolver._governance = mock_gov_cls.return_value - - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - # Return warnings — in old code this would trigger remediation - mock_orch.return_value.delegate.return_value = _make_response( - "WARNING: Missing monitoring. CRITICAL: No backup config." - ) - - with patch.object(session, "_identify_affected_stages") as mock_identify: - session.run( - design={"architecture": "Simple architecture"}, - input_fn=lambda p: next(inputs), - print_fn=lambda m: None, - ) - - # _identify_affected_stages should NOT have been called during Phase 4 - mock_identify.assert_not_called() - - def test_advisory_qa_header_says_advisory(self, tmp_project): - """Output should contain 'Advisory notes' not 'QA Review'.""" - session, qa_agent, tf_agent = self._make_session(tmp_project) - - stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text('resource "null" "x" {}') - - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "dir": "concept/infra/terraform/stage-1", - "services": [], - "status": "generated", - "files": ["concept/infra/terraform/stage-1/main.tf"], - }, - ] - ) - session._build_state.set_stage_advisory(1, "- **[Cost]** Basic SKU is cheap but limited.") - session._build_state.set_design_snapshot({"architecture": "Simple architecture"}) - - printed = [] - inputs = iter(["done"]) - - with patch("azext_prototype.stages.build_session.GovernanceContext") as mock_gov_cls: - mock_gov_cls.return_value.check_response_for_violations.return_value = [] - session._governance = mock_gov_cls.return_value - session._policy_resolver._governance = mock_gov_cls.return_value - - session.run( - design={"architecture": "Simple architecture"}, - input_fn=lambda p: next(inputs), - print_fn=lambda m: printed.append(m), - ) - - output = "\n".join(printed) - assert "Advisory notes" in output - # Should NOT contain "QA Review:" as a section header - assert "QA Review:" not in output - - -# ====================================================================== -# Stable ID tests -# ====================================================================== - - -class TestStableIds: - - def test_stable_ids_assigned_on_set_deployment_plan(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, - {"stage": 2, "name": "Data Layer", "capability": "data", "services": [], "status": "pending", "files": []}, - ] - bs.set_deployment_plan(stages) - - for s in bs.state["deployment_stages"]: - assert "id" in s - assert s["id"] # non-empty - assert bs.state["deployment_stages"][0]["id"] == "foundation" - assert bs.state["deployment_stages"][1]["id"] == "data-layer" - - def test_stable_ids_preserved_on_renumber(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, - {"stage": 2, "name": "Data Layer", "capability": "data", "services": [], "status": "pending", "files": []}, - ] - bs.set_deployment_plan(stages) - - original_ids = [s["id"] for s in bs.state["deployment_stages"]] - bs.renumber_stages() - new_ids = [s["id"] for s in bs.state["deployment_stages"]] - assert original_ids == new_ids - - def test_stable_ids_unique_on_name_collision(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, - {"stage": 2, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, - ] - bs.set_deployment_plan(stages) - - ids = [s["id"] for s in bs.state["deployment_stages"]] - assert len(set(ids)) == 2 # all unique - assert ids[0] == "foundation" - assert ids[1] == "foundation-2" - - def test_stable_ids_backfilled_on_load(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - # Write a legacy state file without ids - state_dir = Path(str(tmp_project)) / ".prototype" / "state" - state_dir.mkdir(parents=True, exist_ok=True) - legacy = { - "deployment_stages": [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "files": [], - }, - ], - "templates_used": [], - "iac_tool": "terraform", - "_metadata": {"created": None, "last_updated": None, "iteration": 0}, - } - with open(state_dir / "build.yaml", "w") as f: - yaml.dump(legacy, f) - - bs = BuildState(str(tmp_project)) - bs.load() - assert bs.state["deployment_stages"][0]["id"] == "foundation" - assert bs.state["deployment_stages"][0]["deploy_mode"] == "auto" - - def test_get_stage_by_id(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": [], "status": "pending", "files": []}, - {"stage": 2, "name": "Data Layer", "capability": "data", "services": [], "status": "pending", "files": []}, - ] - bs.set_deployment_plan(stages) - - found = bs.get_stage_by_id("data-layer") - assert found is not None - assert found["name"] == "Data Layer" - assert bs.get_stage_by_id("nonexistent") is None - - def test_deploy_mode_in_stage_schema(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - stages = [ - { - "stage": 1, - "name": "Manual Upload", - "capability": "external", - "services": [], - "status": "pending", - "files": [], - "deploy_mode": "manual", - "manual_instructions": "Upload the notebook to the Fabric workspace.", - }, - { - "stage": 2, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "pending", - "files": [], - }, - ] - bs.set_deployment_plan(stages) - - assert bs.state["deployment_stages"][0]["deploy_mode"] == "manual" - assert "Upload" in bs.state["deployment_stages"][0]["manual_instructions"] - assert bs.state["deployment_stages"][1]["deploy_mode"] == "auto" - assert bs.state["deployment_stages"][1]["manual_instructions"] is None - - def test_add_stages_assigns_ids(self, tmp_project): - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "pending", - "files": [], - }, - ] - ) - bs.add_stages( - [ - {"name": "API Layer", "capability": "app"}, - ] - ) - ids = [s["id"] for s in bs.state["deployment_stages"]] - assert "api-layer" in ids - - -# ====================================================================== -# _get_app_scaffolding_requirements tests -# ====================================================================== - - -class TestGetAppScaffoldingRequirements: - """Tests for _get_app_scaffolding_requirements static method.""" - - def test_infra_capability_returns_empty(self): - from azext_prototype.stages.build_session import BuildSession - - result = BuildSession._get_app_scaffolding_requirements({"layer": "infra", "capability": "infra", "services": []}) - assert result == "" - - def test_data_capability_returns_empty(self): - from azext_prototype.stages.build_session import BuildSession - - result = BuildSession._get_app_scaffolding_requirements({"layer": "data", "capability": "data", "services": []}) - assert result == "" - - def test_docs_capability_returns_empty(self): - from azext_prototype.stages.build_session import BuildSession - - result = BuildSession._get_app_scaffolding_requirements({"layer": "docs", "capability": "docs", "services": []}) - assert result == "" - - def test_functions_detected_by_resource_type(self): - from azext_prototype.stages.build_session import BuildSession - - stage = { - "layer": "app", - "capability": "app", - "services": [{"name": "api", "resource_type": "Microsoft.Web/functionapps"}], - } - result = BuildSession._get_app_scaffolding_requirements(stage) - assert "host.json" in result - assert ".csproj" in result - - def test_functions_detected_by_name(self): - from azext_prototype.stages.build_session import BuildSession - - stage = { - "layer": "app", - "capability": "app", - "services": [{"name": "function-app", "resource_type": ""}], - } - result = BuildSession._get_app_scaffolding_requirements(stage) - assert "host.json" in result - - def test_webapp_without_language_hint_gets_generic(self): - """Webapp resource type without a language hint falls back to generic.""" - from azext_prototype.stages.build_session import BuildSession - - stage = { - "layer": "app", - "capability": "app", - "services": [{"name": "api", "resource_type": "Microsoft.Web/sites"}], - } - result = BuildSession._get_app_scaffolding_requirements(stage) - assert "Required Project Files" in result - assert "Entry point" in result - - def test_webapp_with_framework_hint_detected(self): - """Webapp with a framework name in the service name returns framework-specific scaffolding.""" - from azext_prototype.stages.build_session import BuildSession - - stage = { - "layer": "app", - "capability": "app", - "services": [{"name": "api-fastapi", "resource_type": "Microsoft.App/containerApps"}], - } - result = BuildSession._get_app_scaffolding_requirements(stage) - assert "FastAPI" in result - assert "requirements.txt" in result - assert "Dockerfile" in result - - def test_generic_app_fallback(self): - from azext_prototype.stages.build_session import BuildSession - - stage = { - "layer": "app", - "capability": "app", - "services": [{"name": "worker", "resource_type": ""}], - } - result = BuildSession._get_app_scaffolding_requirements(stage) - assert "Required Project Files" in result - assert "Entry point" in result - - def test_schema_capability_triggers_scaffolding(self): - from azext_prototype.stages.build_session import BuildSession - - stage = { - "layer": "app", - "capability": "schema", - "services": [{"name": "db-migration", "resource_type": ""}], - } - result = BuildSession._get_app_scaffolding_requirements(stage) - assert "Required Project Files" in result - - def test_external_capability_triggers_scaffolding(self): - from azext_prototype.stages.build_session import BuildSession - - stage = { - "layer": "app", - "capability": "external", - "services": [{"name": "stripe-integration", "resource_type": ""}], - } - result = BuildSession._get_app_scaffolding_requirements(stage) - assert "Required Project Files" in result - - -# ====================================================================== -# _write_stage_files tests -# ====================================================================== - - -class TestWriteStageFiles: - """Tests for _write_stage_files edge cases.""" - - def test_empty_content_returns_empty(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - stage = {"dir": "concept/infra/terraform/stage-1-foundation"} - - result = session._write_stage_files(stage, "") - assert result == [] - - def test_no_file_blocks_returns_empty(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - stage = {"dir": "concept/infra/terraform/stage-1-foundation"} - - result = session._write_stage_files(stage, "This is just text with no code blocks.") - assert result == [] - - def test_writes_files_and_returns_paths(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - stage = {"dir": "concept/infra/terraform/stage-1-foundation"} - - content = '```main.tf\n# terraform code\n```\n\n```variables.tf\nvariable "name" {}\n```' - result = session._write_stage_files(stage, content) - - assert len(result) == 2 - # Files should exist on disk - project_root = Path(build_context.project_dir) - for rel_path in result: - assert (project_root / rel_path).exists() - - def test_strips_stage_dir_prefix_from_filenames(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - stage_dir = "concept/infra/terraform/stage-1-foundation" - stage = {"dir": stage_dir} - - # AI sometimes includes full path in filename - content = f"```{stage_dir}/main.tf\n# code\n```" - result = session._write_stage_files(stage, content) - - assert len(result) == 1 - # Should NOT create nested duplicate path - assert result[0] == f"{stage_dir}/main.tf" - - def test_blocks_versions_tf_for_terraform(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._iac_tool = "terraform" - stage = {"dir": "concept/infra/terraform/stage-1"} - - content = "```main.tf\n# main code\n```\n\n```versions.tf\n# should be blocked\n```" - result = session._write_stage_files(stage, content) - - filenames = [Path(p).name for p in result] - assert "main.tf" in filenames - assert "versions.tf" not in filenames - - def test_allows_versions_tf_for_bicep(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._iac_tool = "bicep" - stage = {"dir": "concept/infra/bicep/stage-1"} - - content = "```main.bicep\n# main code\n```\n\n```versions.tf\n# allowed for bicep\n```" - result = session._write_stage_files(stage, content) - - filenames = [Path(p).name for p in result] - assert "main.bicep" in filenames - assert "versions.tf" in filenames - - -# ====================================================================== -# _handle_describe tests -# ====================================================================== - - -class TestHandleDescribe: - """Tests for /describe slash command.""" - - def test_describe_valid_stage(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [ - { - "name": "key-vault", - "computed_name": "zd-kv-dev", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - }, - ], - "status": "generated", - "dir": "concept/infra/terraform/stage-1", - "files": ["main.tf", "variables.tf"], - }, - ] - ) - - printed = [] - session._handle_describe("1", lambda m: printed.append(m)) - output = "\n".join(printed) - - assert "Foundation" in output - assert "infra" in output - assert "zd-kv-dev" in output - assert "Microsoft.KeyVault/vaults" in output - assert "standard" in output - assert "main.tf" in output - - def test_describe_stage_not_found(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "pending", - "dir": "", - "files": [], - }, - ] - ) - - printed = [] - session._handle_describe("99", lambda m: printed.append(m)) - output = "\n".join(printed) - - assert "not found" in output.lower() - - def test_describe_no_arg(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - printed = [] - session._handle_describe("", lambda m: printed.append(m)) - output = "\n".join(printed) - - assert "Usage" in output - - def test_describe_non_numeric(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - printed = [] - session._handle_describe("abc", lambda m: printed.append(m)) - output = "\n".join(printed) - - assert "Usage" in output - - -# ====================================================================== -# _clean_removed_stage_files tests -# ====================================================================== - - -class TestCleanRemovedStageFiles: - """Tests for _clean_removed_stage_files.""" - - def test_removes_existing_directory(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - # Create the directory with a file - stage_dir = Path(build_context.project_dir) / "concept" / "infra" / "terraform" / "stage-2-data" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text("# data stage", encoding="utf-8") - assert stage_dir.exists() - - stages = [ - {"stage": 2, "dir": "concept/infra/terraform/stage-2-data"}, - ] - session._clean_removed_stage_files([2], stages) - - assert not stage_dir.exists() - - def test_ignores_nonexistent_directory(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stages = [ - {"stage": 2, "dir": "concept/infra/terraform/stage-2-nonexistent"}, - ] - # Should not raise - session._clean_removed_stage_files([2], stages) - - def test_ignores_stage_not_in_removed_list(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stage_dir = Path(build_context.project_dir) / "concept" / "infra" / "terraform" / "stage-1-foundation" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text("# keep this", encoding="utf-8") - - stages = [ - {"stage": 1, "dir": "concept/infra/terraform/stage-1-foundation"}, - {"stage": 2, "dir": "concept/infra/terraform/stage-2-data"}, - ] - # Only remove stage 2, not stage 1 - session._clean_removed_stage_files([2], stages) - - assert stage_dir.exists() - - -# ====================================================================== -# _fix_stage_dirs tests -# ====================================================================== - - -class TestFixStageDirs: - """Tests for _fix_stage_dirs after stage renumbering.""" - - def test_renumbers_stage_dir_paths(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state._state["deployment_stages"] = [ - { - "stage": 1, - "name": "A", - "dir": "concept/infra/terraform/stage-1-foundation", - "capability": "infra", - "services": [], - "status": "generated", - "files": [], - }, - { - "stage": 2, - "name": "B", - "dir": "concept/infra/terraform/stage-4-data", - "capability": "data", - "services": [], - "status": "pending", - "files": [], - }, - ] - - session._fix_stage_dirs() - - stages = session._build_state._state["deployment_stages"] - assert stages[0]["dir"] == "concept/infra/terraform/stage-1-foundation" - assert stages[1]["dir"] == "concept/infra/terraform/stage-2-data" - - def test_skips_empty_dirs(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state._state["deployment_stages"] = [ - {"stage": 1, "name": "A", "dir": "", "capability": "infra", "services": [], "status": "pending", "files": []}, - ] - - # Should not raise - session._fix_stage_dirs() - - assert session._build_state._state["deployment_stages"][0]["dir"] == "" - - -# ====================================================================== -# _build_stage_task bicep branch tests -# ====================================================================== - - -class TestBuildStageTaskBicep: - """Tests for _build_stage_task with bicep IaC tool.""" - - def test_bicep_capability_infra(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - # Create a registry that has a bicep agent - mock_bicep_agent = MagicMock() - mock_bicep_agent.name = "bicep-agent" - mock_bicep_agent._governor_brief = "" - - def find_by_cap(cap): - if cap == AgentCapability.BICEP: - return [mock_bicep_agent] - if cap == AgentCapability.TERRAFORM: - return [] - return [] - - registry = MagicMock() - registry.find_by_capability.side_effect = find_by_cap - - # Override iac_tool in config - config_path = Path(build_context.project_dir) / "prototype.yaml" - import yaml - - with open(config_path) as f: - cfg = yaml.safe_load(f) - cfg["project"]["iac_tool"] = "bicep" - with open(config_path, "w") as f: - yaml.dump(cfg, f) - - session = BuildSession(build_context, registry) - - stage = { - "stage": 1, - "name": "Foundation", - "layer": "infra", - "capability": "infra", - "services": [ - { - "name": "key-vault", - "computed_name": "zd-kv-dev", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - } - ], - "dir": "concept/infra/bicep/stage-1-foundation", - } - - agent, task = session._build_stage_task(stage, "architecture", []) - - assert agent is mock_bicep_agent - assert "consistent deployment naming (Bicep)" in task - assert "Terraform File Structure" not in task - - def test_app_stage_includes_scaffolding(self, build_context, build_registry, mock_dev_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_dev_agent._governor_brief = "" - - stage = { - "stage": 2, - "name": "API", - "layer": "app", - "capability": "app", - "services": [ - { - "name": "container-app-api", - "resource_type": "Microsoft.App/containerApps", - "computed_name": "api-1", - "sku": "", - } - ], - "dir": "concept/apps/stage-2-api", - } - - _, task = session._build_stage_task(stage, "architecture", []) - - assert "Required Project Files" in task - assert "Dockerfile" in task - - -# ====================================================================== -# _collect_stage_file_content edge case tests -# ====================================================================== - - -class TestCollectStageFileContentEdgeCases: - """Additional tests for _collect_stage_file_content.""" - - def test_unreadable_file(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stage = {"files": ["nonexistent/file.tf"]} - result = session._collect_stage_file_content(stage) - - assert "could not read file" in result - - def test_large_file_not_truncated(self, build_context, build_registry): - """QA must see the full file — no per-file truncation.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - file_path = Path(build_context.project_dir) / "big.tf" - file_path.write_text("x" * 20000, encoding="utf-8") - - stage = {"files": ["big.tf"]} - result = session._collect_stage_file_content(stage) - - assert "truncated" not in result - assert "x" * 20000 in result - - def test_many_files_all_included(self, build_context, build_registry): - """QA must see all files — no total size cap.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - for i in range(10): - f = Path(build_context.project_dir) / f"file{i}.tf" - f.write_text(f"content_{i}" * 500, encoding="utf-8") - - stage = {"files": [f"file{i}.tf" for i in range(10)]} - result = session._collect_stage_file_content(stage) - - assert "omitted" not in result - for i in range(10): - assert f"file{i}.tf" in result - - def test_no_files_returns_empty(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stage = {"files": []} - result = session._collect_stage_file_content(stage) - assert result == "" - - -# ====================================================================== -# _collect_generated_file_content tests -# ====================================================================== - - -class TestCollectGeneratedFileContent: - """Tests for _collect_generated_file_content.""" - - def test_collects_from_generated_stages(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - # Create a file - stage_dir = Path(build_context.project_dir) / "concept" / "infra" / "terraform" / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text("# tf code", encoding="utf-8") - - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "concept/infra/terraform/stage-1", - "files": ["concept/infra/terraform/stage-1/main.tf"], - }, - ] - ) - - result = session._collect_generated_file_content() - assert "main.tf" in result - assert "tf code" in result - - def test_empty_when_no_generated_stages(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "pending", - "dir": "", - "files": [], - }, - ] - ) - - result = session._collect_generated_file_content() - assert result == "" - - -# ====================================================================== -# Naming strategy fallback tests -# ====================================================================== - - -class TestNamingStrategyFallback: - """Tests for the naming strategy fallback in __init__.""" - - def test_naming_fallback_on_invalid_config(self, project_with_design, sample_config): - """When naming config is invalid, should fall back to simple strategy.""" - from azext_prototype.stages.build_session import BuildSession - - # Corrupt the naming config - sample_config["naming"]["strategy"] = "nonexistent-strategy" - - provider = MagicMock() - provider.provider_name = "github-models" - provider.chat.return_value = _make_response() - - context = AgentContext( - project_config=sample_config, - project_dir=str(project_with_design), - ai_provider=provider, - ) - - registry = MagicMock() - registry.find_by_capability.return_value = [] - - # Should not raise — falls back to simple strategy - session = BuildSession(context, registry) - assert session._naming is not None - - -# ====================================================================== -# _identify_stages_via_architect edge cases -# ====================================================================== - - -class TestIdentifyStagesViaArchitect: - """Tests for _identify_stages_via_architect edge cases.""" - - def test_empty_deployment_stages_returns_empty(self, build_context, build_registry, mock_architect_agent_for_build): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - # No deployment stages set - session._build_state._state["deployment_stages"] = [] - - result = session._identify_stages_via_architect("fix the key vault") - assert result == [] - - def test_parse_stage_numbers_json_error(self): - from azext_prototype.stages.build_session import BuildSession - - # Invalid JSON within brackets - result = BuildSession._parse_stage_numbers("[1, 2, invalid]") - assert result == [] - - def test_parse_stage_numbers_no_match(self): - from azext_prototype.stages.build_session import BuildSession - - result = BuildSession._parse_stage_numbers("no numbers here at all") - assert result == [] - - -# ====================================================================== -# _identify_stages_regex edge cases -# ====================================================================== - - -class TestIdentifyStagesRegex: - """Tests for _identify_stages_regex fallback paths.""" - - def test_regex_last_resort_all_generated(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [{"name": "key-vault"}], - "status": "generated", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "Data", - "capability": "data", - "services": [{"name": "cosmos-db"}], - "status": "generated", - "dir": "", - "files": [], - }, - { - "stage": 3, - "name": "Pending", - "capability": "app", - "services": [], - "status": "pending", - "dir": "", - "files": [], - }, - ] - ) - - # Feedback that doesn't match any stage name, service, or number - result = session._identify_stages_regex("completely unrelated feedback about something else entirely") - # Last resort: returns all generated stages - assert result == [1, 2] - - def test_regex_matches_stage_name(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - { - "stage": 2, - "name": "Data", - "capability": "data", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - ] - ) - - result = session._identify_stages_regex("The foundation stage needs more resources") - assert result == [1] - - -# ====================================================================== -# _run_stage_qa edge cases -# ====================================================================== - - -class TestRunStageQAEdgeCases: - """Tests for _run_stage_qa early returns.""" - - def test_no_qa_agent_skips(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._qa_agent = None - - stage = { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - } - - # Should not raise - session._run_stage_qa(stage, "arch", [], False, lambda m: None) - - def test_no_file_content_skips(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stage = { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "status": "generated", - "dir": "", - "files": [], - } - - # No files means no QA review needed - session._run_stage_qa(stage, "arch", [], False, lambda m: None) - - -# ====================================================================== -# _maybe_spinner tests -# ====================================================================== - - -class TestMaybeSpinner: - """Tests for _maybe_spinner context manager.""" - - def test_plain_mode_just_yields(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - executed = False - with session._maybe_spinner("Processing...", use_styled=False): - executed = True - assert executed - - def test_status_fn_mode(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - calls = [] - session = BuildSession(build_context, build_registry, status_fn=lambda msg, kind: calls.append((msg, kind))) - - with session._maybe_spinner("Building...", use_styled=False): - pass - - # Should have called status_fn with "start" and "end" - assert any(k == "start" for _, k in calls) - assert any(k == "end" for _, k in calls) - - def test_status_fn_mode_with_exception(self, build_context, build_registry): - from azext_prototype.stages.build_session import BuildSession - - calls = [] - session = BuildSession(build_context, build_registry, status_fn=lambda msg, kind: calls.append((msg, kind))) - - try: - with session._maybe_spinner("Building...", use_styled=False): - raise ValueError("test") - except ValueError: - pass - - # Even on exception, "end" should be called (finally block) - assert any(k == "end" for _, k in calls) - - -# ====================================================================== -# _apply_governor_brief tests -# ====================================================================== - - -class TestApplyGovernorBrief: - """Tests for _apply_governor_brief.""" - - def test_sets_brief_on_agent(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_governor_brief = MagicMock() - - with patch("azext_prototype.governance.governor.brief", return_value="MUST use managed identity"): - session._apply_governor_brief(mock_tf_agent, "Foundation", [{"name": "key-vault"}]) - - mock_tf_agent.set_governor_brief.assert_called_once_with("MUST use managed identity") - - def test_empty_brief_not_set(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_governor_brief = MagicMock() - - with patch("azext_prototype.governance.governor.brief", return_value=""): - session._apply_governor_brief(mock_tf_agent, "Foundation", []) - - mock_tf_agent.set_governor_brief.assert_not_called() - - def test_exception_silently_caught(self, build_context, build_registry, mock_tf_agent): - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_governor_brief = MagicMock() - - with patch("azext_prototype.governance.governor.brief", side_effect=Exception("boom")): - # Should not raise - session._apply_governor_brief(mock_tf_agent, "Foundation", []) - - mock_tf_agent.set_governor_brief.assert_not_called() - - -# ====================================================================== -# TestBuildSessionRefactored — targeted coverage for refactored helpers -# ====================================================================== - - -class TestBuildSessionRefactored: - """Additional coverage for _agent_build_context, _select_agent, - _apply_stage_knowledge, and _condense_architecture. - - Complements the existing per-class tests to ensure all code paths are - exercised. - """ - - # ------------------------------------------------------------------ # - # _agent_build_context - # ------------------------------------------------------------------ # - - def test_agent_build_context_disables_standards_and_restores(self, build_context, build_registry, mock_tf_agent): - """Context manager must disable standards inside and restore on exit.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent._include_standards = True - mock_tf_agent.set_knowledge_override = MagicMock() - mock_tf_agent.set_governor_brief = MagicMock() - - stage = {"name": "Foundation", "services": []} - - with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): - with session._agent_build_context(mock_tf_agent, stage): - # Standards remain enabled (agent-scoped filtering via applies_to) - assert mock_tf_agent._include_standards is True - - assert mock_tf_agent._include_standards is True - - def test_agent_build_context_calls_apply_governor_brief(self, build_context, build_registry, mock_tf_agent): - """_apply_governor_brief should be called with correct args.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent._include_standards = False - mock_tf_agent.set_knowledge_override = MagicMock() - mock_tf_agent.set_governor_brief = MagicMock() - - stage = {"name": "Data Layer", "layer": "data", "services": [{"name": "cosmos-db"}]} - - with patch.object(session, "_apply_governor_brief") as mock_gov, patch.object( - session, "_apply_stage_knowledge" - ): - with session._agent_build_context(mock_tf_agent, stage): - pass - - mock_gov.assert_called_once_with(mock_tf_agent, "Data Layer", [{"name": "cosmos-db"}], "data") - - def test_agent_build_context_calls_apply_stage_knowledge(self, build_context, build_registry, mock_tf_agent): - """_apply_stage_knowledge should be called with agent and stage dict.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent._include_standards = False - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"name": "App", "services": []} - - with patch.object(session, "_apply_governor_brief"), patch.object( - session, "_apply_stage_knowledge" - ) as mock_know: - with session._agent_build_context(mock_tf_agent, stage): - pass - - mock_know.assert_called_once_with(mock_tf_agent, stage) - - def test_agent_build_context_clears_knowledge_override_on_exit(self, build_context, build_registry, mock_tf_agent): - """set_knowledge_override('') must be called in the finally block.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent._include_standards = False - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"name": "Docs", "services": []} - - with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): - with session._agent_build_context(mock_tf_agent, stage): - pass - - mock_tf_agent.set_knowledge_override.assert_called_with("") - - def test_agent_build_context_restores_on_exception(self, build_context, build_registry, mock_tf_agent): - """Standards flag and knowledge override are restored even if code raises.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent._include_standards = True - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"name": "Foundation", "services": []} - - with patch.object(session, "_apply_governor_brief"), patch.object(session, "_apply_stage_knowledge"): - try: - with session._agent_build_context(mock_tf_agent, stage): - raise RuntimeError("simulated failure") - except RuntimeError: - pass - - assert mock_tf_agent._include_standards is True - mock_tf_agent.set_knowledge_override.assert_called_with("") - - # ------------------------------------------------------------------ # - # _select_agent - # ------------------------------------------------------------------ # - - def test_select_agent_infra_capability(self, build_context, build_registry, mock_tf_agent): - """Infra capability should resolve to the IaC (terraform) agent.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "infra"}) - assert agent is mock_tf_agent - - def test_select_agent_app_capability(self, build_context, build_registry, mock_dev_agent): - """App capability should resolve to the developer agent.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "app"}) - assert agent is mock_dev_agent - - def test_select_agent_docs_capability(self, build_context, build_registry, mock_doc_agent): - """Docs capability should resolve to the doc agent.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "docs"}) - assert agent is mock_doc_agent - - def test_select_agent_unknown_falls_back_to_iac(self, build_context, build_registry, mock_tf_agent): - """Unknown capability falls back to IaC agent, then dev agent.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - agent = session._select_agent({"capability": "foobar"}) - assert agent is mock_tf_agent - - def test_select_agent_unknown_falls_back_to_dev_when_no_iac(self, build_context, build_registry, mock_dev_agent): - """When no IaC agent exists, unknown capability falls back to dev agent.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - session._iac_agents = {} - agent = session._select_agent({"capability": "foobar"}) - assert agent is mock_dev_agent - - # ------------------------------------------------------------------ # - # _apply_stage_knowledge - # ------------------------------------------------------------------ # - - def test_apply_stage_knowledge_passes_svc_names_to_loader(self, build_context, build_registry, mock_tf_agent): - """Service names are extracted from stage and passed to KnowledgeLoader.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"services": [{"name": "key-vault"}, {"name": "sql-server"}]} - - mock_loader = MagicMock() - mock_loader.compose_context.return_value = "knowledge text" - mock_knowledge_module = MagicMock() - mock_knowledge_module.KnowledgeLoader.return_value = mock_loader - - with patch.dict("sys.modules", {"azext_prototype.knowledge": mock_knowledge_module}): - session._apply_stage_knowledge(mock_tf_agent, stage) - - call_kwargs = mock_loader.compose_context.call_args[1] - assert "key-vault" in call_kwargs["services"] - assert "sql-server" in call_kwargs["services"] - - def test_apply_stage_knowledge_swallows_exceptions(self, build_context, build_registry, mock_tf_agent): - """Import or runtime errors must not propagate — generation must proceed.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - mock_tf_agent.set_knowledge_override = MagicMock() - - stage = {"services": [{"name": "key-vault"}]} - - with patch.dict("sys.modules", {"azext_prototype.knowledge": None}): - # Should not raise - session._apply_stage_knowledge(mock_tf_agent, stage) - - mock_tf_agent.set_knowledge_override.assert_not_called() - - # ------------------------------------------------------------------ # - # _condense_architecture - # ------------------------------------------------------------------ # - - def test_condense_architecture_returns_cached_contexts(self, build_context, build_registry): - """When stage_contexts cache is fully populated, no AI call should happen.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, - {"stage": 2, "name": "Data", "capability": "data", "services": []}, - ] - session._build_state._state["stage_contexts"] = { - "1": "## Stage 1: Foundation\nContext for stage 1", - "2": "## Stage 2: Data\nContext for stage 2", - } - - result = session._condense_architecture("arch", stages, use_styled=False) - - assert result[1] == "## Stage 1: Foundation\nContext for stage 1" - assert result[2] == "## Stage 2: Data\nContext for stage 2" - build_context.ai_provider.chat.assert_not_called() - - def test_condense_architecture_empty_response_returns_empty_dict(self, build_context, build_registry): - """Empty string response from AI provider yields empty mapping.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, - ] - - build_context.ai_provider.chat.return_value = _make_response("") - result = session._condense_architecture("arch", stages, use_styled=False) - - assert result == {} - - def test_condense_architecture_no_ai_provider_returns_empty_dict(self, build_context, build_registry): - """No AI provider means condensation can't run — return empty dict.""" - from azext_prototype.stages.build_session import BuildSession - - build_context.ai_provider = None - session = BuildSession(build_context, build_registry) - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, - ] - - result = session._condense_architecture("arch", stages, use_styled=False) - - assert result == {} - - def test_condense_architecture_parses_stage_contexts_from_response(self, build_context, build_registry): - """AI response with per-stage headings should be parsed into a mapping.""" - from azext_prototype.stages.build_session import BuildSession - - session = BuildSession(build_context, build_registry) - stages = [ - {"stage": 1, "name": "Foundation", "capability": "infra", "services": []}, - {"stage": 2, "name": "Data", "capability": "data", "services": []}, - ] - - ai_content = ( - "## Stage 1: Foundation\n" - "Builds resource group and managed identity.\n\n" - "## Stage 2: Data\n" - "Deploys Cosmos DB account.\n" - ) - build_context.ai_provider.chat.return_value = _make_response(ai_content) - - result = session._condense_architecture("architecture text", stages, use_styled=False) - - assert 1 in result - assert 2 in result - assert "Foundation" in result[1] - assert "Data" in result[2] diff --git a/tests/test_deploy_helpers.py b/tests/test_deploy_helpers.py deleted file mode 100644 index 085564f..0000000 --- a/tests/test_deploy_helpers.py +++ /dev/null @@ -1,477 +0,0 @@ -"""Tests for azext_prototype.stages.deploy_helpers.""" - -import json -from pathlib import Path -from unittest.mock import MagicMock, patch - -from azext_prototype.stages.deploy_helpers import ( - DEPLOY_ENV_MAPPING, - DeploymentOutputCapture, - DeployScriptGenerator, - RollbackManager, - build_deploy_env, - resolve_stage_secrets, - scan_tf_secret_variables, -) - - -class TestDeploymentOutputCapture: - """Test output capture and environment variable generation.""" - - def test_capture_and_retrieve(self, tmp_project): - capture = DeploymentOutputCapture(str(tmp_project)) - - # Simulate Bicep outputs - bicep_output = json.dumps( - { - "properties": { - "outputs": { - "resource_group_name": {"type": "string", "value": "zd-rg-api-dev-eus"}, - "storage_account_name": {"type": "string", "value": "stzddatadeveus"}, - } - } - } - ) - capture.capture_bicep(bicep_output) - - assert capture.get("resource_group_name") == "zd-rg-api-dev-eus" - assert capture.get("storage_account_name") == "stzddatadeveus" - assert capture.get("nonexistent", "fallback") == "fallback" - - def test_to_env_vars(self, tmp_project): - capture = DeploymentOutputCapture(str(tmp_project)) - - bicep_output = json.dumps( - { - "properties": { - "outputs": { - "resource_group_name": {"type": "string", "value": "rg-test"}, - "app_url": {"type": "string", "value": "https://myapp.azurewebsites.net"}, - } - } - } - ) - capture.capture_bicep(bicep_output) - - env_vars = capture.to_env_vars() - assert env_vars["PROTOTYPE_RESOURCE_GROUP_NAME"] == "rg-test" - assert env_vars["PROTOTYPE_APP_URL"] == "https://myapp.azurewebsites.net" - - def test_persistence(self, tmp_project): - # Write - capture1 = DeploymentOutputCapture(str(tmp_project)) - capture1._outputs["terraform"] = {"foo": "bar"} - capture1._save() - - # Read - capture2 = DeploymentOutputCapture(str(tmp_project)) - assert capture2.get("foo") == "bar" - - def test_get_all(self, tmp_project): - capture = DeploymentOutputCapture(str(tmp_project)) - assert isinstance(capture.get_all(), dict) - - def test_invalid_bicep_output(self, tmp_project): - capture = DeploymentOutputCapture(str(tmp_project)) - result = capture.capture_bicep("not-json") - assert result == {} - - -class TestDeployScriptGenerator: - """Test deploy script generation.""" - - def test_generate_webapp_script(self, tmp_path): - app_dir = tmp_path / "my-api" - app_dir.mkdir() - - script = DeployScriptGenerator.generate( - app_dir=app_dir, - app_name="my-api", - deploy_type="webapp", - resource_group="rg-test", - ) - - assert "#!/usr/bin/env bash" in script - assert "my-api" in script - assert "az webapp deploy" in script - assert (app_dir / "deploy.sh").exists() - - def test_generate_container_app_script(self, tmp_path): - app_dir = tmp_path / "my-app" - app_dir.mkdir() - - script = DeployScriptGenerator.generate( - app_dir=app_dir, - app_name="my-app", - deploy_type="container_app", - resource_group="rg-test", - registry="myregistry.azurecr.io", - ) - - assert "az acr build" in script - assert "az containerapp update" in script - assert "myregistry.azurecr.io" in script - - def test_generate_function_script(self, tmp_path): - app_dir = tmp_path / "my-func" - app_dir.mkdir() - - script = DeployScriptGenerator.generate( - app_dir=app_dir, - app_name="my-func", - deploy_type="function", - resource_group="rg-test", - ) - - assert "func azure functionapp publish" in script - assert "my-func" in script - - -class TestRollbackManager: - """Test rollback tracking and instructions.""" - - def test_snapshot_before_deploy(self, tmp_project): - mgr = RollbackManager(str(tmp_project)) - snapshot = mgr.snapshot_before_deploy("infra", "terraform") - - assert snapshot["scope"] == "infra" - assert snapshot["iac_tool"] == "terraform" - assert "timestamp" in snapshot - - def test_multiple_snapshots(self, tmp_project): - mgr = RollbackManager(str(tmp_project)) - mgr.snapshot_before_deploy("infra", "terraform") - mgr.snapshot_before_deploy("apps", "terraform") - - latest = mgr.get_last_snapshot() - assert latest["scope"] == "apps" - - def test_rollback_instructions_terraform(self, tmp_project): - mgr = RollbackManager(str(tmp_project)) - mgr.snapshot_before_deploy("infra", "terraform") - - instructions = mgr.get_rollback_instructions() - assert any("terraform" in line.lower() for line in instructions) - - def test_rollback_instructions_bicep(self, tmp_project): - mgr = RollbackManager(str(tmp_project)) - mgr.snapshot_before_deploy("infra", "bicep") - - instructions = mgr.get_rollback_instructions() - assert any("bicep" in line.lower() or "deployment" in line.lower() for line in instructions) - - def test_no_snapshots(self, tmp_project): - mgr = RollbackManager(str(tmp_project)) - assert mgr.get_last_snapshot() is None - - instructions = mgr.get_rollback_instructions() - assert len(instructions) >= 1 # Should have "nothing to roll back" message - - def test_persistence(self, tmp_project): - mgr1 = RollbackManager(str(tmp_project)) - mgr1.snapshot_before_deploy("infra", "terraform") - - mgr2 = RollbackManager(str(tmp_project)) - assert mgr2.get_last_snapshot() is not None - assert mgr2.get_last_snapshot()["scope"] == "infra" - - -class TestDeployEnvMapping: - """Tests for DEPLOY_ENV_MAPPING and build_deploy_env().""" - - def test_mapping_covers_all_params(self): - """Every build_deploy_env parameter has a mapping entry.""" - assert "subscription" in DEPLOY_ENV_MAPPING - assert "tenant" in DEPLOY_ENV_MAPPING - assert "client_id" in DEPLOY_ENV_MAPPING - assert "client_secret" in DEPLOY_ENV_MAPPING - - def test_mapping_includes_tf_var(self): - """Each param maps to at least one TF_VAR_* entry.""" - for param, keys in DEPLOY_ENV_MAPPING.items(): - tf_vars = [k for k in keys if k.startswith("TF_VAR_")] - assert tf_vars, f"{param} has no TF_VAR_* mapping" - - def test_mapping_includes_arm(self): - """Each param maps to at least one ARM_* entry.""" - for param, keys in DEPLOY_ENV_MAPPING.items(): - arm_vars = [k for k in keys if k.startswith("ARM_")] - assert arm_vars, f"{param} has no ARM_* mapping" - - def test_all_fields(self): - env = build_deploy_env("sub-123", "tenant-456", "client-id", "secret") - # ARM vars - assert env["ARM_SUBSCRIPTION_ID"] == "sub-123" - assert env["ARM_TENANT_ID"] == "tenant-456" - assert env["ARM_CLIENT_ID"] == "client-id" - assert env["ARM_CLIENT_SECRET"] == "secret" - # TF_VAR vars (auto-resolve HCL variables) - assert env["TF_VAR_subscription_id"] == "sub-123" - assert env["TF_VAR_tenant_id"] == "tenant-456" - assert env["TF_VAR_client_id"] == "client-id" - assert env["TF_VAR_client_secret"] == "secret" - # Legacy - assert env["SUBSCRIPTION_ID"] == "sub-123" - - def test_subscription_only(self): - env = build_deploy_env("sub-123") - assert env["ARM_SUBSCRIPTION_ID"] == "sub-123" - assert env["TF_VAR_subscription_id"] == "sub-123" - assert env["SUBSCRIPTION_ID"] == "sub-123" - assert "ARM_TENANT_ID" not in env - assert "TF_VAR_tenant_id" not in env - assert "ARM_CLIENT_ID" not in env - - def test_inherits_os_environ(self): - env = build_deploy_env("sub-123") - # PATH should be inherited from os.environ - assert "PATH" in env - - def test_empty(self): - env = build_deploy_env() - assert "ARM_SUBSCRIPTION_ID" not in env - assert "TF_VAR_subscription_id" not in env - assert "ARM_TENANT_ID" not in env - # Should still have os.environ entries - assert "PATH" in env - - -class TestDeployEnvPassing: - """Tests that verify env is passed through to subprocess calls.""" - - @patch("subprocess.run") - def test_deploy_terraform_passes_env(self, mock_run): - from azext_prototype.stages.deploy_helpers import deploy_terraform - - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - test_env = build_deploy_env("sub-123", "tenant-456") - - deploy_terraform(Path("/tmp/fake"), "sub-123", env=test_env) - - # All subprocess.run calls should receive env=test_env - for c in mock_run.call_args_list: - assert c.kwargs.get("env") is test_env - - @patch("subprocess.run") - def test_deploy_bicep_adds_tenant_flag(self, mock_run): - from azext_prototype.stages.deploy_helpers import deploy_bicep - - mock_run.return_value = MagicMock(returncode=0, stdout="{}", stderr="") - infra_dir = Path("/tmp/fake") - test_env = build_deploy_env("sub-123", "tenant-456") - - # Create a mock bicep file - with patch.object(Path, "exists", return_value=True), patch.object(Path, "glob", return_value=[]), patch( - "azext_prototype.stages.deploy_helpers.find_bicep_params", return_value=None - ), patch("azext_prototype.stages.deploy_helpers.is_subscription_scoped", return_value=False): - deploy_bicep(infra_dir, "sub-123", "my-rg", env=test_env) - - # Verify --tenant was added to the command - cmd = mock_run.call_args[0][0] - assert "--tenant" in cmd - assert "tenant-456" in cmd - assert mock_run.call_args.kwargs.get("env") is test_env - - @patch("subprocess.run") - def test_deploy_app_stage_merges_env(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_app_stage - - stage_dir = tmp_path / "app" - stage_dir.mkdir() - deploy_sh = stage_dir / "deploy.sh" - deploy_sh.write_text("#!/bin/bash\necho ok") - - mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") - test_env = build_deploy_env("sub-123", "tenant-456", "cid", "csecret") - - deploy_app_stage(stage_dir, "sub-123", "my-rg", env=test_env) - - passed_env = mock_run.call_args.kwargs.get("env") - assert passed_env is not None - assert passed_env["ARM_SUBSCRIPTION_ID"] == "sub-123" - assert passed_env["ARM_TENANT_ID"] == "tenant-456" - assert passed_env["SUBSCRIPTION_ID"] == "sub-123" - assert passed_env["RESOURCE_GROUP"] == "my-rg" - - @patch("subprocess.run") - def test_deploy_app_sub_dirs_receive_env(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_app_stage - - stage_dir = tmp_path / "apps" - stage_dir.mkdir() - sub_app = stage_dir / "api" - sub_app.mkdir() - (sub_app / "deploy.sh").write_text("#!/bin/bash\necho ok") - - mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") - test_env = build_deploy_env("sub-123", "tenant-456") - - deploy_app_stage(stage_dir, "sub-123", "my-rg", env=test_env) - - passed_env = mock_run.call_args.kwargs.get("env") - assert passed_env is not None - assert passed_env["ARM_SUBSCRIPTION_ID"] == "sub-123" - assert passed_env["ARM_TENANT_ID"] == "tenant-456" - assert passed_env["RESOURCE_GROUP"] == "my-rg" - - @patch("subprocess.run") - def test_rollback_terraform_passes_env(self, mock_run): - from azext_prototype.stages.deploy_helpers import rollback_terraform - - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - test_env = build_deploy_env("sub-123", "tenant-456") - - rollback_terraform(Path("/tmp/fake"), env=test_env) - - assert mock_run.call_args.kwargs.get("env") is test_env - - @patch("subprocess.run") - def test_plan_terraform_passes_env(self, mock_run): - from azext_prototype.stages.deploy_helpers import plan_terraform - - mock_run.return_value = MagicMock(returncode=0, stdout="Plan: 1 to add", stderr="") - test_env = build_deploy_env("sub-123") - - plan_terraform(Path("/tmp/fake"), "sub-123", env=test_env) - - for c in mock_run.call_args_list: - assert c.kwargs.get("env") is test_env - - @patch("subprocess.run") - def test_rollback_bicep_adds_tenant_flag(self, mock_run): - from azext_prototype.stages.deploy_helpers import rollback_bicep - - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - test_env = build_deploy_env("sub-123", "tenant-456") - - rollback_bicep(Path("/tmp/fake"), "sub-123", "my-rg", env=test_env) - - cmd = mock_run.call_args[0][0] - assert "--tenant" in cmd - assert "tenant-456" in cmd - assert mock_run.call_args.kwargs.get("env") is test_env - - @patch("subprocess.run") - def test_whatif_bicep_adds_tenant_flag(self, mock_run): - from azext_prototype.stages.deploy_helpers import whatif_bicep - - mock_run.return_value = MagicMock(returncode=0, stdout="What-if output", stderr="") - test_env = build_deploy_env("sub-123", "tenant-789") - - with patch.object(Path, "exists", return_value=True), patch.object(Path, "glob", return_value=[]), patch( - "azext_prototype.stages.deploy_helpers.find_bicep_params", return_value=None - ), patch("azext_prototype.stages.deploy_helpers.is_subscription_scoped", return_value=False): - whatif_bicep(Path("/tmp/fake"), "sub-123", "my-rg", env=test_env) - - cmd = mock_run.call_args[0][0] - assert "--tenant" in cmd - assert "tenant-789" in cmd - - @patch("subprocess.run") - def test_deploy_terraform_no_env_still_works(self, mock_run): - """Verify backward compat — env defaults to None.""" - from azext_prototype.stages.deploy_helpers import deploy_terraform - - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - deploy_terraform(Path("/tmp/fake"), "sub-123") - - # env=None is passed (default), which means subprocess inherits os.environ - for c in mock_run.call_args_list: - assert c.kwargs.get("env") is None - - -class TestSecretVariableScanning: - """Tests for scan_tf_secret_variables().""" - - def test_scan_finds_secret_suffix(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "graph_client_secret" {}\n') - result = scan_tf_secret_variables(tmp_path) - assert "graph_client_secret" in result - - def test_scan_finds_password_suffix(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "admin_password" {\n type = string\n}\n') - result = scan_tf_secret_variables(tmp_path) - assert "admin_password" in result - - def test_scan_ignores_known_vars(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "client_secret" {}\n') - result = scan_tf_secret_variables(tmp_path) - assert "client_secret" not in result - - def test_scan_ignores_non_secret_vars(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "location" {}\nvariable "resource_group_name" {}\n') - result = scan_tf_secret_variables(tmp_path) - assert result == [] - - def test_scan_ignores_vars_with_default(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "api_secret" {\n default = "preset-value"\n}\n') - result = scan_tf_secret_variables(tmp_path) - assert result == [] - - def test_scan_multiple_files(self, tmp_path): - (tmp_path / "main.tf").write_text('variable "graph_client_secret" {}\n') - (tmp_path / "variables.tf").write_text('variable "db_password" {}\n') - result = scan_tf_secret_variables(tmp_path) - assert "graph_client_secret" in result - assert "db_password" in result - - def test_scan_empty_dir(self, tmp_path): - result = scan_tf_secret_variables(tmp_path) - assert result == [] - - -class TestResolveStageSecrets: - """Tests for resolve_stage_secrets().""" - - def _make_config(self, tmp_project): - from azext_prototype.config import ProjectConfig - - config = ProjectConfig(str(tmp_project)) - config.create_default() - return config - - def test_generates_new_secret(self, tmp_path, tmp_project): - (tmp_path / "main.tf").write_text('variable "graph_client_secret" {}\n') - config = self._make_config(tmp_project) - - result = resolve_stage_secrets(tmp_path, config) - assert "TF_VAR_graph_client_secret" in result - assert len(result["TF_VAR_graph_client_secret"]) == 64 # token_hex(32) - - def test_reuses_existing_secret(self, tmp_path, tmp_project): - (tmp_path / "main.tf").write_text('variable "graph_client_secret" {}\n') - config = self._make_config(tmp_project) - config.set("deploy.generated_secrets.graph_client_secret", "reused-value") - - result = resolve_stage_secrets(tmp_path, config) - assert result["TF_VAR_graph_client_secret"] == "reused-value" - - def test_persists_generated_secret(self, tmp_path, tmp_project): - (tmp_path / "main.tf").write_text('variable "app_password" {}\n') - config = self._make_config(tmp_project) - - resolve_stage_secrets(tmp_path, config) - - stored = config.get("deploy.generated_secrets.app_password") - assert stored is not None - assert len(stored) == 64 - - def test_multiple_secrets(self, tmp_path, tmp_project): - (tmp_path / "main.tf").write_text('variable "graph_client_secret" {}\nvariable "admin_password" {}\n') - config = self._make_config(tmp_project) - - result = resolve_stage_secrets(tmp_path, config) - assert "TF_VAR_graph_client_secret" in result - assert "TF_VAR_admin_password" in result - - def test_no_secrets_needed(self, tmp_path, tmp_project): - (tmp_path / "main.tf").write_text('variable "location" {}\n') - config = self._make_config(tmp_project) - - result = resolve_stage_secrets(tmp_path, config) - assert result == {} diff --git a/tests/test_deploy_session.py b/tests/test_deploy_session.py deleted file mode 100644 index 796134f..0000000 --- a/tests/test_deploy_session.py +++ /dev/null @@ -1,5835 +0,0 @@ -"""Tests for DeployState, DeploySession, preflight checks, and deploy stage. - -Covers the deploy-stage overhaul modules: -- DeployState: YAML persistence, stage transitions, rollback ordering -- DeploySession: interactive session, dry-run, single-stage, slash commands -- Preflight checks: subscription, IaC tool, resource group, resource providers -- DeployStage: thin orchestrator delegation -- Deploy helpers: execution primitives, RollbackManager extensions -""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import yaml - -from azext_prototype.ai.provider import AIResponse - -# ====================================================================== -# Helpers -# ====================================================================== - - -def _make_response(content: str = "Mock response") -> AIResponse: - return AIResponse(content=content, model="gpt-4o", usage={}) - - -def _build_yaml(stages: list[dict] | None = None, iac_tool: str = "terraform") -> dict: - """Return a realistic build.yaml structure.""" - if stages is None: - stages = [ - { - "stage": 1, - "name": "Foundation", - "layer": "infra", - "capability": "infra", - "services": [ - { - "name": "key-vault", - "computed_name": "zd-kv-api-dev-eus", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - }, - ], - "status": "generated", - "dir": "concept/infra/terraform/stage-1-foundation", - "files": [], - }, - { - "stage": 2, - "name": "Data Layer", - "layer": "data", - "capability": "data", - "services": [ - { - "name": "sql-db", - "computed_name": "zd-sql-api-dev-eus", - "resource_type": "Microsoft.Sql/servers", - "sku": "S0", - }, - ], - "status": "generated", - "dir": "concept/infra/terraform/stage-2-data", - "files": [], - }, - { - "stage": 3, - "name": "Application", - "layer": "app", - "capability": "app", - "services": [ - { - "name": "web-app", - "computed_name": "zd-app-web-dev-eus", - "resource_type": "Microsoft.Web/sites", - "sku": "B1", - }, - ], - "status": "generated", - "dir": "concept/apps/stage-3-application", - "files": [], - }, - ] - return { - "iac_tool": iac_tool, - "deployment_stages": stages, - "_metadata": {"created": "2026-01-01T00:00:00", "last_updated": "2026-01-01T00:00:00", "iteration": 1}, - } - - -def _write_build_yaml(project_dir, stages=None, iac_tool="terraform"): - """Write build.yaml into the project state dir.""" - state_dir = Path(project_dir) / ".prototype" / "state" - state_dir.mkdir(parents=True, exist_ok=True) - build_data = _build_yaml(stages, iac_tool) - with open(state_dir / "build.yaml", "w", encoding="utf-8") as f: - yaml.dump(build_data, f, default_flow_style=False) - return state_dir / "build.yaml" - - -# ====================================================================== -# DeployState tests -# ====================================================================== - - -class TestDeployState: - - def test_default_state_structure(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - state = ds.state - assert state["iac_tool"] == "terraform" - assert state["subscription"] == "" - assert state["resource_group"] == "" - assert state["deployment_stages"] == [] - assert state["preflight_results"] == [] - assert state["deploy_log"] == [] - assert state["rollback_log"] == [] - assert state["captured_outputs"] == {} - assert state["_metadata"]["iteration"] == 0 - - def test_load_save_roundtrip(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - ds._state["subscription"] = "test-sub-123" - ds._state["iac_tool"] = "bicep" - ds.save() - - ds2 = DeployState(str(tmp_project)) - loaded = ds2.load() - assert loaded["subscription"] == "test-sub-123" - assert loaded["iac_tool"] == "bicep" - assert loaded["_metadata"]["created"] is not None - assert loaded["_metadata"]["last_updated"] is not None - - def test_exists_property(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - assert not ds.exists - ds.save() - assert ds.exists - - def test_load_from_build_state(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - result = ds.load_from_build_state(build_path) - - assert result is True - assert len(ds.state["deployment_stages"]) == 3 - # Verify deploy-specific fields were added - stage = ds.state["deployment_stages"][0] - assert stage["deploy_status"] == "pending" - assert stage["deploy_timestamp"] is None - assert stage["deploy_output"] == "" - assert stage["deploy_error"] == "" - assert stage["rollback_timestamp"] is None - - def test_load_from_build_state_missing_file(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - result = ds.load_from_build_state("/nonexistent/build.yaml") - assert result is False - - def test_load_from_build_state_no_stages(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project, stages=[]) - ds = DeployState(str(tmp_project)) - result = ds.load_from_build_state(build_path) - assert result is False - - def test_stage_transitions(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # pending → deploying - ds.mark_stage_deploying(1) - assert ds.get_stage(1)["deploy_status"] == "deploying" - - # deploying → deployed - ds.mark_stage_deployed(1, output="resource_id=abc123") - stage = ds.get_stage(1) - assert stage["deploy_status"] == "deployed" - assert stage["deploy_timestamp"] is not None - assert stage["deploy_output"] == "resource_id=abc123" - assert stage["deploy_error"] == "" - - # deployed → rolled_back - ds.mark_stage_rolled_back(1) - stage = ds.get_stage(1) - assert stage["deploy_status"] == "rolled_back" - assert stage["rollback_timestamp"] is not None - - def test_stage_failure(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deploying(1) - ds.mark_stage_failed(1, error="timeout connecting to Azure") - stage = ds.get_stage(1) - assert stage["deploy_status"] == "failed" - assert stage["deploy_error"] == "timeout connecting to Azure" - - def test_get_pending_deployed_failed(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - assert len(ds.get_pending_stages()) == 3 - assert len(ds.get_deployed_stages()) == 0 - assert len(ds.get_failed_stages()) == 0 - - ds.mark_stage_deployed(1) - ds.mark_stage_failed(2, "error") - - assert len(ds.get_pending_stages()) == 1 - assert len(ds.get_deployed_stages()) == 1 - assert len(ds.get_failed_stages()) == 1 - - def test_can_rollback_ordering(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deployed(1) - ds.mark_stage_deployed(2) - ds.mark_stage_deployed(3) - - # Can only rollback stage 3 (highest) - assert ds.can_rollback(3) is True - assert ds.can_rollback(2) is False # stage 3 still deployed - assert ds.can_rollback(1) is False # stages 2,3 still deployed - - # Roll back stage 3 - ds.mark_stage_rolled_back(3) - assert ds.can_rollback(2) is True - assert ds.can_rollback(1) is False # stage 2 still deployed - - def test_rollback_candidates_reverse_order(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deployed(1) - ds.mark_stage_deployed(2) - ds.mark_stage_deployed(3) - - candidates = ds.get_rollback_candidates() - assert [c["stage"] for c in candidates] == [3, 2, 1] - - def test_preflight_results(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - results = [ - {"name": "Azure Login", "status": "pass", "message": "Logged in."}, - {"name": "Terraform", "status": "fail", "message": "Not found.", "fix_command": "brew install terraform"}, - ] - ds.set_preflight_results(results) - - failures = ds.get_preflight_failures() - assert len(failures) == 1 - assert failures[0]["name"] == "Terraform" - - def test_deploy_log(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deploying(1) - ds.mark_stage_deployed(1) - - assert len(ds.state["deploy_log"]) == 2 - assert ds.state["deploy_log"][0]["action"] == "deploying" - assert ds.state["deploy_log"][1]["action"] == "deployed" - - def test_reset(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - assert len(ds.state["deployment_stages"]) == 3 - - ds.reset() - assert ds.state["deployment_stages"] == [] - assert ds.exists # File still exists after reset - - def test_format_deploy_report(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - ds._state["subscription"] = "sub-123" - - ds.mark_stage_deployed(1) - ds.mark_stage_failed(2, "timeout") - - report = ds.format_deploy_report() - assert "Deploy Report" in report - assert "sub-123" in report - assert "1 deployed" in report - assert "1 failed" in report - - def test_format_stage_status(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - status = ds.format_stage_status() - assert "Foundation" in status - assert "Application" in status - assert "0/3 stages deployed" in status - - def test_format_preflight_report(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - ds.set_preflight_results( - [ - {"name": "Azure Login", "status": "pass", "message": "OK"}, - { - "name": "Terraform", - "status": "warn", - "message": "Old version", - "fix_command": "brew upgrade terraform", - }, - ] - ) - - report = ds.format_preflight_report() - assert "Preflight Checks" in report - assert "2 passed" in report or "1 passed" in report - assert "1 warning" in report - - def test_conversation_tracking(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - ds.update_from_exchange("deploy all", "Deploying stage 1...", 1) - - assert len(ds.state["conversation_history"]) == 1 - assert ds.state["conversation_history"][0]["user"] == "deploy all" - - -# ====================================================================== -# Preflight check tests -# ====================================================================== - - -class TestPreflightChecks: - - def _make_session(self, project_dir, iac_tool="terraform"): - """Create a DeploySession with mocked dependencies.""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - context = AgentContext( - project_config={"project": {"iac_tool": iac_tool}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - - return DeploySession(context, registry) - - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - def test_subscription_pass(self, _mock_sub, _mock_login, tmp_project): - session = self._make_session(tmp_project) - result = session._check_subscription("sub-123") - assert result["status"] == "pass" - - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=False) - def test_subscription_fail_no_login(self, _mock_login, tmp_project): - session = self._make_session(tmp_project) - result = session._check_subscription("sub-123") - assert result["status"] == "fail" - assert "az login" in result.get("fix_command", "") - - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="other-sub") - def test_subscription_warn_mismatch(self, _mock_sub, _mock_login, tmp_project): - session = self._make_session(tmp_project) - result = session._check_subscription("sub-123") - assert result["status"] == "warn" - - @patch("subprocess.run") - def test_iac_tool_terraform_pass(self, mock_run, tmp_project): - mock_run.return_value = MagicMock(returncode=0, stdout="Terraform v1.7.0\n") - session = self._make_session(tmp_project, iac_tool="terraform") - result = session._check_iac_tool() - assert result["status"] == "pass" - assert "Terraform" in result["message"] - - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_iac_tool_terraform_missing(self, _mock_run, tmp_project): - session = self._make_session(tmp_project, iac_tool="terraform") - result = session._check_iac_tool() - assert result["status"] == "fail" - - def test_iac_tool_bicep_always_pass(self, tmp_project): - session = self._make_session(tmp_project, iac_tool="bicep") - result = session._check_iac_tool() - assert result["status"] == "pass" - - @patch("subprocess.run") - def test_resource_group_exists(self, mock_run, tmp_project): - mock_run.return_value = MagicMock(returncode=0) - session = self._make_session(tmp_project) - result = session._check_resource_group("sub-123", "my-rg") - assert result["status"] == "pass" - - @patch("subprocess.run") - def test_resource_group_missing_warns(self, mock_run, tmp_project): - mock_run.return_value = MagicMock(returncode=1) - session = self._make_session(tmp_project) - result = session._check_resource_group("sub-123", "my-rg") - assert result["status"] == "warn" - assert "fix_command" in result - - @patch("subprocess.run") - def test_resource_providers_skips_non_microsoft_namespaces(self, mock_run, tmp_project): - """Non-Microsoft namespaces like 'External' should NOT be checked.""" - session = self._make_session(tmp_project) - session._deploy_state._state["deployment_stages"] = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [ - {"name": "ext", "resource_type": "External/something", "sku": ""}, - {"name": "hashicorp", "resource_type": "hashicorp/random", "sku": ""}, - {"name": "kv", "resource_type": "Microsoft.KeyVault/vaults", "sku": ""}, - ], - "status": "generated", - "dir": "stage-1", - "files": [], - }, - ] - - mock_run.return_value = MagicMock(returncode=0, stdout="Registered\n", stderr="") - results = session._check_resource_providers("sub-123") # noqa: F841 - - # Should have checked only Microsoft.* namespaces — not External or hashicorp - checked_namespaces = [c.args[0][4] for c in mock_run.call_args_list if "provider" in c.args[0]] - assert "Microsoft.KeyVault" in checked_namespaces - assert "External" not in checked_namespaces - assert "hashicorp" not in checked_namespaces - - @patch("subprocess.run") - def test_resource_providers_skips_empty_resource_types(self, mock_run, tmp_project): - """Services with empty resource_type should be skipped.""" - session = self._make_session(tmp_project) - session._deploy_state._state["deployment_stages"] = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [ - {"name": "custom", "resource_type": "", "sku": ""}, - ], - "status": "generated", - "dir": "stage-1", - "files": [], - }, - ] - - results = session._check_resource_providers("sub-123") - assert results == [] - mock_run.assert_not_called() - - -# ====================================================================== -# File-based resource provider extraction tests -# ====================================================================== - - -class TestExtractResourceProvidersFromFiles: - """Verify _extract_providers_from_files() parses IaC files for namespaces.""" - - def _make_session(self, project_dir, iac_tool="terraform"): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - context = AgentContext( - project_config={"project": {"iac_tool": iac_tool}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_extracts_from_tf_files(self, tmp_project): - session = self._make_session(tmp_project) - stage_dir = tmp_project / "stage-1" - stage_dir.mkdir() - (stage_dir / "main.tf").write_text( - 'resource "azapi_resource" "rg" {\n' - ' type = "Microsoft.Resources/resourceGroups@2025-06-01"\n' - "}\n" - 'resource "azapi_resource" "storage" {\n' - ' type = "Microsoft.Storage/storageAccounts@2025-06-01"\n' - "}\n" - ) - session._deploy_state._state["deployment_stages"] = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "dir": "stage-1", - "services": [], - "status": "generated", - "files": [], - }, - ] - namespaces = session._extract_providers_from_files() - assert "Microsoft.Resources" in namespaces - assert "Microsoft.Storage" in namespaces - - def test_extracts_from_bicep_files(self, tmp_project): - session = self._make_session(tmp_project, iac_tool="bicep") - stage_dir = tmp_project / "stage-1" - stage_dir.mkdir() - (stage_dir / "main.bicep").write_text( - "resource rg 'Microsoft.Resources/resourceGroups@2025-06-01' = {\n" - " name: 'myrg'\n" - " location: 'eastus'\n" - "}\n" - "resource kv 'Microsoft.KeyVault/vaults@2025-06-01' = {\n" - " name: 'mykv'\n" - "}\n" - ) - session._deploy_state._state["deployment_stages"] = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "dir": "stage-1", - "services": [], - "status": "generated", - "files": [], - }, - ] - namespaces = session._extract_providers_from_files() - assert "Microsoft.Resources" in namespaces - assert "Microsoft.KeyVault" in namespaces - - def test_ignores_non_microsoft_types(self, tmp_project): - session = self._make_session(tmp_project) - stage_dir = tmp_project / "stage-1" - stage_dir.mkdir() - (stage_dir / "main.tf").write_text( - 'resource "null_resource" "test" {}\n' 'resource "random_string" "suffix" {}\n' - ) - session._deploy_state._state["deployment_stages"] = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "dir": "stage-1", - "services": [], - "status": "generated", - "files": [], - }, - ] - namespaces = session._extract_providers_from_files() - assert len(namespaces) == 0 - - def test_handles_missing_dirs(self, tmp_project): - session = self._make_session(tmp_project) - session._deploy_state._state["deployment_stages"] = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "dir": "nonexistent-dir", - "services": [], - "status": "generated", - "files": [], - }, - ] - namespaces = session._extract_providers_from_files() - assert len(namespaces) == 0 - - @patch("subprocess.run") - def test_file_based_preferred_over_metadata(self, mock_run, tmp_project): - """When IaC files exist, file-based extraction is used over metadata.""" - session = self._make_session(tmp_project) - stage_dir = tmp_project / "stage-1" - stage_dir.mkdir() - (stage_dir / "main.tf").write_text( - 'resource "azapi_resource" "storage" {\n' ' type = "Microsoft.Storage/storageAccounts@2025-06-01"\n' "}\n" - ) - session._deploy_state._state["deployment_stages"] = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "dir": "stage-1", - "services": [ - {"name": "kv", "resource_type": "Microsoft.KeyVault/vaults", "sku": ""}, - ], - "status": "generated", - "files": [], - }, - ] - mock_run.return_value = MagicMock(returncode=0, stdout="Registered\n", stderr="") - results = session._check_resource_providers("sub-123") # noqa: F841 - # File-based: only Microsoft.Storage, NOT Microsoft.KeyVault from metadata - checked_namespaces = [c.args[0][4] for c in mock_run.call_args_list if "provider" in c.args[0]] - assert "Microsoft.Storage" in checked_namespaces - assert "Microsoft.KeyVault" not in checked_namespaces - - @patch("subprocess.run") - def test_falls_back_to_metadata(self, mock_run, tmp_project): - """When no IaC files exist, falls back to service metadata.""" - session = self._make_session(tmp_project) - # No stage directory created — no files to scan - session._deploy_state._state["deployment_stages"] = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "dir": "nonexistent-stage-dir", - "services": [ - {"name": "kv", "resource_type": "Microsoft.KeyVault/vaults", "sku": ""}, - ], - "status": "generated", - "files": [], - }, - ] - mock_run.return_value = MagicMock(returncode=0, stdout="Registered\n", stderr="") - results = session._check_resource_providers("sub-123") # noqa: F841 - checked_namespaces = [c.args[0][4] for c in mock_run.call_args_list if "provider" in c.args[0]] - assert "Microsoft.KeyVault" in checked_namespaces - - -# ====================================================================== -# DeploySession tests -# ====================================================================== - - -class TestDeploySession: - - def _make_session(self, project_dir, iac_tool="terraform", build_stages=None): - """Create a DeploySession with all dependencies mocked.""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages, iac_tool=iac_tool) - - context = AgentContext( - project_config={"project": {"iac_tool": iac_tool}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - - return DeploySession(context, registry) - - def test_quit_cancels_session(self, tmp_project): - session = self._make_session(tmp_project) - output = [] - result = session.run( - subscription="sub-123", - input_fn=lambda p: "quit", - print_fn=lambda msg: output.append(msg), - ) - assert result.cancelled is True - - def test_session_loads_build_state(self, tmp_project): - session = self._make_session(tmp_project) - output = [] - # Immediately quit - session.run( - subscription="sub-123", - input_fn=lambda p: "quit", - print_fn=lambda msg: output.append(msg), - ) - # Verify stages were loaded (shown in plan overview) - joined = "\n".join(output) - assert "Foundation" in joined or "Stage" in joined - - @patch( - "azext_prototype.stages.deploy_session.subprocess.run", - return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), - ) - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) - @patch("azext_prototype.stages.deploy_session.deploy_app_stage", return_value={"status": "deployed"}) - def test_full_deploy_flow(self, mock_app, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): - """Test full interactive deploy: confirm → preflight → deploy → done.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - # Create the stage directory - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, build_stages=stages) - - inputs = iter(["", "done"]) # confirm, then done - output = [] - result = session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - assert not result.cancelled - assert len(result.deployed_stages) == 1 - - @patch( - "azext_prototype.stages.deploy_session.subprocess.run", - return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), - ) - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - @patch( - "azext_prototype.stages.deploy_session.deploy_terraform", - return_value={"status": "failed", "error": "auth error"}, - ) - def test_deploy_failure_qa_routing(self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): - """Test that deploy failure routes to QA agent.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, build_stages=stages) - # Mock QA agent response - session._qa_agent = MagicMock() - session._qa_agent.execute.return_value = _make_response("Check your service principal credentials.") - # Clear fix agents so remediation is skipped (this test verifies QA routing only) - session._iac_agents = {} - session._dev_agent = None - session._architect_agent = None - - inputs = iter(["", "done"]) # confirm, then done - output = [] - result = session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - assert len(result.failed_stages) == 1 - joined = "\n".join(output) - assert "QA Diagnosis" in joined or "service principal" in joined - - def test_dry_run_no_build_state(self, tmp_project): - """Dry run with no build state returns cancelled.""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(tmp_project) / "prototype.yaml" - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - - output = [] - result = session.run_dry_run( - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - assert result.cancelled is True - - @patch( - "azext_prototype.stages.deploy_session.plan_terraform", return_value={"output": "Plan: 3 to add", "error": None} - ) - def test_dry_run_terraform(self, mock_plan, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - - output = [] - session.run_dry_run( - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "Plan: 3 to add" in joined - - @patch( - "azext_prototype.stages.deploy_session.plan_terraform", return_value={"output": "Plan: 1 to add", "error": None} - ) - def test_dry_run_single_stage(self, mock_plan, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - { - "stage": 2, - "name": "Data", - "layer": "data", - "capability": "data", - "services": [], - "dir": "concept/infra/terraform/data", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - (tmp_project / "concept" / "infra" / "terraform" / "data").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - - output = [] - session.run_dry_run( - target_stage=1, - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - # Should only show stage 1 - assert mock_plan.call_count == 1 - - def test_dry_run_stage_not_found(self, tmp_project): - session = self._make_session(tmp_project) - output = [] - result = session.run_dry_run( - target_stage=99, - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - assert result.cancelled is True - - @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) - def test_single_stage_deploy(self, mock_tf, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - - output = [] - result = session.run_single_stage( - 1, - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - assert len(result.deployed_stages) == 1 - mock_tf.assert_called_once() - - def test_single_stage_not_found(self, tmp_project): - session = self._make_session(tmp_project) - output = [] - result = session.run_single_stage( - 99, - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - assert result.cancelled is True - - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) - def test_slash_status(self, mock_tf, mock_sub, mock_login, tmp_project): - """Test /status slash command shows stage info.""" - session = self._make_session(tmp_project) - output = [] - - inputs = iter(["", "/status", "done"]) - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "stages deployed" in joined - - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - def test_slash_help(self, mock_sub, mock_login, tmp_project): - """Test /help slash command shows available commands.""" - session = self._make_session(tmp_project) - output = [] - - # Preflight will run — need to avoid actual subprocess calls - with patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n")): - inputs = iter(["", "/help", "done"]) - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - - joined = "\n".join(output) - assert "/status" in joined - assert "/deploy" in joined - assert "/rollback" in joined - - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - def test_slash_outputs(self, mock_sub, mock_login, tmp_project): - """Test /outputs slash command.""" - session = self._make_session(tmp_project) - output = [] - - with patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n")): - inputs = iter(["", "/outputs", "done"]) - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - - joined = "\n".join(output) - assert "outputs" in joined.lower() - - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) - @patch("azext_prototype.stages.deploy_session.rollback_terraform", return_value={"status": "rolled_back"}) - def test_slash_rollback_enforces_order(self, mock_rb, mock_tf, mock_sub, mock_login, tmp_project): - """Test that /rollback enforces reverse order.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - { - "stage": 2, - "name": "Data", - "capability": "data", - "services": [], - "dir": "concept/infra/terraform/data", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - (tmp_project / "concept" / "infra" / "terraform" / "data").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - - output = [] - # Deploy all, then try to rollback stage 1 (should fail), then done - inputs = iter(["", "/rollback 1", "done"]) - with patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n")): - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - - joined = "\n".join(output) - assert "Cannot roll back" in joined or "not deployed" in joined.lower() - - def test_eof_cancels(self, tmp_project): - """Test that EOFError during prompt cancels session.""" - session = self._make_session(tmp_project) - - def eof_input(p): - raise EOFError - - result = session.run( - subscription="sub-123", - input_fn=eof_input, - print_fn=lambda msg: None, - ) - assert result.cancelled is True - - def test_docs_stage_auto_deployed(self, tmp_project): - """Test that docs-layer stages are auto-marked as deployed.""" - stages = [ - { - "stage": 1, - "name": "Docs", - "layer": "docs", - "capability": "docs", - "services": [], - "dir": "concept/docs", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "docs").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - - output = [] - result = session.run_single_stage( - 1, - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - assert len(result.deployed_stages) == 1 - - -# ====================================================================== -# DeployStage integration tests -# ====================================================================== - - -class TestDeployStageIntegration: - - def test_guard_checks_build_yaml(self, tmp_project): - """Verify deploy guard checks for build.yaml (not build.json).""" - import os - - from azext_prototype.stages.deploy_stage import DeployStage - - os.chdir(str(tmp_project)) - try: - stage = DeployStage() - guards = stage.get_guards() - build_guard = [g for g in guards if g.name == "build_complete"][0] - - # No build.yaml → guard fails - assert build_guard.check_fn() is False - - # Create build.yaml → guard passes - state_dir = tmp_project / ".prototype" / "state" - state_dir.mkdir(parents=True, exist_ok=True) - (state_dir / "build.yaml").write_text("iac_tool: terraform\n") - assert build_guard.check_fn() is True - finally: - os.chdir("/") - - @patch("azext_prototype.stages.deploy_session.DeploySession") - def test_status_flag(self, mock_session_cls, tmp_project): - """Test --status flag shows deploy state without starting session.""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.deploy_stage import DeployStage - - _write_build_yaml(tmp_project) - context = AgentContext( - project_config={}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - registry = MagicMock() - - stage = DeployStage() - result = stage.execute(context, registry, status=True) - assert result["status"] == "status_displayed" - # DeploySession should NOT be constructed for --status - mock_session_cls.assert_not_called() - - @patch("azext_prototype.stages.deploy_session.DeploySession") - def test_reset_flag(self, mock_session_cls, tmp_project): - """Test --reset flag clears deploy state.""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.deploy_stage import DeployStage - - context = AgentContext( - project_config={}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - registry = MagicMock() - - stage = DeployStage() - result = stage.execute(context, registry, reset=True) - assert result["status"] == "reset" - mock_session_cls.assert_not_called() - - def test_dry_run_delegates(self, tmp_project): - """Test --dry-run delegates to DeploySession.run_dry_run().""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.deploy_session import DeployResult - from azext_prototype.stages.deploy_stage import DeployStage - - _write_build_yaml(tmp_project) - config_path = Path(tmp_project) / "prototype.yaml" - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - registry = MagicMock() - registry.find_by_capability.return_value = [] - - with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_cls: - mock_session = MagicMock() - mock_session.run_dry_run.return_value = DeployResult() - mock_cls.return_value = mock_session - - stage = DeployStage() - result = stage.execute(context, registry, dry_run=True, subscription="sub-123") - - mock_session.run_dry_run.assert_called_once() - assert result["mode"] == "dry-run" - - def test_single_stage_delegates(self, tmp_project): - """Test --stage N delegates to DeploySession.run_single_stage().""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.deploy_session import DeployResult - from azext_prototype.stages.deploy_stage import DeployStage - - _write_build_yaml(tmp_project) - config_path = Path(tmp_project) / "prototype.yaml" - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - registry = MagicMock() - registry.find_by_capability.return_value = [] - - with patch("azext_prototype.stages.deploy_stage.DeploySession") as mock_cls: - mock_session = MagicMock() - mock_session.run_single_stage.return_value = DeployResult(deployed_stages=[{"stage": 1}]) - mock_cls.return_value = mock_session - - stage = DeployStage() - result = stage.execute(context, registry, stage=1, subscription="sub-123") - - mock_session.run_single_stage.assert_called_once_with( - 1, subscription="sub-123", tenant=None, force=False, client_id=None, client_secret=None - ) - assert result["mode"] == "single_stage" - assert result["deployed"] == 1 - - -# ====================================================================== -# Deploy helpers tests -# ====================================================================== - - -class TestDeployHelpers: - - @patch("subprocess.run") - def test_check_az_login_success(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - mock_run.return_value = MagicMock(returncode=0) - assert check_az_login() is True - - @patch("subprocess.run") - def test_check_az_login_failure(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - mock_run.return_value = MagicMock(returncode=1) - assert check_az_login() is False - - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_check_az_login_missing(self, _mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - assert check_az_login() is False - - @patch("subprocess.run") - def test_get_current_subscription(self, mock_run): - from azext_prototype.stages.deploy_helpers import get_current_subscription - - mock_run.return_value = MagicMock(returncode=0, stdout="sub-123\n") - assert get_current_subscription() == "sub-123" - - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_get_current_subscription_missing(self, _mock_run): - from azext_prototype.stages.deploy_helpers import get_current_subscription - - assert get_current_subscription() == "" - - def test_rollback_manager_snapshot_stage(self, tmp_project): - from azext_prototype.stages.deploy_helpers import RollbackManager - - mgr = RollbackManager(str(tmp_project)) - snapshot = mgr.snapshot_stage(1, "infra", "terraform") - assert snapshot["stage"] == 1 - assert snapshot["scope"] == "infra" - assert snapshot["iac_tool"] == "terraform" - - @patch("subprocess.run") - def test_deploy_terraform(self, mock_run, tmp_project): - from azext_prototype.stages.deploy_helpers import deploy_terraform - - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - result = deploy_terraform(tmp_project, "sub-123") - assert result["status"] == "deployed" - - @patch("subprocess.run") - def test_deploy_terraform_failure(self, mock_run, tmp_project): - from azext_prototype.stages.deploy_helpers import deploy_terraform - - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Error: auth failed") - result = deploy_terraform(tmp_project, "sub-123") - assert result["status"] == "failed" - assert "auth failed" in result.get("error", "") - - @patch("subprocess.run") - def test_plan_terraform(self, mock_run, tmp_project): - from azext_prototype.stages.deploy_helpers import plan_terraform - - mock_run.return_value = MagicMock(returncode=0, stdout="Plan: 2 to add, 0 to change", stderr="") - result = plan_terraform(tmp_project, "sub-123") - assert "Plan: 2 to add" in result.get("output", "") - - @patch("subprocess.run") - def test_rollback_terraform(self, mock_run, tmp_project): - from azext_prototype.stages.deploy_helpers import rollback_terraform - - mock_run.return_value = MagicMock(returncode=0, stdout="Destroy complete", stderr="") - result = rollback_terraform(tmp_project) - assert result["status"] == "rolled_back" - - @patch("subprocess.run") - def test_rollback_terraform_failure(self, mock_run, tmp_project): - from azext_prototype.stages.deploy_helpers import rollback_terraform - - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Error: state locked") - result = rollback_terraform(tmp_project) - assert result["status"] == "failed" - - def test_find_bicep_params(self, tmp_project): - from azext_prototype.stages.deploy_helpers import find_bicep_params - - # Create test files - main_bicep = tmp_project / "main.bicep" - main_bicep.write_text("resource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {}") - params = tmp_project / "main.parameters.json" - params.write_text('{"parameters": {}}') - - result = find_bicep_params(tmp_project, main_bicep) - assert result is not None - assert result.name == "main.parameters.json" - - def test_is_subscription_scoped(self, tmp_project): - from azext_prototype.stages.deploy_helpers import is_subscription_scoped - - bicep_file = tmp_project / "main.bicep" - bicep_file.write_text( - "targetScope = 'subscription'\nresource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = {}" - ) - assert is_subscription_scoped(bicep_file) is True - - bicep_file.write_text("resource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {}") - assert is_subscription_scoped(bicep_file) is False - - -# ====================================================================== -# Rollback ordering tests (specific edge cases) -# ====================================================================== - - -class TestRollbackOrdering: - - def test_rollback_with_gap_in_stages(self, tmp_project): - """Test rollback ordering works with non-contiguous stage numbers.""" - from azext_prototype.stages.deploy_state import DeployState - - stages = [ - {"stage": 1, "name": "A", "capability": "infra", "services": [], "dir": "a", "files": []}, - {"stage": 3, "name": "C", "capability": "infra", "services": [], "dir": "c", "files": []}, - {"stage": 5, "name": "E", "capability": "app", "services": [], "dir": "e", "files": []}, - ] - build_path = _write_build_yaml(tmp_project, stages=stages) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deployed(1) - ds.mark_stage_deployed(3) - ds.mark_stage_deployed(5) - - assert ds.can_rollback(5) is True - assert ds.can_rollback(3) is False - assert ds.can_rollback(1) is False - - def test_rollback_with_mixed_statuses(self, tmp_project): - """Test rollback logic with failed and rolled-back stages.""" - from azext_prototype.stages.deploy_state import DeployState - - stages = [ - {"stage": 1, "name": "A", "capability": "infra", "services": [], "dir": "a", "files": []}, - {"stage": 2, "name": "B", "capability": "data", "services": [], "dir": "b", "files": []}, - {"stage": 3, "name": "C", "capability": "app", "services": [], "dir": "c", "files": []}, - ] - build_path = _write_build_yaml(tmp_project, stages=stages) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deployed(1) - ds.mark_stage_deployed(2) - ds.mark_stage_failed(3, "timeout") - - # Stage 3 is failed (not deployed), so stage 2 can be rolled back - assert ds.can_rollback(2) is True - assert ds.can_rollback(1) is False # stage 2 still deployed - - def test_get_stage_returns_none_for_missing(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - assert ds.get_stage(999) is None - - def test_default_state_has_tenant(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - assert ds.state["tenant"] == "" - - -# ====================================================================== -# AI-independent deploy tests -# ====================================================================== - - -class TestDeployNoAI: - """Deploy stage works without an AI provider.""" - - def _make_session(self, project_dir, ai_provider=None, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=ai_provider, - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_session_works_with_none_ai_provider(self, tmp_project): - """Session initialises and quits cleanly with ai_provider=None.""" - session = self._make_session(tmp_project, ai_provider=None) - output = [] - result = session.run( - subscription="sub-123", - input_fn=lambda p: "quit", - print_fn=lambda msg: output.append(msg), - ) - assert result.cancelled is True - - @patch( - "azext_prototype.stages.deploy_session.subprocess.run", - return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), - ) - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) - def test_deploy_succeeds_without_ai(self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): - """Full deploy succeeds with ai_provider=None.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, ai_provider=None, build_stages=stages) - inputs = iter(["", "done"]) - output = [] - result = session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - assert not result.cancelled - assert len(result.deployed_stages) == 1 - - @patch( - "azext_prototype.stages.deploy_session.subprocess.run", - return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), - ) - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - @patch( - "azext_prototype.stages.deploy_session.deploy_terraform", - return_value={"status": "failed", "error": "auth error"}, - ) - def test_deploy_failure_without_ai_shows_raw_error( - self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project - ): - """Deploy failure with ai_provider=None falls back to raw error display.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, ai_provider=None, build_stages=stages) - inputs = iter(["", "done"]) - output = [] - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "auth error" in joined - - def test_dry_run_without_ai(self, tmp_project): - """Dry-run mode works with ai_provider=None.""" - session = self._make_session(tmp_project, ai_provider=None) - output = [] - result = session.run_dry_run( - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - # Should not raise — result is a DeployResult - assert not result.cancelled or result.cancelled # always passes: just no crash - - -# ====================================================================== -# Service principal login tests -# ====================================================================== - - -class TestServicePrincipalLogin: - """Tests for login_service_principal() and set_deployment_context().""" - - @patch("subprocess.run") - def test_login_service_principal_success(self, mock_run): - from azext_prototype.stages.deploy_helpers import login_service_principal - - # First call: az login; second call: az account show (get_current_subscription) - mock_run.side_effect = [ - MagicMock(returncode=0, stdout="", stderr=""), # az login - MagicMock(returncode=0, stdout="sub-from-sp\n", stderr=""), # az account show - ] - result = login_service_principal("app-id", "secret", "tenant-id") - assert result["status"] == "ok" - assert result["subscription"] == "sub-from-sp" - - # Verify az login was called with correct args - login_call = mock_run.call_args_list[0] - assert "--service-principal" in login_call[0][0] - assert "-u" in login_call[0][0] - assert "app-id" in login_call[0][0] - - @patch("subprocess.run") - def test_login_service_principal_failure(self, mock_run): - from azext_prototype.stages.deploy_helpers import login_service_principal - - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="AADSTS7000215: Invalid client secret") - result = login_service_principal("app-id", "bad-secret", "tenant-id") - assert result["status"] == "failed" - assert "Invalid client secret" in result["error"] - - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_login_service_principal_no_az_cli(self, mock_run): - from azext_prototype.stages.deploy_helpers import login_service_principal - - result = login_service_principal("app-id", "secret", "tenant-id") - assert result["status"] == "failed" - assert "az CLI not found" in result["error"] - - @patch("subprocess.run") - def test_set_deployment_context_success(self, mock_run): - from azext_prototype.stages.deploy_helpers import set_deployment_context - - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - result = set_deployment_context("sub-123", "tenant-456") - assert result["status"] == "ok" - - cmd = mock_run.call_args[0][0] - assert "--subscription" in cmd - assert "sub-123" in cmd - assert "--tenant" in cmd - assert "tenant-456" in cmd - - @patch("subprocess.run") - def test_set_deployment_context_no_tenant(self, mock_run): - from azext_prototype.stages.deploy_helpers import set_deployment_context - - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - result = set_deployment_context("sub-123") - assert result["status"] == "ok" - - cmd = mock_run.call_args[0][0] - assert "--subscription" in cmd - assert "--tenant" not in cmd - - @patch("subprocess.run") - def test_set_deployment_context_failure(self, mock_run): - from azext_prototype.stages.deploy_helpers import set_deployment_context - - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Subscription not found") - result = set_deployment_context("bad-sub") - assert result["status"] == "failed" - assert "Subscription not found" in result["error"] - - @patch("subprocess.run") - def test_get_current_tenant(self, mock_run): - from azext_prototype.stages.deploy_helpers import get_current_tenant - - mock_run.return_value = MagicMock(returncode=0, stdout="tenant-abc\n", stderr="") - result = get_current_tenant() - assert result == "tenant-abc" - - -# ====================================================================== -# Tenant preflight tests -# ====================================================================== - - -class TestTenantPreflight: - """Tests for tenant preflight checking in DeploySession.""" - - def _make_session(self, project_dir): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - @patch("azext_prototype.stages.deploy_session.get_current_tenant", return_value="tenant-abc") - def test_tenant_preflight_match(self, mock_tenant, tmp_project): - session = self._make_session(tmp_project) - result = session._check_tenant("tenant-abc") - assert result["status"] == "pass" - - @patch("azext_prototype.stages.deploy_session.get_current_tenant", return_value="tenant-xyz") - def test_tenant_preflight_mismatch(self, mock_tenant, tmp_project): - session = self._make_session(tmp_project) - result = session._check_tenant("tenant-abc") - assert result["status"] == "warn" - assert "fix_command" in result - assert "az login --tenant" in result["fix_command"] - - -# ====================================================================== -# SP parameter validation in prototype_deploy -# ====================================================================== - - -class TestDeploySPValidation: - """Tests for --service-principal validation in prototype_deploy.""" - - @patch("azext_prototype.custom._check_requirements") - @patch("azext_prototype.custom._get_project_dir") - def test_sp_missing_params_raises(self, mock_dir, mock_check_req, project_with_config): - from knack.util import CLIError - - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_config) - - with pytest.raises(CLIError, match="requires client-id"): - prototype_deploy( - cmd=MagicMock(), - service_principal=True, - client_id="abc", - # Missing client_secret and tenant_id - ) - - @patch("azext_prototype.custom._check_requirements") - @patch("azext_prototype.custom._get_project_dir") - @patch("azext_prototype.stages.deploy_helpers.login_service_principal") - def test_sp_login_failure_raises(self, mock_login, mock_dir, mock_check_req, project_with_config): - from knack.util import CLIError - - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_config) - mock_login.return_value = {"status": "failed", "error": "bad creds"} - - with pytest.raises(CLIError, match="Service principal login failed"): - prototype_deploy( - cmd=MagicMock(), - service_principal=True, - client_id="abc", - client_secret="def", - tenant_id="ghi", - ) - - @patch("azext_prototype.custom._check_requirements") - @patch("azext_prototype.custom._get_project_dir") - @patch("azext_prototype.stages.deploy_helpers.login_service_principal") - @patch("azext_prototype.custom._check_guards") - def test_sp_login_success_proceeds(self, mock_guards, mock_login, mock_dir, mock_check_req, project_with_config): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_config) - mock_login.return_value = {"status": "ok", "subscription": "sp-sub-123"} - - # Let guards pass, but make deploy_stage.execute raise so we can verify flow - mock_guards.return_value = None - - with patch("azext_prototype.stages.deploy_stage.DeployStage.execute") as mock_exec: - mock_exec.return_value = {"status": "success"} - result = prototype_deploy( - cmd=MagicMock(), - service_principal=True, - client_id="abc", - client_secret="def", - tenant_id="ghi", - json_output=True, - ) - assert result["status"] == "success" - # Verify tenant and subscription were passed through - call_kwargs = mock_exec.call_args[1] - assert call_kwargs["tenant"] == "ghi" - assert call_kwargs["subscription"] == "sp-sub-123" - - -# ====================================================================== -# Subscription resolution chain tests -# ====================================================================== - - -class TestSubscriptionResolution: - """Tests for subscription resolution: CLI arg > config > current context.""" - - def _make_session(self, project_dir, config_subscription=""): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - "deploy": {"subscription": config_subscription, "resource_group": ""}, - } - config_path = Path(project_dir) / "prototype.yaml" - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_cli_arg_takes_priority(self, tmp_project): - session = self._make_session(tmp_project, config_subscription="config-sub") - output = [] - session.run( - subscription="cli-sub", - input_fn=lambda p: "quit", - print_fn=lambda msg: output.append(msg), - ) - # The subscription displayed should be the CLI arg - joined = "\n".join(output) - assert "cli-sub" in joined - - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="context-sub") - def test_config_sub_used_when_no_cli_arg(self, mock_sub, tmp_project): - session = self._make_session(tmp_project, config_subscription="config-sub") - output = [] - session.run( - input_fn=lambda p: "quit", - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "config-sub" in joined - - -# ====================================================================== -# /login slash command tests -# ====================================================================== - - -class TestLoginSlashCommand: - """Tests for the /login slash command in DeploySession.""" - - def _make_session(self, project_dir): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - @patch("azext_prototype.stages.deploy_session.subprocess.run") - def test_login_command_success(self, mock_run, tmp_project): - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - session = self._make_session(tmp_project) - output = [] - session._handle_slash_command( - "/login", - False, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - joined = "\n".join(output) - assert "Login successful" in joined - assert "/preflight" in joined - - @patch("azext_prototype.stages.deploy_session.subprocess.run") - def test_login_command_failure(self, mock_run, tmp_project): - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="AADSTS error") - session = self._make_session(tmp_project) - output = [] - session._handle_slash_command( - "/login", - False, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - joined = "\n".join(output) - assert "Login failed" in joined - - def test_help_includes_login(self, tmp_project): - session = self._make_session(tmp_project) - output = [] - session._handle_slash_command( - "/help", - False, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - joined = "\n".join(output) - assert "/login" in joined - - -# ====================================================================== -# _prepare_deploy_command tests -# ====================================================================== - - -class TestPrepareDeployCommand: - """Tests for _prepare_deploy_command in custom.py.""" - - @patch("azext_prototype.custom._check_requirements") - @patch("azext_prototype.custom._get_project_dir") - def test_returns_none_ai_provider_when_factory_fails(self, mock_dir, mock_check_req, project_with_config): - from azext_prototype.custom import _prepare_deploy_command - - mock_dir.return_value = str(project_with_config) - - with patch("azext_prototype.ai.factory.create_ai_provider", side_effect=Exception("No Copilot license")): - project_dir, config, registry, agent_context = _prepare_deploy_command() - - assert agent_context.ai_provider is None - assert project_dir == str(project_with_config) - - @patch("azext_prototype.custom._check_requirements") - @patch("azext_prototype.custom._get_project_dir") - def test_returns_ai_provider_when_factory_succeeds(self, mock_dir, mock_check_req, project_with_config): - from azext_prototype.custom import _prepare_deploy_command - - mock_dir.return_value = str(project_with_config) - mock_provider = MagicMock() - - with patch("azext_prototype.ai.factory.create_ai_provider", return_value=mock_provider): - project_dir, config, registry, agent_context = _prepare_deploy_command() - - assert agent_context.ai_provider is mock_provider - - -# ====================================================================== -# Config SP routing tests -# ====================================================================== - - -class TestConfigSPRouting: - """Verify SP credentials route to secrets file.""" - - def test_sp_client_id_is_secret(self): - from azext_prototype.config import ProjectConfig - - assert ProjectConfig._is_secret_key("deploy.service_principal.client_id") - assert ProjectConfig._is_secret_key("deploy.service_principal.client_secret") - assert ProjectConfig._is_secret_key("deploy.service_principal.tenant_id") - - def test_default_config_has_sp_section(self): - from azext_prototype.config import DEFAULT_CONFIG - - deploy = DEFAULT_CONFIG["deploy"] - assert "tenant" in deploy - assert "service_principal" in deploy - sp = deploy["service_principal"] - assert "client_id" in sp - assert "client_secret" in sp - assert "tenant_id" in sp - - -# ====================================================================== -# _terraform_validate tests -# ====================================================================== - - -class TestTerraformValidate: - """Tests for the _terraform_validate() helper in deploy_helpers.""" - - @patch("subprocess.run") - def test_validate_success(self, mock_run): - from azext_prototype.stages.deploy_helpers import _terraform_validate - - mock_run.return_value = MagicMock(returncode=0, stdout="Success!", stderr="") - result = _terraform_validate(Path("/tmp/fake")) - assert result["ok"] is True - - @patch("subprocess.run") - def test_validate_failure(self, mock_run): - from azext_prototype.stages.deploy_helpers import _terraform_validate - - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Error: Unsupported block type") - result = _terraform_validate(Path("/tmp/fake")) - assert result["ok"] is False - assert "Unsupported block type" in result["error"] - - @patch("subprocess.run") - def test_validate_returns_stdout_on_empty_stderr(self, mock_run): - from azext_prototype.stages.deploy_helpers import _terraform_validate - - mock_run.return_value = MagicMock(returncode=1, stdout="Invalid HCL syntax", stderr="") - result = _terraform_validate(Path("/tmp/fake")) - assert result["ok"] is False - assert "Invalid HCL syntax" in result["error"] - - @patch("subprocess.run") - def test_deploy_terraform_calls_validate(self, mock_run, tmp_project): - """Verify deploy_terraform() calls validate between init and plan.""" - from azext_prototype.stages.deploy_helpers import deploy_terraform - - # init succeeds, validate fails - mock_run.side_effect = [ - MagicMock(returncode=0, stdout="", stderr=""), # init - MagicMock(returncode=1, stdout="", stderr="Error: bad HCL"), # validate - ] - result = deploy_terraform(tmp_project, "sub-123") - assert result["status"] == "failed" - assert result["command"] == "terraform validate" - assert "bad HCL" in result["error"] - - @patch("subprocess.run") - def test_deploy_terraform_validate_pass_continues(self, mock_run, tmp_project): - """Verify deploy_terraform() continues past validate when it passes.""" - from azext_prototype.stages.deploy_helpers import deploy_terraform - - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - result = deploy_terraform(tmp_project, "sub-123") - assert result["status"] == "deployed" - # Should have called: init, validate, plan, apply = 4 calls - assert mock_run.call_count == 4 - - -# ====================================================================== -# Terraform preflight validation tests -# ====================================================================== - - -class TestTerraformPreflightValidation: - """Tests for _check_terraform_validate() in DeploySession.""" - - def _make_session(self, project_dir, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - build_path = _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - # Load build state into deploy state so _check_terraform_validate has stages - session._deploy_state.load_from_build_state(build_path) - return session - - @patch("azext_prototype.stages.deploy_session.subprocess.run") - def test_valid_terraform_passes(self, mock_run, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - stage_dir = tmp_project / "concept" / "infra" / "terraform" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text('resource "azurerm_resource_group" "rg" {}') - - session = self._make_session(tmp_project, build_stages=stages) - - mock_run.side_effect = [ - MagicMock(returncode=0, stdout="", stderr=""), # init - MagicMock(returncode=0, stdout="", stderr=""), # validate - ] - results = session._check_terraform_validate() - assert len(results) == 1 - assert results[0]["status"] == "pass" - - @patch("azext_prototype.stages.deploy_session.subprocess.run") - def test_invalid_terraform_fails(self, mock_run, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - stage_dir = tmp_project / "concept" / "infra" / "terraform" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "versions.tf").write_text("}") - - session = self._make_session(tmp_project, build_stages=stages) - - mock_run.side_effect = [ - MagicMock(returncode=0, stdout="", stderr=""), # init - MagicMock(returncode=1, stdout="", stderr="Error: Unsupported block type"), # validate - ] - results = session._check_terraform_validate() - assert len(results) == 1 - assert results[0]["status"] == "fail" - assert "Unsupported block type" in results[0]["message"] - - @patch("azext_prototype.stages.deploy_session.subprocess.run") - def test_init_failure_reported(self, mock_run, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - stage_dir = tmp_project / "concept" / "infra" / "terraform" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text("bad content") - - session = self._make_session(tmp_project, build_stages=stages) - - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Init error") - results = session._check_terraform_validate() - assert len(results) == 1 - assert results[0]["status"] == "fail" - assert "Init failed" in results[0]["message"] - - def test_skips_app_stages(self, tmp_project): - stages = [ - { - "stage": 1, - "name": "App", - "layer": "app", - "capability": "app", - "services": [], - "dir": "concept/apps/stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "apps" / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - results = session._check_terraform_validate() - assert len(results) == 0 - - def test_skips_missing_dirs(self, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform/nonexistent", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - results = session._check_terraform_validate() - assert len(results) == 0 - - def test_skips_dirs_without_tf_files(self, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - stage_dir = tmp_project / "concept" / "infra" / "terraform" - stage_dir.mkdir(parents=True, exist_ok=True) - # No .tf files in the directory - - session = self._make_session(tmp_project, build_stages=stages) - results = session._check_terraform_validate() - assert len(results) == 0 - - @patch("azext_prototype.stages.deploy_session.subprocess.run") - def test_preflight_includes_terraform_validate(self, mock_run, tmp_project): - """Verify _run_preflight() includes terraform validate results.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - stage_dir = tmp_project / "concept" / "infra" / "terraform" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text('resource "null" "x" {}') - - session = self._make_session(tmp_project, build_stages=stages) - session._subscription = "sub-123" - - mock_run.return_value = MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr="") - - with patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True), patch( - "azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123" - ): - results = session._run_preflight() - - names = [r["name"] for r in results] - assert any("Terraform Validate" in n for n in names) - - -# ====================================================================== -# Deploy env threading tests -# ====================================================================== - - -class TestDeployEnv: - """Tests for deploy env construction and threading in DeploySession.""" - - def _make_session(self, project_dir, config_data=None, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - if config_data is None: - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - - config_path = Path(project_dir) / "prototype.yaml" - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_resolve_context_builds_deploy_env(self, tmp_project): - session = self._make_session(tmp_project) - session._resolve_context("sub-123", None) - - assert session._deploy_env is not None - assert session._deploy_env["ARM_SUBSCRIPTION_ID"] == "sub-123" - assert session._deploy_env["SUBSCRIPTION_ID"] == "sub-123" - - def test_resolve_context_with_tenant(self, tmp_project): - session = self._make_session(tmp_project) - session._resolve_context("sub-123", "tenant-456") - - assert session._deploy_env is not None - assert session._deploy_env["ARM_TENANT_ID"] == "tenant-456" - - def test_resolve_context_sp_creds_in_env(self, tmp_project): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - "deploy": { - "service_principal": { - "client_id": "sp-client", - "client_secret": "sp-secret", - "tenant_id": "sp-tenant", - }, - }, - } - # Write secrets file with SP creds - secrets_path = Path(tmp_project) / "prototype.secrets.yaml" - secrets_data = { - "deploy": { - "service_principal": { - "client_id": "sp-client", - "client_secret": "sp-secret", - "tenant_id": "sp-tenant", - }, - }, - } - with open(secrets_path, "w") as f: - yaml.dump(secrets_data, f) - - session = self._make_session(tmp_project, config_data=config_data) - session._resolve_context("sub-123", None) - - env = session._deploy_env - assert env is not None - # SP creds come from config.get("deploy.service_principal") which - # reads merged config+secrets. If the config has them, they should - # appear in the env. - assert env["ARM_SUBSCRIPTION_ID"] == "sub-123" - - @patch("azext_prototype.stages.deploy_session.deploy_terraform") - @patch("azext_prototype.stages.deploy_session.set_deployment_context", return_value={"status": "ok"}) - def test_deploy_single_stage_passes_env(self, _mock_ctx, mock_tf, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, build_stages=stages) - # Load build state into deploy state - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - session._resolve_context("sub-123", "tenant-456") - - mock_tf.return_value = {"status": "deployed"} - - stage = session._deploy_state._state["deployment_stages"][0] - session._deploy_single_stage(stage) - - # Verify env= was passed - assert mock_tf.called - _, kwargs = mock_tf.call_args - assert "env" in kwargs - assert kwargs["env"]["ARM_SUBSCRIPTION_ID"] == "sub-123" - assert kwargs["env"]["ARM_TENANT_ID"] == "tenant-456" - - @patch("azext_prototype.stages.deploy_session.deploy_bicep") - @patch("azext_prototype.stages.deploy_session.set_deployment_context", return_value={"status": "ok"}) - def test_deploy_single_stage_bicep_passes_env(self, _mock_ctx, mock_bicep, tmp_project): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "bicep"}, - "ai": {"provider": "github-models"}, - } - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/bicep", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "bicep").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, config_data=config_data, build_stages=stages) - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - session._resolve_context("sub-123", "tenant-456") - - mock_bicep.return_value = {"status": "deployed"} - - stage = session._deploy_state._state["deployment_stages"][0] - session._deploy_single_stage(stage) - - assert mock_bicep.called - _, kwargs = mock_bicep.call_args - assert kwargs["env"]["ARM_TENANT_ID"] == "tenant-456" - - @patch("azext_prototype.stages.deploy_session.rollback_terraform") - @patch("azext_prototype.stages.deploy_session.set_deployment_context", return_value={"status": "ok"}) - def test_rollback_passes_env(self, _mock_ctx, mock_rb, tmp_project): - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, build_stages=stages) - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - session._resolve_context("sub-123", "tenant-456") - - # Mark as deployed so we can rollback - session._deploy_state.mark_stage_deployed(1) - - mock_rb.return_value = {"status": "rolled_back"} - output = [] - session._rollback_stage(1, lambda msg: output.append(msg)) - - assert mock_rb.called - _, kwargs = mock_rb.call_args - assert kwargs["env"]["ARM_SUBSCRIPTION_ID"] == "sub-123" - - -# ====================================================================== -# Deployer object ID lookup tests -# ====================================================================== - - -class TestDeployerObjectIdLookup: - """Tests for _lookup_deployer_object_id() and its integration.""" - - @patch("azext_prototype.stages.deploy_session.subprocess.run") - def test_sp_lookup(self, mock_run): - from azext_prototype.stages.deploy_session import _lookup_deployer_object_id - - mock_run.return_value = MagicMock(returncode=0, stdout="sp-object-id-abc\n", stderr="") - result = _lookup_deployer_object_id("my-client-id") - - assert result == "sp-object-id-abc" - cmd = mock_run.call_args[0][0] - assert "sp" in cmd - assert "show" in cmd - assert "my-client-id" in cmd - - @patch("azext_prototype.stages.deploy_session.subprocess.run") - def test_user_lookup(self, mock_run): - from azext_prototype.stages.deploy_session import _lookup_deployer_object_id - - mock_run.return_value = MagicMock(returncode=0, stdout="user-object-id-xyz\n", stderr="") - result = _lookup_deployer_object_id(None) - - assert result == "user-object-id-xyz" - cmd = mock_run.call_args[0][0] - assert "signed-in-user" in cmd - - @patch("azext_prototype.stages.deploy_session.subprocess.run") - def test_lookup_failure_returns_none(self, mock_run): - from azext_prototype.stages.deploy_session import _lookup_deployer_object_id - - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error") - assert _lookup_deployer_object_id("bad-id") is None - assert _lookup_deployer_object_id(None) is None - - @patch("azext_prototype.stages.deploy_session.subprocess.run", side_effect=FileNotFoundError) - def test_lookup_no_az_cli(self, _mock_run): - from azext_prototype.stages.deploy_session import _lookup_deployer_object_id - - assert _lookup_deployer_object_id("client-id") is None - - @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value="sp-oid-123") - @patch("azext_prototype.stages.deploy_session.set_deployment_context", return_value={"status": "ok"}) - def test_resolve_context_sets_deployer_oid_for_sp(self, _mock_ctx, _mock_lookup, tmp_project): - """SP auth: deployer_object_id is the SP's object ID.""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(tmp_project) / "prototype.yaml" - config_data = { - "project": {"name": "t", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - context = AgentContext(project_config={}, project_dir=str(tmp_project), ai_provider=MagicMock()) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - - session._resolve_context("sub-123", "tenant-456", client_id="my-app-id", client_secret="secret") - - assert session._deploy_env["TF_VAR_deployer_object_id"] == "sp-oid-123" - _mock_lookup.assert_called_once_with("my-app-id") - - @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value="user-oid-456") - def test_resolve_context_sets_deployer_oid_for_user(self, _mock_lookup, tmp_project): - """User auth (no SP): deployer_object_id is the signed-in user's object ID.""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(tmp_project) / "prototype.yaml" - config_data = { - "project": {"name": "t", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - context = AgentContext(project_config={}, project_dir=str(tmp_project), ai_provider=MagicMock()) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - - session._resolve_context("sub-123", None) - - assert session._deploy_env["TF_VAR_deployer_object_id"] == "user-oid-456" - # Called with None (no client_id) → signed-in-user path - _mock_lookup.assert_called_once_with(None) - - @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) - def test_resolve_context_no_oid_when_lookup_fails(self, _mock_lookup, tmp_project): - """When lookup fails, TF_VAR_deployer_object_id is not set.""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(tmp_project) / "prototype.yaml" - config_data = { - "project": {"name": "t", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - context = AgentContext(project_config={}, project_dir=str(tmp_project), ai_provider=MagicMock()) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - - session._resolve_context("sub-123", None) - - assert "TF_VAR_deployer_object_id" not in session._deploy_env - - -# ====================================================================== -# Coverage expansion: run() phases, slash commands, remediation -# ====================================================================== - - -class TestRunPhasesCoverage: - """Tests covering run() phases: no build state, re-entry sync, - preflight failure branch, interactive loop edge cases.""" - - def _make_session(self, project_dir, iac_tool="terraform", build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - if build_stages is not None: - _write_build_yaml(project_dir, stages=build_stages, iac_tool=iac_tool) - - context = AgentContext( - project_config={"project": {"iac_tool": iac_tool}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_run_no_build_state_returns_cancelled(self, tmp_project): - """Lines 322-324: No build state file => cancelled.""" - session = self._make_session(tmp_project, build_stages=None) - # No build.yaml written - output = [] - result = session.run( - subscription="sub-123", - input_fn=lambda p: "quit", - print_fn=lambda msg: output.append(msg), - ) - assert result.cancelled is True - joined = "\n".join(output) - assert "No build state found" in joined - - def test_run_reentry_sync_shows_changes(self, tmp_project): - """Lines 326-335: Re-entry with build state changes shows sync info.""" - from azext_prototype.stages.deploy_state import SyncResult - - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - # Pre-load deployment_stages so re-entry branch triggers - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - - sync = SyncResult(created=["Stage 2: Data"], orphaned=[], updated_code=1, details=["Added new Stage 2: Data"]) - with patch.object(session._deploy_state, "sync_from_build_state", return_value=sync): - output = [] - session.run( - subscription="sub-123", - input_fn=lambda p: "quit", - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "Build state changed" in joined - assert "updated code" in joined.lower() or "1 deployed stage(s)" in joined - - def test_run_tenant_displayed(self, tmp_project): - """Lines 352-353: Tenant is printed during plan overview.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session.run( - subscription="sub-123", - tenant="tenant-abc", - input_fn=lambda p: "quit", - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "tenant-abc" in joined - - def test_run_resource_group_displayed(self, tmp_project): - """Lines 354-355: Resource group is printed when set.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - "deploy": {"resource_group": "my-rg", "subscription": ""}, - } - config_path = Path(tmp_project) / "prototype.yaml" - with open(config_path, "w") as f: - yaml.dump(config_data, f) - _write_build_yaml(tmp_project, stages=stages) - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - - output = [] - session.run( - subscription="sub-123", - input_fn=lambda p: "quit", - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "my-rg" in joined - - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=False) - @patch( - "azext_prototype.stages.deploy_session.subprocess.run", - return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), - ) - def test_run_preflight_failure_branch(self, _mock_sub, _mock_login, tmp_project): - """Lines 388-391: Preflight failures print fix instructions.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - inputs = iter(["", "done"]) - output = [] - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "preflight checks failed" in joined.lower() or "fix the issues" in joined.lower() - - def test_run_empty_input_continues(self, tmp_project): - """Lines 419-420: Empty input during interactive loop does nothing.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - # Skip preflight by having everything fail, then loop: empty -> quit - inputs = iter(["", "", "", "quit"]) - output = [] - with patch.object(session, "_run_preflight", return_value=[]): - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - # Reached quit without error - - def test_run_done_finishes(self, tmp_project): - """Lines 427-428: 'done' word exits loop.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - inputs = iter(["", "lgtm"]) - output = [] - with patch.object(session, "_run_preflight", return_value=[]): - result = session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - assert not result.cancelled - - def test_run_eof_in_interactive_loop_breaks(self, tmp_project): - """Lines 416-417: EOFError in interactive loop breaks cleanly.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - call_count = [0] - - def eof_on_second(p): - call_count[0] += 1 - if call_count[0] == 1: - return "" # confirm - raise EOFError - - with patch.object(session, "_run_preflight", return_value=[]): - result = session.run( - subscription="sub-123", - input_fn=eof_on_second, - print_fn=lambda msg: None, - ) - assert not result.cancelled # exits normally via break - - def test_run_natural_language_fallback(self, tmp_project): - """Line 468: Unrecognized input shows help hint.""" - from azext_prototype.stages.intent import IntentKind, IntentResult - - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - # Mock intent classifier to return CONVERSATIONAL (no matching command) - session._intent_classifier = MagicMock() - session._intent_classifier.classify.return_value = IntentResult( - kind=IntentKind.CONVERSATIONAL, command="", args="" - ) - inputs = iter(["", "something random", "quit"]) - output = [] - with patch.object(session, "_run_preflight", return_value=[]): - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "/help" in joined - - def test_run_natural_language_multi_stage(self, tmp_project): - """Lines 448-456: Multi-stage intent dispatches multiple commands.""" - from azext_prototype.stages.intent import IntentKind, IntentResult - - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - session._intent_classifier = MagicMock() - session._intent_classifier.classify.return_value = IntentResult( - kind=IntentKind.COMMAND, command="/deploy", args="stages 1 and 2" - ) - inputs = iter(["", "deploy stages 1 and 2", "quit"]) - output = [] - with patch.object(session, "_run_preflight", return_value=[]): - with patch.object(session, "_handle_slash_command") as mock_cmd: - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - # Should have dispatched /deploy 1 and /deploy 2 - calls = [c.args[0] for c in mock_cmd.call_args_list] - assert "/deploy 1" in calls - assert "/deploy 2" in calls - - -class TestSingleStageFailureRemediation: - """Tests for run_single_stage failure remediation (lines 587-598).""" - - def _make_session(self, project_dir, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - @patch( - "azext_prototype.stages.deploy_session.deploy_terraform", - return_value={"status": "failed", "error": "auth error"}, - ) - def test_single_stage_failure_shows_error_and_attempts_remediation(self, mock_tf, tmp_project): - """Lines 587-598: Single-stage failure prints error and tries remediation.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - # Clear fix agents so _remediate_deploy_failure returns None - session._iac_agents = {} - session._dev_agent = None - - output = [] - session.run_single_stage( - 1, - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "failed" in joined.lower() - assert "auth error" in joined - - @patch("azext_prototype.stages.deploy_session.deploy_terraform") - def test_single_stage_remediation_success(self, mock_tf, tmp_project): - """Lines 597-598: Remediation succeeds prints success.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - # First call fails, remediation returns deployed - mock_tf.return_value = {"status": "failed", "error": "oops"} - - with patch.object( - session, - "_remediate_deploy_failure", - return_value={"status": "deployed"}, - ): - output = [] - session.run_single_stage( - 1, - subscription="sub-123", - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "remediation" in joined.lower() - - -class TestDeployPendingStagesAwaitingManual: - """Tests covering awaiting_manual status (lines 892-909).""" - - def _make_session(self, project_dir, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_manual_step_done_marks_deployed(self, tmp_project): - """Lines 892-904: Manual step answered with 'done' marks deployed.""" - stages = [ - { - "stage": 1, - "name": "Manual DNS", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - "deploy_mode": "manual", - "manual_instructions": "Update DNS records.", - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - # Manually set deploy_mode on the loaded state - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - ds = session._deploy_state._state["deployment_stages"][0] - ds["deploy_mode"] = "manual" - ds["manual_instructions"] = "Update DNS records." - - output = [] - session._deploy_pending_stages( - force=False, - use_styled=False, - _print=lambda msg: output.append(msg), - _input=lambda p: "done", - ) - joined = "\n".join(output) - assert "Manual step required" in joined or "manual" in joined.lower() - - def test_manual_step_skip(self, tmp_project): - """Lines 905-906: Manual step answered with 'skip' skips.""" - stages = [ - { - "stage": 1, - "name": "Manual DNS", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - ds = session._deploy_state._state["deployment_stages"][0] - ds["deploy_mode"] = "manual" - ds["manual_instructions"] = "Do something manual." - - output = [] - session._deploy_pending_stages( - force=False, - use_styled=False, - _print=lambda msg: output.append(msg), - _input=lambda p: "skip", - ) - joined = "\n".join(output) - assert "skip" in joined.lower() - - def test_manual_step_eof_skips(self, tmp_project): - """Lines 899-901: Manual step EOF is treated as skipped.""" - stages = [ - { - "stage": 1, - "name": "Manual Step", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - ds = session._deploy_state._state["deployment_stages"][0] - ds["deploy_mode"] = "manual" - ds["manual_instructions"] = "Do it." - - output = [] - session._deploy_pending_stages( - force=False, - use_styled=False, - _print=lambda msg: output.append(msg), - _input=lambda p: (_ for _ in ()).throw(EOFError), - ) - joined = "\n".join(output) - assert "skipped" in joined.lower() - - def test_manual_step_other_breaks(self, tmp_project): - """Lines 907-909: Unknown answer pauses deployment.""" - stages = [ - { - "stage": 1, - "name": "Manual Step", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - ds = session._deploy_state._state["deployment_stages"][0] - ds["deploy_mode"] = "manual" - ds["manual_instructions"] = "Do it." - - output = [] - session._deploy_pending_stages( - force=False, - use_styled=False, - _print=lambda msg: output.append(msg), - _input=lambda p: "help me", - ) - joined = "\n".join(output) - assert "pausing" in joined.lower() or "continue" in joined.lower() - - -class TestRollbackAllCoverage: - """Tests for _rollback_all (lines 1618-1640).""" - - def _make_session(self, project_dir, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_rollback_all_no_candidates(self, tmp_project): - """Lines 1619-1621: No deployed stages to roll back.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - - output = [] - session._rollback_all(lambda msg: output.append(msg), lambda p: "y") - joined = "\n".join(output) - assert "No deployed stages" in joined - - @patch("azext_prototype.stages.deploy_session.rollback_terraform") - def test_rollback_all_confirms_each(self, mock_rb, tmp_project): - """Lines 1626-1640: Confirms each stage and rolls back.""" - stages = [ - { - "stage": 1, - "name": "A", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - { - "stage": 2, - "name": "B", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "stage-2", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - (tmp_project / "stage-2").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - session._deploy_state.mark_stage_deployed(1) - session._deploy_state.mark_stage_deployed(2) - session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} - - mock_rb.return_value = {"status": "rolled_back"} - output = [] - session._rollback_all(lambda msg: output.append(msg), lambda p: "y") - joined = "\n".join(output) - assert "Rolling back" in joined - assert mock_rb.call_count == 2 - - def test_rollback_all_decline_stops(self, tmp_project): - """Lines 1635-1637: Declining rollback stops the sequence.""" - stages = [ - { - "stage": 1, - "name": "A", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - { - "stage": 2, - "name": "B", - "capability": "infra", - "services": [], - "dir": "stage-2", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - session._deploy_state.mark_stage_deployed(1) - session._deploy_state.mark_stage_deployed(2) - - output = [] - session._rollback_all(lambda msg: output.append(msg), lambda p: "n") - joined = "\n".join(output) - assert "Skipping" in joined - - def test_rollback_all_eof_cancels(self, tmp_project): - """Lines 1631-1633: EOF during rollback cancels.""" - stages = [ - { - "stage": 1, - "name": "A", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - build_path = Path(tmp_project) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - session._deploy_state.mark_stage_deployed(1) - - output = [] - session._rollback_all( - lambda msg: output.append(msg), - lambda p: (_ for _ in ()).throw(EOFError), - ) - joined = "\n".join(output) - assert "cancelled" in joined.lower() - - -class TestSlashCommandPlan: - """Tests covering /plan slash command (lines 1842-1875).""" - - def _make_session(self, project_dir, iac_tool="terraform", build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages, iac_tool=iac_tool) - - context = AgentContext( - project_config={"project": {"iac_tool": iac_tool}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - return session - - def test_plan_no_arg(self, tmp_project): - """Lines 1843-1844: /plan without arg shows usage.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_slash_command("/plan", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "Usage" in joined - - def test_plan_manual_stage(self, tmp_project): - """Line 1850: Manual stage has no plan preview.""" - stages = [ - { - "stage": 1, - "name": "Manual", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - ds = session._deploy_state._state["deployment_stages"][0] - ds["deploy_mode"] = "manual" - - output = [] - session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "manual step" in joined.lower() - - def test_plan_missing_dir(self, tmp_project): - """Lines 1851-1852: Stage dir not found.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "nonexistent", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "not found" in joined.lower() - - @patch( - "azext_prototype.stages.deploy_session.plan_terraform", - return_value={"output": "Plan: 5 to add", "error": None}, - ) - def test_plan_terraform_infra_stage(self, mock_plan, tmp_project): - """Lines 1855-1861: Terraform plan for infra stage.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} - session._subscription = "sub-123" - - output = [] - session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "Plan: 5 to add" in joined - - @patch( - "azext_prototype.stages.deploy_session.whatif_bicep", - return_value={"output": "What-if: 2 to create", "error": None}, - ) - def test_plan_bicep_infra_stage(self, mock_whatif, tmp_project): - """Lines 1862-1868: Bicep what-if for infra stage.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, iac_tool="bicep", build_stages=stages) - session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} - session._subscription = "sub-123" - session._resource_group = "my-rg" - - output = [] - session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "What-if: 2 to create" in joined - - @patch( - "azext_prototype.stages.deploy_session.plan_terraform", - return_value={"output": None, "error": "Init failed"}, - ) - def test_plan_with_error(self, mock_plan, tmp_project): - """Lines 1871-1872: Plan error is displayed.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "layer": "infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} - session._subscription = "sub-123" - - output = [] - session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "Init failed" in joined - - def test_plan_app_stage_no_preview(self, tmp_project): - """Lines 1873-1874: App stages have no plan preview.""" - stages = [ - { - "stage": 1, - "name": "App", - "capability": "app", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - - output = [] - session._handle_slash_command("/plan 1", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "app stage" in joined.lower() - - -class TestSlashCommandSplit: - """Tests covering /split slash command (lines 1878-1903).""" - - def _make_session(self, project_dir, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - return session - - def test_split_no_arg(self, tmp_project): - """Lines 1879-1880: /split without arg shows usage.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_slash_command("/split", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "Usage" in joined - - def test_split_success(self, tmp_project): - """Lines 1887-1900: Split stage into substages.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - names = iter(["Networking", "Compute", ""]) # 2 substages + blank - - output = [] - session._handle_slash_command( - "/split 1", - False, - False, - lambda msg: output.append(msg), - lambda p: next(names), - ) - joined = "\n".join(output) - assert "Split into 2 substages" in joined - - def test_split_too_few_substages(self, tmp_project): - """Lines 1901-1902: Less than 2 substages cancels.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - names = iter(["OnlyOne", ""]) # 1 substage + blank - - output = [] - session._handle_slash_command( - "/split 1", - False, - False, - lambda msg: output.append(msg), - lambda p: next(names), - ) - joined = "\n".join(output) - assert "at least 2" in joined.lower() - - def test_split_eof_during_input(self, tmp_project): - """Lines 1893-1894: EOF during substage naming stops input.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - - output = [] - session._handle_slash_command( - "/split 1", - False, - False, - lambda msg: output.append(msg), - lambda p: (_ for _ in ()).throw(EOFError), - ) - # Should not crash, split cancelled - joined = "\n".join(output) - assert "at least 2" in joined.lower() or "Split" in joined - - -class TestSlashCommandDestroy: - """Tests covering /destroy slash command (lines 1906-1927).""" - - def _make_session(self, project_dir, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - return session - - def test_destroy_no_arg(self, tmp_project): - """Lines 1907-1908: /destroy without arg shows usage.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_slash_command("/destroy", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "Usage" in joined - - @patch("azext_prototype.stages.deploy_session.rollback_terraform") - def test_destroy_confirmed(self, mock_rb, tmp_project): - """Lines 1918-1922: Destroy confirmed rolls back and destroys.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "stage-1").mkdir(parents=True, exist_ok=True) - session = self._make_session(tmp_project, build_stages=stages) - session._deploy_state.mark_stage_deployed(1) - session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} - mock_rb.return_value = {"status": "rolled_back"} - - output = [] - session._handle_slash_command( - "/destroy 1", - False, - False, - lambda msg: output.append(msg), - lambda p: "y", - ) - joined = "\n".join(output) - assert "destroyed" in joined.lower() - - def test_destroy_cancelled(self, tmp_project): - """Lines 1925-1926: Destroy declined is cancelled.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - session._deploy_state.mark_stage_deployed(1) - - output = [] - session._handle_slash_command( - "/destroy 1", - False, - False, - lambda msg: output.append(msg), - lambda p: "n", - ) - joined = "\n".join(output) - assert "cancelled" in joined.lower() - - def test_destroy_eof_cancels(self, tmp_project): - """Lines 1915-1917: EOF during destroy confirmation cancels.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - session._deploy_state.mark_stage_deployed(1) - - output = [] - session._handle_slash_command( - "/destroy 1", - False, - False, - lambda msg: output.append(msg), - lambda p: (_ for _ in ()).throw(EOFError), - ) - joined = "\n".join(output) - assert "cancelled" in joined.lower() - - -class TestSlashCommandManual: - """Tests covering /manual slash command (lines 1930-1952).""" - - def _make_session(self, project_dir, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - return session - - def test_manual_no_arg(self, tmp_project): - """Lines 1931-1932: /manual without arg shows usage.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_slash_command("/manual", False, False, lambda msg: output.append(msg), lambda p: "") - joined = "\n".join(output) - assert "Usage" in joined - - def test_manual_set_instructions(self, tmp_project): - """Lines 1940-1944: Setting manual instructions.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_slash_command( - '/manual 1 "Run az keyvault set-policy"', - False, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - joined = "\n".join(output) - assert "manual mode" in joined.lower() - # Verify it was saved - ds = session._deploy_state._state["deployment_stages"][0] - assert ds["deploy_mode"] == "manual" - - def test_manual_view_existing_instructions(self, tmp_project): - """Lines 1946-1948: Viewing existing manual instructions.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - ds = session._deploy_state._state["deployment_stages"][0] - ds["manual_instructions"] = "Do the thing." - - output = [] - session._handle_slash_command( - "/manual 1", - False, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - joined = "\n".join(output) - assert "Do the thing" in joined - - def test_manual_view_no_instructions(self, tmp_project): - """Lines 1949-1951: No instructions set shows hint.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_slash_command( - "/manual 1", - False, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - joined = "\n".join(output) - assert "No manual instructions" in joined - - -class TestHandleDescribe: - """Tests for _handle_describe (lines 2020-2080).""" - - def _make_session(self, project_dir, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - return session - - def test_describe_no_arg(self, tmp_project): - """Lines 2024-2026: No arg shows usage.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_describe("", lambda msg: output.append(msg)) - joined = "\n".join(output) - assert "Usage" in joined - - def test_describe_no_numbers(self, tmp_project): - """Lines 2029-2031: No number in arg shows usage.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_describe("abc", lambda msg: output.append(msg)) - joined = "\n".join(output) - assert "Usage" in joined - - def test_describe_not_found(self, tmp_project): - """Lines 2035-2037: Stage not found.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_describe("99", lambda msg: output.append(msg)) - joined = "\n".join(output) - assert "not found" in joined.lower() - - def test_describe_full_details(self, tmp_project): - """Lines 2040-2080: Full description with services, files, output, error.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [ - { - "name": "kv", - "computed_name": "mykv", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - } - ], - "dir": "stage-1", - "status": "generated", - "files": ["stage-1/main.tf"], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - ds = session._deploy_state._state["deployment_stages"][0] - ds["deployed_at"] = "2026-01-01T12:00:00" - ds["deploy_output"] = "resource_id=abc123\nendpoint=https://foo.com" - ds["deploy_error"] = "some warning message" - - output = [] - session._handle_describe("1", lambda msg: output.append(msg)) - joined = "\n".join(output) - assert "Infra" in joined - assert "mykv" in joined - assert "Microsoft.KeyVault" in joined - assert "standard" in joined - assert "main.tf" in joined - assert "2026-01-01T12:00:00" in joined - assert "resource_id=abc123" in joined - assert "some warning message" in joined - - def test_describe_truncates_long_output(self, tmp_project): - """Lines 2074-2075: Long deploy output is truncated.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - ds = session._deploy_state._state["deployment_stages"][0] - ds["deploy_output"] = "\n".join(f"line {i}" for i in range(20)) - - output = [] - session._handle_describe("1", lambda msg: output.append(msg)) - joined = "\n".join(output) - assert "truncated" in joined.lower() - - -class TestUnknownSlashCommand: - """Tests for unknown slash command (line 2020).""" - - def _make_session(self, project_dir, build_stages=None): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - session = DeploySession(context, registry) - build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - return session - - def test_unknown_command(self, tmp_project): - """Line 2020: Unknown slash command shows error.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "stage-1", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - output = [] - session._handle_slash_command( - "/foobar", - False, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - joined = "\n".join(output) - assert "Unknown command" in joined - - -class TestMaybeSpinner: - """Tests for _maybe_spinner (lines 2099-2116).""" - - def _make_session(self, project_dir): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_spinner_with_status_fn(self, tmp_project): - """Lines 2106-2114: status_fn mode calls start/end/tokens.""" - session = self._make_session(tmp_project) - calls = [] - session._status_fn = lambda msg, kind: calls.append((msg, kind)) - - with session._maybe_spinner("Working...", use_styled=False): - pass - - # Should have called start and end - kinds = [k for _, k in calls] - assert "start" in kinds - assert "end" in kinds - - def test_spinner_plain_mode(self, tmp_project): - """Line 2116: Plain mode (no styled, no status_fn) just yields.""" - session = self._make_session(tmp_project) - session._status_fn = None - - with session._maybe_spinner("Working...", use_styled=False): - pass # Should not crash - - -class TestCollectStageFileContent: - """Tests for _collect_stage_file_content (lines 1178-1225).""" - - def _make_session(self, project_dir): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_glob_fallback_when_no_files(self, tmp_project): - """Lines 1191-1200: Falls back to globbing when files list is empty.""" - session = self._make_session(tmp_project) - stage_dir = tmp_project / "stage-1" - stage_dir.mkdir() - (stage_dir / "main.tf").write_text("resource {} {}") - - stage = {"dir": "stage-1", "files": []} - content = session._collect_stage_file_content(stage) - assert "main.tf" in content - assert "resource {} {}" in content - - def test_empty_dir_returns_empty(self, tmp_project): - """Lines 1202-1203: No files found returns empty string.""" - session = self._make_session(tmp_project) - stage = {"dir": "nonexistent", "files": []} - content = session._collect_stage_file_content(stage) - assert content == "" - - def test_unreadable_file(self, tmp_project): - """Lines 1213-1215: Unreadable file shows 'could not read'.""" - session = self._make_session(tmp_project) - stage = {"dir": "stage-1", "files": ["stage-1/missing.tf"]} - content = session._collect_stage_file_content(stage) - assert "could not read" in content - - def test_max_bytes_cap(self, tmp_project): - """Lines 1206-1208: Size cap truncates remaining files.""" - session = self._make_session(tmp_project) - stage_dir = tmp_project / "stage-1" - stage_dir.mkdir() - # Create a file larger than 1000 bytes - (stage_dir / "big.tf").write_text("x" * 2000) - - stage = {"dir": "stage-1", "files": ["stage-1/big.tf", "stage-1/other.tf"]} - content = session._collect_stage_file_content(stage, max_bytes=100) - assert "omitted" in content.lower() or "big.tf" in content - - def test_truncates_large_individual_files(self, tmp_project): - """Lines 1218-1219: Individual files over 8000 chars are truncated.""" - session = self._make_session(tmp_project) - stage_dir = tmp_project / "stage-1" - stage_dir.mkdir() - (stage_dir / "huge.tf").write_text("x" * 10000) - - stage = {"dir": "stage-1", "files": ["stage-1/huge.tf"]} - content = session._collect_stage_file_content(stage) - assert "truncated" in content.lower() - - -class TestParseStageNumbers: - """Tests for _parse_stage_numbers static method.""" - - def test_parses_json_array(self): - from azext_prototype.stages.deploy_session import DeploySession - - valid = [{"stage": 3}, {"stage": 4}, {"stage": 5}] - result = DeploySession._parse_stage_numbers("[3, 4]", valid) - assert result == [3, 4] - - def test_filters_invalid_numbers(self): - from azext_prototype.stages.deploy_session import DeploySession - - valid = [{"stage": 3}] - result = DeploySession._parse_stage_numbers("[3, 99]", valid) - assert result == [3] - - def test_fallback_to_regex(self): - from azext_prototype.stages.deploy_session import DeploySession - - valid = [{"stage": 5}, {"stage": 6}] - result = DeploySession._parse_stage_numbers("Stages 5 and 6 need updates", valid) - assert 5 in result - assert 6 in result - - def test_empty_array(self): - from azext_prototype.stages.deploy_session import DeploySession - - valid = [{"stage": 1}] - result = DeploySession._parse_stage_numbers("[]", valid) - assert result == [] - - -class TestWriteStageFiles: - """Tests for _write_stage_files (lines 1289-1330).""" - - def _make_session(self, project_dir): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_empty_content(self, tmp_project): - """Line 1294-1295: Empty content returns empty list.""" - session = self._make_session(tmp_project) - result = session._write_stage_files({"dir": "stage-1"}, "") - assert result == [] - - def test_no_file_blocks(self, tmp_project): - """Lines 1298-1299: No parseable file blocks returns empty.""" - session = self._make_session(tmp_project) - result = session._write_stage_files({"dir": "stage-1"}, "No code blocks here.") - assert result == [] - - def test_writes_files_and_strips_prefix(self, tmp_project): - """Lines 1310-1314: Stage dir prefix is stripped from filenames.""" - session = self._make_session(tmp_project) - stage_dir = tmp_project / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - - content = "```stage-1/main.tf\nresource {} {}\n```" - with patch.object(session, "_sync_build_state"): - result = session._write_stage_files({"dir": "stage-1", "stage": 1}, content) - assert len(result) == 1 - assert (stage_dir / "main.tf").exists() - - def test_blocked_files_dropped(self, tmp_project): - """Lines 1316-1318: Blocked files (versions.tf for terraform) are dropped.""" - session = self._make_session(tmp_project) - stage_dir = tmp_project / "stage-1" - stage_dir.mkdir(parents=True, exist_ok=True) - - content = ( - "```stage-1/main.tf\nresource {} {}\n```\n\n" - '```stage-1/versions.tf\nterraform { required_version = ">= 1.0" }\n```' - ) - with patch.object(session, "_sync_build_state"): - result = session._write_stage_files({"dir": "stage-1", "stage": 1}, content) - # versions.tf should be dropped - written_names = [Path(f).name for f in result] - assert "versions.tf" not in written_names - assert "main.tf" in written_names - - -class TestBuildFixTask: - """Tests for _build_fix_task (lines 1227-1287).""" - - def _make_session(self, project_dir): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - return DeploySession(context, registry) - - def test_infra_stage_selects_iac_agent(self, tmp_project): - """Lines 1242-1243: Infra capability selects IaC agent.""" - session = self._make_session(tmp_project) - stage = { - "stage": 1, - "name": "Infra", - "capability": "infra", - "dir": "stage-1", - "services": [], - } - agent, task = session._build_fix_task(stage, "error", "diag", "guide") - assert agent is not None # terraform agent from registry - assert "Fix deployment Stage 1" in task - - def test_app_stage_selects_dev_agent(self, tmp_project): - """Lines 1244-1245: App capability selects dev agent.""" - session = self._make_session(tmp_project) - stage = { - "stage": 1, - "name": "App", - "capability": "app", - "dir": "stage-1", - "services": [], - } - agent, task = session._build_fix_task(stage, "error", "diag", "guide") - assert agent is not None - assert "Fix deployment Stage 1" in task - - def test_no_agent_returns_none(self, tmp_project): - """Lines 1249-1250: No suitable agent returns (None, '').""" - session = self._make_session(tmp_project) - session._iac_agents = {} - session._dev_agent = None - stage = { - "stage": 1, - "name": "Infra", - "capability": "infra", - "dir": "stage-1", - "services": [], - } - agent, task = session._build_fix_task(stage, "error", "diag", "guide") - assert agent is None - assert task == "" - - def test_includes_services_in_task(self, tmp_project): - """Line 1277: Services included in fix task.""" - session = self._make_session(tmp_project) - stage = { - "stage": 1, - "name": "Infra", - "capability": "infra", - "dir": "stage-1", - "services": [ - { - "name": "kv", - "computed_name": "mykv", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - } - ], - } - agent, task = session._build_fix_task(stage, "err", "diag", "guide") - assert "mykv" in task - assert "Microsoft.KeyVault" in task - - -# ====================================================================== -# Natural Language Intent Detection — Deploy Integration -# ====================================================================== - - -class TestNaturalLanguageIntentDeploy: - """Test that natural language triggers correct deploy commands.""" - - def _make_session(self, project_dir, build_stages=None): - """Create a DeploySession with dependencies mocked.""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": "terraform"}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages) - - context = AgentContext( - project_config={"project": {"iac_tool": "terraform"}}, - project_dir=str(project_dir), - ai_provider=MagicMock(), - ) - registry = AgentRegistry() - register_all_builtin(registry) - - return DeploySession(context, registry) - - @patch( - "azext_prototype.stages.deploy_session.subprocess.run", - return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), - ) - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - @patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}) - def test_nl_deploy_stage_1(self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): - """'deploy stage 1' in natural language triggers deploy.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, build_stages=stages) - inputs = iter(["", "deploy stage 1", "done"]) - output = [] - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - # Should show deploy success or at least process the deploy command - assert "deployed" in joined.lower() or "Stage 1" in joined - - def test_nl_describe_stage(self, tmp_project): - """'describe stage 1' shows stage details.""" - session = self._make_session(tmp_project) - inputs = iter(["", "describe stage 1", "done"]) - output = [] - session.run( - subscription="sub-123", - input_fn=lambda p: next(inputs), - print_fn=lambda msg: output.append(msg), - ) - joined = "\n".join(output) - assert "Foundation" in joined or "Stage 1" in joined - - -# ====================================================================== -# Deploy State Remediation tests -# ====================================================================== - - -class TestDeployStateRemediation: - """Tests for remediation state tracking in DeployState.""" - - def test_mark_stage_remediating(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_failed(1, "auth error") - ds.mark_stage_remediating(1) - - stage = ds.get_stage(1) - assert stage["deploy_status"] == "remediating" - assert stage["remediation_attempts"] == 1 - - def test_remediation_attempts_increment(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_remediating(1) - assert ds.get_stage(1)["remediation_attempts"] == 1 - - ds.mark_stage_remediating(1) - assert ds.get_stage(1)["remediation_attempts"] == 2 - - ds.mark_stage_remediating(1) - assert ds.get_stage(1)["remediation_attempts"] == 3 - - def test_reset_stage_to_pending(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_failed(1, "timeout") - assert ds.get_stage(1)["deploy_status"] == "failed" - assert ds.get_stage(1)["deploy_error"] == "timeout" - - ds.reset_stage_to_pending(1) - stage = ds.get_stage(1) - assert stage["deploy_status"] == "pending" - assert stage["deploy_error"] == "" - - def test_add_patch_stages(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - new_stages = [ - {"stage": 0, "name": "Patch Fix", "capability": "infra"}, - ] - ds.add_patch_stages(new_stages) - - stages = ds.state["deployment_stages"] - assert len(stages) == 4 - # Should have deploy-specific fields - patch_stage = [s for s in stages if s["name"] == "Patch Fix"][0] - assert patch_stage["deploy_status"] == "pending" - assert patch_stage["remediation_attempts"] == 0 - assert patch_stage["deploy_timestamp"] is None - - def test_add_patch_stages_before_docs(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - stages = [ - {"stage": 1, "name": "Infra", "capability": "infra", "services": [], "dir": "s1", "files": []}, - {"stage": 2, "name": "Docs", "capability": "docs", "services": [], "dir": "s2", "files": []}, - ] - build_path = _write_build_yaml(tmp_project, stages=stages) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.add_patch_stages([{"stage": 0, "name": "Patch", "capability": "infra"}]) - - stage_names = [s["name"] for s in ds.state["deployment_stages"]] - # Patch should be before Docs - assert stage_names.index("Patch") < stage_names.index("Docs") - - def test_renumber_stages(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # Manually set non-sequential numbers - ds.state["deployment_stages"][0]["stage"] = 10 - ds.state["deployment_stages"][1]["stage"] = 20 - ds.state["deployment_stages"][2]["stage"] = 30 - - ds.renumber_stages() - - nums = [s["stage"] for s in ds.state["deployment_stages"]] - assert nums == [1, 2, 3] - - def test_remediation_attempts_in_load_from_build_state(self, tmp_project): - """Verify remediation_attempts field is added during build state import.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - for stage in ds.state["deployment_stages"]: - assert "remediation_attempts" in stage - assert stage["remediation_attempts"] == 0 - - def test_remediating_status_icon(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_remediating(1) - status = ds.format_stage_status() - assert "<>" in status - - -# ====================================================================== -# Deploy Remediation Loop tests -# ====================================================================== - - -class TestDeployRemediation: - """Tests for the deploy auto-remediation loop in DeploySession.""" - - _SENTINEL = object() - - def _make_session(self, project_dir, iac_tool="terraform", build_stages=None, ai_provider=_SENTINEL): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.stages.deploy_session import DeploySession - - config_path = Path(project_dir) / "prototype.yaml" - if not config_path.exists(): - config_data = { - "project": {"name": "test", "location": "eastus", "iac_tool": iac_tool}, - "ai": {"provider": "github-models"}, - } - with open(config_path, "w") as f: - yaml.dump(config_data, f) - - _write_build_yaml(project_dir, stages=build_stages, iac_tool=iac_tool) - - provider = MagicMock() if ai_provider is self._SENTINEL else ai_provider - context = AgentContext( - project_config={"project": {"iac_tool": iac_tool}}, - project_dir=str(project_dir), - ai_provider=provider, - ) - registry = AgentRegistry() - register_all_builtin(registry) - - session = DeploySession(context, registry) - # Pre-load build state into deploy state - build_path = Path(project_dir) / ".prototype" / "state" / "build.yaml" - session._deploy_state.load_from_build_state(build_path) - return session - - def test_remediation_succeeds_first_attempt(self, tmp_project): - """Deploy fails -> QA diagnoses -> fix agent fixes -> redeploy succeeds.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - (tmp_project / "concept" / "infra" / "terraform" / "main.tf").write_text("# original") - - session = self._make_session(tmp_project, build_stages=stages) - - # Mock QA agent - session._qa_agent = MagicMock() - session._qa_agent.execute.return_value = _make_response( - "Missing provider configuration. Add required_providers block." - ) - - # Mock architect agent - session._architect_agent = MagicMock() - session._architect_agent.execute.return_value = _make_response( - "Root cause: missing provider. Add azurerm provider config.\nNo downstream impact." - ) - - # Mock IaC agent (terraform) - mock_iac = MagicMock() - mock_iac.execute.return_value = _make_response( - "```main.tf\n# fixed provider config\nterraform { required_providers " - '{ azurerm = { source = "hashicorp/azurerm" } } }\n```' - ) - session._iac_agents["terraform"] = mock_iac - - result = {"status": "failed", "error": "Error: No provider configured"} - stage = session._deploy_state.get_stage(1) - output = [] - - with patch("azext_prototype.stages.deploy_session.deploy_terraform", return_value={"status": "deployed"}): - remediated = session._remediate_deploy_failure( - stage, - result, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - - assert remediated is not None - assert remediated["status"] == "deployed" - joined = "\n".join(output) - assert "Remediating" in joined - assert "deployed successfully after remediation" in joined - - def test_remediation_succeeds_second_attempt(self, tmp_project): - """First redeploy fails, second attempt succeeds.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - (tmp_project / "concept" / "infra" / "terraform" / "main.tf").write_text("# original") - - session = self._make_session(tmp_project, build_stages=stages) - - session._qa_agent = MagicMock() - session._qa_agent.execute.return_value = _make_response("Diagnosis: missing config") - - session._architect_agent = MagicMock() - session._architect_agent.execute.return_value = _make_response("Fix the provider.\n[]") - - mock_iac = MagicMock() - mock_iac.execute.return_value = _make_response("```main.tf\n# fixed\n```") - session._iac_agents["terraform"] = mock_iac - - result = {"status": "failed", "error": "Error: provider error"} - stage = session._deploy_state.get_stage(1) - output = [] - - deploy_call_count = [0] - - def mock_deploy(*args, **kwargs): - deploy_call_count[0] += 1 - if deploy_call_count[0] <= 1: - return {"status": "failed", "error": "still broken"} - return {"status": "deployed"} - - with patch.object(session, "_deploy_single_stage", side_effect=mock_deploy): - remediated = session._remediate_deploy_failure( - stage, - result, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - - assert remediated is not None - assert remediated["status"] == "deployed" - assert deploy_call_count[0] == 2 - - def test_remediation_exhausted(self, tmp_project): - """All remediation attempts fail — falls through.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - (tmp_project / "concept" / "infra" / "terraform" / "main.tf").write_text("# original") - - session = self._make_session(tmp_project, build_stages=stages) - - session._qa_agent = MagicMock() - session._qa_agent.execute.return_value = _make_response("Diagnosis: broken") - - session._architect_agent = MagicMock() - session._architect_agent.execute.return_value = _make_response("Fix it.\n[]") - - mock_iac = MagicMock() - mock_iac.execute.return_value = _make_response("```main.tf\n# attempt\n```") - session._iac_agents["terraform"] = mock_iac - - result = {"status": "failed", "error": "persistent error"} - stage = session._deploy_state.get_stage(1) - output = [] - - with patch.object(session, "_deploy_single_stage", return_value={"status": "failed", "error": "still broken"}): - remediated = session._remediate_deploy_failure( - stage, - result, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - - assert remediated is not None - assert remediated["status"] == "failed" - joined = "\n".join(output) - assert "Re-deploy failed" in joined - - def test_remediation_no_agents(self, tmp_project): - """Gracefully skipped when no fix agents are available.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - - # Clear all agents - session._qa_agent = None - session._iac_agents = {} - session._dev_agent = None - session._architect_agent = None - - result = {"status": "failed", "error": "auth error"} - stage = session._deploy_state.get_stage(1) - output = [] - - remediated = session._remediate_deploy_failure( - stage, - result, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - - assert remediated is None # No remediation attempted - - def test_remediation_qa_cannot_diagnose(self, tmp_project): - """Stops early when QA can't diagnose.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - - # QA returns no diagnosis - session._qa_agent = MagicMock() - session._qa_agent.execute.return_value = _make_response("") - - mock_iac = MagicMock() - session._iac_agents["terraform"] = mock_iac - - result = {"status": "failed", "error": "auth error"} - stage = session._deploy_state.get_stage(1) - output = [] - - session._remediate_deploy_failure( - stage, - result, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - - # Should not have called the IaC agent since QA couldn't diagnose - mock_iac.execute.assert_not_called() - - def test_remediation_updates_build_state(self, tmp_project): - """Build.yaml files list is updated after remediation writes.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": ["concept/infra/terraform/main.tf"], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - (tmp_project / "concept" / "infra" / "terraform" / "main.tf").write_text("# original") - - session = self._make_session(tmp_project, build_stages=stages) - - content = "```main.tf\n# fixed content\n```" - stage = session._deploy_state.get_stage(1) - written = session._write_stage_files(stage, content) - - assert len(written) == 1 - assert "main.tf" in written[0] - - # Verify build state was updated - from azext_prototype.stages.build_state import BuildState - - bs = BuildState(str(tmp_project)) - bs.load() - build_stage = bs.state["deployment_stages"][0] - assert build_stage["files"] == written - - @patch( - "azext_prototype.stages.deploy_session.subprocess.run", - return_value=MagicMock(returncode=0, stdout="Terraform v1.7.0\n", stderr=""), - ) - @patch("azext_prototype.stages.deploy_session.check_az_login", return_value=True) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub-123") - @patch("azext_prototype.stages.deploy_session.deploy_terraform") - def test_slash_deploy_routes_through_remediation(self, mock_tf, mock_sub, mock_login, mock_subprocess, tmp_project): - """/deploy N triggers remediation on failure.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, build_stages=stages) - - mock_tf.return_value = {"status": "failed", "error": "auth error"} - output = [] - - with patch.object( - session, "_handle_deploy_failure", return_value={"status": "failed", "error": "auth error"} - ) as mock_handle: - session._handle_slash_command( - "/deploy 1", - False, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - - # _handle_deploy_failure should have been called - mock_handle.assert_called_once() - - @patch("azext_prototype.stages.deploy_session.deploy_terraform") - def test_slash_redeploy_routes_through_remediation(self, mock_tf, tmp_project): - """/redeploy N triggers remediation on failure.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - (tmp_project / "concept" / "infra" / "terraform").mkdir(parents=True, exist_ok=True) - - session = self._make_session(tmp_project, build_stages=stages) - session._deploy_env = {"ARM_SUBSCRIPTION_ID": "sub-123"} - - mock_tf.return_value = {"status": "failed", "error": "deploy error"} - output = [] - - with patch.object( - session, "_handle_deploy_failure", return_value={"status": "failed", "error": "deploy error"} - ) as mock_handle: - session._handle_slash_command( - "/redeploy 1", - False, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - - mock_handle.assert_called_once() - - def test_downstream_impact_detected(self, tmp_project): - """Architect flags downstream stages for regeneration.""" - stages = [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform/stage-1", - "status": "generated", - "files": [], - }, - { - "stage": 2, - "name": "Data Layer", - "capability": "data", - "services": [], - "dir": "concept/infra/terraform/stage-2", - "status": "generated", - "files": [], - }, - { - "stage": 3, - "name": "App", - "capability": "app", - "services": [], - "dir": "concept/apps/stage-3", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - - # Mark stage 2 and 3 as pending (downstream) - session._deploy_state.get_stage(2)["deploy_status"] = "pending" - session._deploy_state.get_stage(3)["deploy_status"] = "pending" - - # Architect returns stage 2 as affected - session._architect_agent = MagicMock() - session._architect_agent.execute.return_value = _make_response("Affected stages: [2]") - - stage = session._deploy_state.get_stage(1) - result = session._check_downstream_impact(stage, "Changed outputs from foundation") - - assert 2 in result - assert 1 not in result # Not downstream of itself - - def test_downstream_regeneration(self, tmp_project): - """Flagged downstream stages get regenerated code.""" - stages = [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform/stage-1", - "status": "generated", - "files": [], - }, - { - "stage": 2, - "name": "Data Layer", - "capability": "data", - "services": [], - "dir": "concept/infra/terraform/stage-2", - "status": "generated", - "files": [], - }, - ] - for s in stages: - (tmp_project / s["dir"]).mkdir(parents=True, exist_ok=True) - (tmp_project / s["dir"] / "main.tf").write_text("# original") - - session = self._make_session(tmp_project, build_stages=stages) - - # Mock IaC agent to return regenerated content - mock_iac = MagicMock() - mock_iac.execute.return_value = _make_response("```main.tf\n# regenerated with fixed references\n```") - session._iac_agents["terraform"] = mock_iac - - output = [] - session._regenerate_downstream_stages( - [2], - False, - lambda msg: output.append(msg), - ) - - joined = "\n".join(output) - assert "regenerated" in joined.lower() - # Verify the file was actually written - content = (tmp_project / "concept" / "infra" / "terraform" / "stage-2" / "main.tf").read_text() - assert "regenerated" in content - - def test_handle_deploy_failure_returns_result(self, tmp_project): - """_handle_deploy_failure returns the remediation result.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages) - - # No agents available — remediation returns None - session._qa_agent = None - session._iac_agents = {} - session._dev_agent = None - - result = {"status": "failed", "error": "auth error"} - stage = session._deploy_state.get_stage(1) - output = [] - - returned = session._handle_deploy_failure( - stage, - result, - False, - lambda msg: output.append(msg), - lambda p: "", - ) - - # Should return original result when remediation not possible - assert returned["status"] == "failed" - # Should still show interactive options - joined = "\n".join(output) - assert "/deploy" in joined - - def test_no_ai_provider_skips_remediation(self, tmp_project): - """Remediation is skipped when ai_provider is None.""" - stages = [ - { - "stage": 1, - "name": "Infra", - "capability": "infra", - "services": [], - "dir": "concept/infra/terraform", - "status": "generated", - "files": [], - }, - ] - session = self._make_session(tmp_project, build_stages=stages, ai_provider=None) - - result = {"status": "failed", "error": "auth error"} - stage = session._deploy_state.get_stage(1) - - remediated = session._remediate_deploy_failure( - stage, - result, - False, - lambda msg: None, - lambda p: "", - ) - - assert remediated is None - - -# ====================================================================== -# Build-Deploy Decoupling: Stable IDs, Sync, Splitting, Manual Steps -# ====================================================================== - - -def _build_yaml_with_ids(stages=None, iac_tool="terraform"): - """Build YAML with stable IDs.""" - if stages is None: - stages = [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "id": "foundation", - "deploy_mode": "auto", - "manual_instructions": None, - "services": [ - { - "name": "key-vault", - "computed_name": "kv-1", - "resource_type": "Microsoft.KeyVault/vaults", - "sku": "standard", - } - ], - "status": "generated", - "dir": "concept/infra/terraform/stage-1-foundation", - "files": ["main.tf"], - }, - { - "stage": 2, - "name": "Data Layer", - "capability": "data", - "id": "data-layer", - "deploy_mode": "auto", - "manual_instructions": None, - "services": [ - {"name": "sql-db", "computed_name": "sql-1", "resource_type": "Microsoft.Sql/servers", "sku": "S0"} - ], - "status": "generated", - "dir": "concept/infra/terraform/stage-2-data", - "files": ["main.tf"], - }, - { - "stage": 3, - "name": "Application", - "capability": "app", - "id": "application", - "deploy_mode": "auto", - "manual_instructions": None, - "services": [ - {"name": "web-app", "computed_name": "app-1", "resource_type": "Microsoft.Web/sites", "sku": "B1"} - ], - "status": "generated", - "dir": "concept/apps/stage-3-application", - "files": ["app.py"], - }, - ] - return { - "iac_tool": iac_tool, - "deployment_stages": stages, - "_metadata": {"created": "2026-01-01T00:00:00", "last_updated": "2026-01-01T00:00:00", "iteration": 1}, - } - - -def _write_build_yaml_with_ids(project_dir, stages=None, iac_tool="terraform"): - """Write build.yaml with stable IDs.""" - state_dir = Path(project_dir) / ".prototype" / "state" - state_dir.mkdir(parents=True, exist_ok=True) - data = _build_yaml_with_ids(stages, iac_tool) - with open(state_dir / "build.yaml", "w", encoding="utf-8") as f: - yaml.dump(data, f, default_flow_style=False) - return state_dir / "build.yaml" - - -class TestSyncFromBuildState: - - def test_sync_from_build_state_fresh(self, tmp_project): - """First sync creates deploy stages from build stages.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - result = ds.sync_from_build_state(build_path) - - assert result.created == 3 - assert result.matched == 0 - assert result.orphaned == 0 - assert len(ds.state["deployment_stages"]) == 3 - assert ds.state["deployment_stages"][0]["build_stage_id"] == "foundation" - - def test_sync_from_build_state_preserves_deploy_status(self, tmp_project): - """Matched stages keep their deploy state.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # Deploy stage 1 - ds.mark_stage_deployed(1, output="done") - - # Re-sync - result = ds.sync_from_build_state(build_path) - assert result.matched == 3 - assert result.created == 0 - - stage1 = ds.state["deployment_stages"][0] - assert stage1["deploy_status"] == "deployed" - assert stage1["deploy_output"] == "done" - - def test_sync_from_build_state_detects_code_change(self, tmp_project): - """Changed files trigger _code_updated marking.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - ds.mark_stage_deployed(1) - - # Update build state with new files - updated_stages = _build_yaml_with_ids()["deployment_stages"] - updated_stages[0]["files"] = ["main.tf", "variables.tf"] # changed - _write_build_yaml_with_ids(tmp_project, stages=updated_stages) - - result = ds.sync_from_build_state(build_path) - assert result.updated_code == 1 - assert ds.state["deployment_stages"][0].get("_code_updated") is True - - def test_sync_from_build_state_creates_new(self, tmp_project): - """New build stage creates new deploy stage.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # Add new stage to build - stages = _build_yaml_with_ids()["deployment_stages"] - stages.append( - { - "stage": 4, - "name": "Monitoring", - "capability": "infra", - "id": "monitoring", - "deploy_mode": "auto", - "manual_instructions": None, - "services": [], - "status": "generated", - "dir": "concept/infra/terraform/stage-4-monitoring", - "files": [], - } - ) - _write_build_yaml_with_ids(tmp_project, stages=stages) - - result = ds.sync_from_build_state(build_path) - assert result.created == 1 - assert len(ds.state["deployment_stages"]) == 4 - assert ds.state["deployment_stages"][3]["build_stage_id"] == "monitoring" - - def test_sync_from_build_state_with_substages(self, tmp_project): - """Split stages preserved across sync.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # Split stage 2 into substages - ds.split_stage( - 2, - [ - {"name": "Data Layer - Base", "dir": "concept/infra/terraform/stage-2-data"}, - {"name": "Data Layer - Schema", "dir": "concept/db/schema"}, - ], - ) - - # Re-sync — substages should be preserved - ds.sync_from_build_state(build_path) - data_stages = ds.get_stages_for_build_stage("data-layer") - assert len(data_stages) == 2 - assert data_stages[0]["substage_label"] == "a" - assert data_stages[1]["substage_label"] == "b" - - def test_sync_orphan_sets_removed_status(self, tmp_project): - """Removed build stage → deploy stage gets 'removed' status.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # Remove a stage from build - stages = _build_yaml_with_ids()["deployment_stages"] - stages = [s for s in stages if s["id"] != "data-layer"] - _write_build_yaml_with_ids(tmp_project, stages=stages) - - result = ds.sync_from_build_state(build_path) - assert result.orphaned == 1 - - removed = [s for s in ds.state["deployment_stages"] if s.get("deploy_status") == "removed"] - assert len(removed) == 1 - assert removed[0]["build_stage_id"] == "data-layer" - - -class TestStageSpitting: - - def test_split_stage(self, tmp_project): - """Split creates substages with shared build_stage_id.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.split_stage( - 2, - [ - {"name": "Data - Base", "dir": "concept/infra/terraform/stage-2-data"}, - {"name": "Data - Schema", "dir": "concept/db/schema"}, - ], - ) - - # All substages share the same build_stage_id - data_stages = ds.get_stages_for_build_stage("data-layer") - assert len(data_stages) == 2 - assert data_stages[0]["substage_label"] == "a" - assert data_stages[1]["substage_label"] == "b" - assert data_stages[0]["_is_substage"] is True - assert data_stages[1]["_is_substage"] is True - - def test_split_stage_renumbering(self, tmp_project): - """After split, stage numbers are correct.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.split_stage( - 2, - [ - {"name": "Data - Base", "dir": "dir1"}, - {"name": "Data - Schema", "dir": "dir2"}, - ], - ) - - stages = ds.state["deployment_stages"] - # Stage 1 stays as 1, substages get stage 2 with labels, stage 3 stays - assert stages[0]["stage"] == 1 # Foundation - assert stages[1]["stage"] == 2 # Data - Base (2a) - assert stages[1]["substage_label"] == "a" - assert stages[2]["stage"] == 2 # Data - Schema (2b) - assert stages[2]["substage_label"] == "b" - assert stages[3]["stage"] == 3 # Application - - def test_get_stage_groups(self, tmp_project): - """Verify grouping by build_stage_id.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.split_stage( - 2, - [ - {"name": "Data - Base", "dir": "dir1"}, - {"name": "Data - Schema", "dir": "dir2"}, - ], - ) - - groups = ds.get_stage_groups() - assert "foundation" in groups - assert "data-layer" in groups - assert "application" in groups - assert len(groups["data-layer"]) == 2 - assert len(groups["foundation"]) == 1 - - def test_can_rollback_with_substages(self, tmp_project): - """Rollback checks work with substages.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.split_stage( - 2, - [ - {"name": "Data - Base", "dir": "dir1"}, - {"name": "Data - Schema", "dir": "dir2"}, - ], - ) - - # Deploy both substages - substages = ds.get_stages_for_build_stage("data-layer") - substages[0]["deploy_status"] = "deployed" - substages[1]["deploy_status"] = "deployed" - ds.save() - - # Can't rollback "a" while "b" is deployed - assert ds.can_rollback(2, "a") is False - # Can rollback "b" - assert ds.can_rollback(2, "b") is True - - def test_get_stage_by_display_id(self, tmp_project): - """Parse and lookup by compound display ID.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.split_stage( - 2, - [ - {"name": "Data - Base", "dir": "dir1"}, - {"name": "Data - Schema", "dir": "dir2"}, - ], - ) - - found = ds.get_stage_by_display_id("2a") - assert found is not None - assert found["name"] == "Data - Base" - - found_b = ds.get_stage_by_display_id("2b") - assert found_b is not None - assert found_b["name"] == "Data - Schema" - - -class TestDeployStateNewStatuses: - - def test_load_from_build_state_backward_compat(self, tmp_project): - """Legacy build state without IDs still imports correctly.""" - from azext_prototype.stages.deploy_state import DeployState - - # Write legacy build yaml (no id field) - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - result = ds.load_from_build_state(build_path) - - assert result is True - # build_stage_id should be auto-generated from name - for stage in ds.state["deployment_stages"]: - assert stage.get("build_stage_id") - - def test_destroy_stage(self, tmp_project): - """Destroyed status after rollback.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deployed(1) - ds.mark_stage_rolled_back(1) - ds.mark_stage_destroyed(1) - - assert ds.get_stage(1)["deploy_status"] == "destroyed" - - def test_destruction_declined_not_reprompted(self, tmp_project): - """_destruction_declined flag persists across save/load.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - stage = ds.get_stage(1) - stage["_destruction_declined"] = True - ds.save() - - ds2 = DeployState(str(tmp_project)) - ds2.load() - assert ds2.get_stage(1)["_destruction_declined"] is True - - def test_awaiting_manual_status(self, tmp_project): - """Manual step sets awaiting_manual status.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_awaiting_manual(1) - assert ds.get_stage(1)["deploy_status"] == "awaiting_manual" - - -class TestManualStepDeploy: - - def test_manual_step_deploy(self, tmp_project): - """Manual stage shows instructions, waits for confirmation.""" - from azext_prototype.stages.deploy_state import DeployState - - stages = [ - { - "stage": 1, - "name": "Upload Notebook", - "capability": "external", - "id": "upload-notebook", - "deploy_mode": "manual", - "manual_instructions": "Upload the notebook to Fabric workspace.", - "services": [], - "status": "generated", - "dir": "concept/docs", - "files": [], - }, - ] - build_path = _write_build_yaml_with_ids(tmp_project, stages=stages) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # Verify the manual stage imported correctly - stage = ds.get_stage(1) - assert stage["deploy_mode"] == "manual" - assert "Upload" in stage["manual_instructions"] - - def test_manual_step_from_build(self, tmp_project): - """deploy_mode: 'manual' inherited from build stage via sync.""" - from azext_prototype.stages.deploy_state import DeployState - - stages = [ - { - "stage": 1, - "name": "Foundation", - "capability": "infra", - "id": "foundation", - "deploy_mode": "auto", - "manual_instructions": None, - "services": [], - "status": "generated", - "dir": "concept/infra/terraform/stage-1-foundation", - "files": [], - }, - { - "stage": 2, - "name": "Manual Config", - "capability": "external", - "id": "manual-config", - "deploy_mode": "manual", - "manual_instructions": "Configure the firewall rules manually.", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - ] - build_path = _write_build_yaml_with_ids(tmp_project, stages=stages) - - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - manual_stage = ds.state["deployment_stages"][1] - assert manual_stage["deploy_mode"] == "manual" - assert "firewall" in manual_stage["manual_instructions"] - - def test_code_split_syncs_back_to_build(self, tmp_project): - """Type A split: _sync_build_state uses build_stage_id for matching.""" - from azext_prototype.stages.build_state import BuildState - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - - # Load into deploy state - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # Load build state and verify get_stage_by_id works - bs = BuildState(str(tmp_project)) - bs.load() - - # Verify the build stage has the right id - build_stage = bs.get_stage_by_id("data-layer") - assert build_stage is not None - assert build_stage["name"] == "Data Layer" - - # Deploy stage links back correctly - deploy_stage = ds.state["deployment_stages"][1] - assert deploy_stage["build_stage_id"] == "data-layer" - - -class TestParseStageRef: - - def test_parse_simple_number(self): - from azext_prototype.stages.deploy_state import parse_stage_ref - - num, label = parse_stage_ref("5") - assert num == 5 - assert label is None - - def test_parse_substage(self): - from azext_prototype.stages.deploy_state import parse_stage_ref - - num, label = parse_stage_ref("5a") - assert num == 5 - assert label == "a" - - def test_parse_invalid(self): - from azext_prototype.stages.deploy_state import parse_stage_ref - - num, label = parse_stage_ref("abc") - assert num is None - assert label is None - - def test_parse_empty(self): - from azext_prototype.stages.deploy_state import parse_stage_ref - - num, label = parse_stage_ref("") - assert num is None - - def test_parse_with_whitespace(self): - from azext_prototype.stages.deploy_state import parse_stage_ref - - num, label = parse_stage_ref(" 3b ") - assert num == 3 - assert label == "b" - - -class TestRenumberWithSubstages: - - def test_renumber_preserves_substage_labels(self, tmp_project): - """Substages keep their labels and inherit parent number.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # Split stage 2 - ds.split_stage( - 2, - [ - {"name": "Data - Base", "dir": "dir1"}, - {"name": "Data - Schema", "dir": "dir2"}, - ], - ) - - # Remove stage 1 — renumber should shift substages - stages = ds.state["deployment_stages"] - ds._state["deployment_stages"] = [s for s in stages if s.get("build_stage_id") != "foundation"] - ds.renumber_stages() - - stages = ds.state["deployment_stages"] - # Now data substages should be stage 1 - assert stages[0]["stage"] == 1 - assert stages[0]["substage_label"] == "a" - assert stages[1]["stage"] == 1 - assert stages[1]["substage_label"] == "b" - # Application should be stage 2 - assert stages[2]["stage"] == 2 - assert stages[2]["substage_label"] is None - - -class TestFormatDisplayId: - - def test_format_top_level(self): - from azext_prototype.stages.deploy_state import _format_display_id - - assert _format_display_id({"stage": 3}) == "3" - - def test_format_substage(self): - from azext_prototype.stages.deploy_state import _format_display_id - - assert _format_display_id({"stage": 3, "substage_label": "b"}) == "3b" - - def test_format_no_label(self): - from azext_prototype.stages.deploy_state import _format_display_id - - assert _format_display_id({"stage": 1, "substage_label": None}) == "1" - - -class TestNewStatusIcons: - - def test_removed_icon(self): - from azext_prototype.stages.deploy_state import _status_icon - - assert _status_icon("removed") == "~~" - - def test_destroyed_icon(self): - from azext_prototype.stages.deploy_state import _status_icon - - assert _status_icon("destroyed") == "xx" - - def test_awaiting_manual_icon(self): - from azext_prototype.stages.deploy_state import _status_icon - - assert _status_icon("awaiting_manual") == "!!" - - def test_existing_icons_unchanged(self): - from azext_prototype.stages.deploy_state import _status_icon - - assert _status_icon("pending") == " " - assert _status_icon("deployed") == " v" - assert _status_icon("failed") == " x" - assert _status_icon("remediating") == "<>" - - -class TestDeployReportFormatting: - - def test_format_shows_removed_stages(self, tmp_project): - """Removed stages show with strikethrough in report.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - ds.mark_stage_removed(2) - - report = ds.format_deploy_report() - assert "(Removed)" in report - assert "~~Data Layer~~" in report - - def test_format_shows_manual_badge(self, tmp_project): - """Manual stages show [Manual] badge.""" - from azext_prototype.stages.deploy_state import DeployState - - stages = [ - { - "stage": 1, - "name": "Manual Step", - "capability": "external", - "id": "manual", - "deploy_mode": "manual", - "manual_instructions": "Do the thing.", - "services": [], - "status": "generated", - "dir": "", - "files": [], - }, - ] - build_path = _write_build_yaml_with_ids(tmp_project, stages=stages) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - report = ds.format_deploy_report() - assert "[Manual]" in report - - status = ds.format_stage_status() - assert "[Manual]" in status - - def test_format_shows_substage_ids(self, tmp_project): - """Substages show compound display IDs like 2a, 2b.""" - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml_with_ids(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.split_stage( - 2, - [ - {"name": "Data - Base", "dir": "dir1"}, - {"name": "Data - Schema", "dir": "dir2"}, - ], - ) - - status = ds.format_stage_status() - assert "2a" in status - assert "2b" in status diff --git a/tests/test_discovery.py b/tests/test_discovery.py deleted file mode 100644 index 10367ee..0000000 --- a/tests/test_discovery.py +++ /dev/null @@ -1,3367 +0,0 @@ -"""Tests for azext_prototype.stages.discovery — organic multi-turn conversation.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from azext_prototype.agents.base import AgentCapability, AgentContext -from azext_prototype.ai.provider import AIMessage, AIResponse -from azext_prototype.stages.discovery import ( - _DONE_WORDS, - _QUIT_WORDS, - _READY_MARKER, - DiscoveryResult, - DiscoverySession, - extract_section_headers, - parse_sections, -) - -# ====================================================================== -# Fixtures -# ====================================================================== - - -@pytest.fixture -def mock_biz_agent(): - agent = MagicMock() - agent.name = "biz-analyst" - agent.capabilities = [AgentCapability.BIZ_ANALYSIS, AgentCapability.ANALYZE] - agent._temperature = 0.5 - agent._max_tokens = 8192 - agent.get_system_messages.side_effect = lambda: [ - AIMessage(role="system", content="You are a biz-analyst."), - ] - return agent - - -@pytest.fixture -def mock_architect_agent(): - agent = MagicMock() - agent.name = "cloud-architect" - agent.capabilities = [AgentCapability.ARCHITECT, AgentCapability.COORDINATE] - agent.constraints = [ - "All Azure services MUST use Managed Identity", - "Follow Microsoft Well-Architected Framework principles", - "This is a PROTOTYPE — optimize for speed and demonstration", - "Prefer PaaS over IaaS for simplicity", - ] - return agent - - -@pytest.fixture -def mock_registry(mock_biz_agent, mock_architect_agent): - registry = MagicMock() - - def find_by_cap(cap): - if cap == AgentCapability.BIZ_ANALYSIS: - return [mock_biz_agent] - if cap == AgentCapability.ARCHITECT: - return [mock_architect_agent] - return [] - - registry.find_by_capability.side_effect = find_by_cap - return registry - - -@pytest.fixture -def mock_agent_context(tmp_path): - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_path), - ai_provider=MagicMock(), - ) - return ctx - - -def _make_response(content: str) -> AIResponse: - """Shorthand for creating an AIResponse.""" - return AIResponse(content=content, model="gpt-4o", usage={}) - - -# ====================================================================== -# DiscoveryResult -# ====================================================================== - - -class TestDiscoveryResult: - def test_basic_creation(self): - result = DiscoveryResult( - requirements="Build a web app", - conversation=[], - policy_overrides=[], - exchange_count=3, - ) - assert result.requirements == "Build a web app" - assert result.exchange_count == 3 - assert result.cancelled is False - - def test_cancelled(self): - result = DiscoveryResult( - requirements="", - conversation=[], - policy_overrides=[], - exchange_count=0, - cancelled=True, - ) - assert result.cancelled is True - - -# ====================================================================== -# DiscoverySession — basic conversation flow -# ====================================================================== - - -class TestBasicConversationFlow: - """The core contract: user and agent exchange messages naturally.""" - - def test_bare_invocation_agent_speaks_first( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """With no context, the agent gets a generic opening and starts talking.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Tell me about what you'd like to build."), - _make_response("Interesting — a REST API for orders. What database?"), - _make_response("## Summary\nOrders API, PostgreSQL."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["A REST API for order management", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # exchange_count includes the opening exchange (1) + user reply (2) - assert result.exchange_count == 2 - assert not result.cancelled - # The AI was called: opening + user reply + summary - assert mock_agent_context.ai_provider.chat.call_count == 3 - - def test_with_context_agent_analyzes_and_follows_up( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """When --context is provided, it becomes the opening message.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("I see an inventory system. What about auth?"), - _make_response("Entra ID, got it. What about scale?"), - _make_response("50 users, read-heavy. Makes sense."), - _make_response("## Summary\nInventory system confirmed."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["Entra ID for auth", "About 50 users", "done"]) - - result = session.run( - seed_context="Build an inventory management system", - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # exchange_count: opening (1) + 2 user replies (2, 3) - assert result.exchange_count == 3 - assert not result.cancelled - # Check that the opening message was the seed context - first_call_messages = mock_agent_context.ai_provider.chat.call_args_list[0][0][0] - user_msgs = [m for m in first_call_messages if m.role == "user"] - assert "inventory management" in user_msgs[0].content.lower() - - def test_with_artifacts_and_context( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """Both artifacts AND context form a combined opening message.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("I see both context and specs. Scale?"), - _make_response("50 users, noted. Anything else?"), - _make_response("## Summary\nAll confirmed."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["50 concurrent users", "done"]) - - result = session.run( - seed_context="Inventory system", - artifacts="## Spec\nCRUD for products", - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # exchange_count: opening (1) + user reply (2) - assert result.exchange_count == 2 - first_call_messages = mock_agent_context.ai_provider.chat.call_args_list[0][0][0] - user_msgs = [m for m in first_call_messages if m.role == "user"] - assert "inventory" in user_msgs[0].content.lower() - assert "CRUD" in user_msgs[0].content or "requirement documents" in user_msgs[0].content.lower() - - def test_with_only_artifacts( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """Artifacts alone — opening says 'I have documents for you'.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Let me review... looks like a product catalog."), - _make_response("## Summary\nProduct catalog."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - session.run( - artifacts="## Product Catalog Spec\nCRUD endpoints", - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - - first_user_msg = [m for m in mock_agent_context.ai_provider.chat.call_args_list[0][0][0] if m.role == "user"][0] - assert "requirement documents" in first_user_msg.content.lower() - - -# ====================================================================== -# Multi-turn message history -# ====================================================================== - - -class TestMultiTurnHistory: - """The key architectural requirement: full conversation history on every call.""" - - def test_history_grows_with_each_exchange( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """Each AI call includes the full conversation history.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("A REST API. What database?"), - _make_response("PostgreSQL. Auth?"), - _make_response("## Summary"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["A REST API", "PostgreSQL", "done"]) - - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - calls = mock_agent_context.ai_provider.chat.call_args_list - - # Call 0 (opening): system + 1 user message - # Call 1 (exchange 1): system + 2 user + 1 assistant - # Call 2 (exchange 2): system + 3 user + 2 assistant - # Call 3 (summary): system + 4 user + 3 assistant - - user_count_per_call = [] - for c in calls: - messages = c[0][0] - user_count_per_call.append(sum(1 for m in messages if m.role == "user")) - - # History should grow monotonically - assert user_count_per_call == sorted(user_count_per_call) - assert user_count_per_call[-1] > user_count_per_call[0] - - def test_no_meta_prompt_injection( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """User text goes to the AI unmodified — no wrapping or injection.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("Got it."), - _make_response("## Summary"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["Build me a web app with React and Node.js", "done"]) - - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # The second call should contain the user's exact text - second_call_messages = mock_agent_context.ai_provider.chat.call_args_list[1][0][0] - user_msgs = [m.content for m in second_call_messages if m.role == "user"] - # The user's message should appear verbatim - assert "Build me a web app with React and Node.js" in user_msgs - - -# ====================================================================== -# Session ending -# ====================================================================== - - -class TestSessionEnding: - def test_quit_cancels(self, mock_agent_context, mock_registry, mock_biz_agent): - mock_agent_context.ai_provider.chat.return_value = _make_response("Hi!") - session = DiscoverySession(mock_agent_context, mock_registry) - - result = session.run( - input_fn=lambda _: "q", - print_fn=lambda x: None, - ) - assert result.cancelled is True - assert result.requirements == "" - - def test_all_quit_words(self, mock_agent_context, mock_registry, mock_biz_agent): - for word in _QUIT_WORDS: - mock_agent_context.ai_provider.chat.return_value = _make_response("Hi!") - session = DiscoverySession(mock_agent_context, mock_registry) - result = session.run( - input_fn=lambda _: word, - print_fn=lambda x: None, - ) - assert result.cancelled, f"'{word}' should cancel" - - def test_all_done_words(self, mock_agent_context, mock_registry, mock_biz_agent): - for word in _DONE_WORDS: - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Hi!"), - _make_response("## Summary"), - ] - session = DiscoverySession(mock_agent_context, mock_registry) - result = session.run( - input_fn=lambda _: word, - print_fn=lambda x: None, - ) - assert not result.cancelled, f"'{word}' should end gracefully, not cancel" - - def test_end_in_done_words(self): - """'end' should be recognized as a done word.""" - assert "end" in _DONE_WORDS - - def test_end_word_finishes_session(self, mock_agent_context, mock_registry, mock_biz_agent): - """Typing 'end' should complete the session (not cancel).""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Hi! Tell me about your project."), - _make_response("## Summary\nHere's what we discussed."), - ] - session = DiscoverySession(mock_agent_context, mock_registry) - result = session.run( - input_fn=lambda _: "end", - print_fn=lambda x: None, - ) - assert not result.cancelled - assert result.exchange_count >= 1 - - def test_eof_exits_gracefully(self, mock_agent_context, mock_registry, mock_biz_agent): - mock_agent_context.ai_provider.chat.return_value = _make_response("Hi!") - session = DiscoverySession(mock_agent_context, mock_registry) - - result = session.run( - input_fn=lambda _: (_ for _ in ()).throw(EOFError), - print_fn=lambda x: None, - ) - assert result is not None - - def test_keyboard_interrupt_exits(self, mock_agent_context, mock_registry, mock_biz_agent): - mock_agent_context.ai_provider.chat.return_value = _make_response("Hi!") - session = DiscoverySession(mock_agent_context, mock_registry) - - result = session.run( - input_fn=lambda _: (_ for _ in ()).throw(KeyboardInterrupt), - print_fn=lambda x: None, - ) - assert result is not None - - def test_empty_input_ignored(self, mock_agent_context, mock_registry, mock_biz_agent): - """Blank lines don't count as exchanges.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What do you want to build?"), - _make_response("A web app. Got it."), - _make_response("## Summary"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["", "", "Build a web app", "", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - # exchange_count: opening (1) + one real user reply (2) - assert result.exchange_count == 2 - - -# ====================================================================== -# Agent-driven convergence via [READY] marker -# ====================================================================== - - -class TestConvergence: - def test_ready_marker_triggers_confirmation( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """When agent includes [READY], user is prompted to confirm.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response(f"I have a good picture now. Here's what I've got. {_READY_MARKER}"), - _make_response("## Summary\nAll confirmed."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter( - [ - "A simple REST API for orders", - "", # Enter to accept after [READY] - ] - ) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # exchange_count: opening (1) + user reply (2) - assert result.exchange_count == 2 - assert not result.cancelled - - def test_ready_marker_stripped_from_display( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """The [READY] marker is never shown to the user.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response(f"I think we're done. {_READY_MARKER}"), - _make_response("## Summary"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - printed = [] - inputs = iter(["A web app", ""]) # exchange, then Enter to accept - - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: printed.append(x), - ) - - all_output = "\n".join(printed) - assert _READY_MARKER not in all_output - - def test_user_can_continue_after_ready( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """User can keep typing after agent signals [READY].""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response(f"Looks complete. {_READY_MARKER}"), - _make_response("Redis added. Anything else?"), - _make_response("## Summary"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter( - [ - "A web app", - "Actually, also add Redis caching", # continues after READY - "done", - ] - ) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # exchange_count: opening (1) + user reply (2) + continue after READY (3) - assert result.exchange_count == 3 - - -# ====================================================================== -# No biz-analyst fallback -# ====================================================================== - - -class TestNoBizAnalystFallback: - def test_falls_back_to_input(self, mock_agent_context): - registry = MagicMock() - registry.find_by_capability.return_value = [] - - session = DiscoverySession(mock_agent_context, registry) - result = session.run( - input_fn=lambda _: "Build a web API", - print_fn=lambda x: None, - ) - - assert result.requirements == "Build a web API" - assert result.exchange_count == 0 - - -# ====================================================================== -# Summary production -# ====================================================================== - - -class TestSummaryProduction: - def test_summary_requested_at_end( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """After conversation, a summary call is made.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("An orders API. Makes sense."), - _make_response("## Confirmed Requirements\n- Orders REST API\n- PostgreSQL"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["An orders REST API with PostgreSQL", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - assert "orders" in result.requirements.lower() or "Orders" in result.requirements - - def test_no_summary_when_zero_exchanges( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """If user immediately types 'done', a summary is still produced - because the opening exchange counts.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What would you like to build?"), - _make_response("## Summary\nA web app"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - result = session.run( - seed_context="A web app", - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - - assert "web app" in result.requirements.lower() - # 2 chat calls: opening + summary - assert mock_agent_context.ai_provider.chat.call_count == 2 - - -# ====================================================================== -# Policy override extraction from summary -# ====================================================================== - - -class TestPolicyOverrideExtraction: - def test_extracts_overrides_from_summary( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """If the summary contains a 'Policy Overrides' section, parse it.""" - summary_text = ( - "## Confirmed Requirements\n" - "- Orders API\n\n" - "## Policy Overrides\n" - "- managed-identity: User requires connection strings for legacy compat\n" - "- network-isolation: Public endpoint needed for demo\n\n" - "## Open Items\n" - "- Timeline TBD" - ) - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("Got it."), - _make_response(summary_text), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["An orders API with connection strings", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - assert len(result.policy_overrides) == 2 - names = [o["policy_name"] for o in result.policy_overrides] - assert "managed-identity" in names - assert "network-isolation" in names - - def test_no_overrides_when_section_absent( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """No Policy Overrides heading → empty list.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("Got it."), - _make_response("## Summary\n- Just an API\n## Open Items\n- None"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["A web API", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - assert result.policy_overrides == [] - - -# ====================================================================== -# Integration with DesignStage -# ====================================================================== - - -class TestDesignStageDiscoveryIntegration: - """Test that DesignStage.execute() uses the DiscoverySession.""" - - def test_design_stage_uses_discovery( - self, - project_with_config, - mock_agent_context, - populated_registry, - ): - from azext_prototype.stages.design_stage import DesignStage - - stage = DesignStage() - stage.get_guards = lambda: [] - - mock_agent_context.project_dir = str(project_with_config) - mock_agent_context.ai_provider.chat.return_value = _make_response("Tell me more about your project.") - - inputs = iter(["Build a REST API", "PostgreSQL, 50 users", "done"]) - result = stage.execute( - mock_agent_context, - populated_registry, - context="Build a simple web app", - interactive=False, - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - assert result["status"] == "success" - - def test_cancelled_discovery_cancels_design( - self, - project_with_config, - mock_agent_context, - populated_registry, - ): - from azext_prototype.stages.design_stage import DesignStage - - stage = DesignStage() - stage.get_guards = lambda: [] - - mock_agent_context.project_dir = str(project_with_config) - mock_agent_context.ai_provider.chat.return_value = _make_response("Tell me about your project.") - - result = stage.execute( - mock_agent_context, - populated_registry, - interactive=False, - input_fn=lambda _: "quit", - print_fn=lambda x: None, - ) - assert result["status"] == "cancelled" - - def test_design_stage_persists_policy_overrides( - self, - project_with_config, - mock_agent_context, - populated_registry, - ): - """Policy overrides from discovery are persisted in design state.""" - import json as _json - - from azext_prototype.stages.design_stage import DesignStage - - stage = DesignStage() - stage.get_guards = lambda: [] - - mock_agent_context.project_dir = str(project_with_config) - mock_agent_context.ai_provider.chat.return_value = _make_response("Architecture design with overrides.") - - mock_result = DiscoveryResult( - requirements="Build an API with connection strings (overridden)", - conversation=[], - policy_overrides=[ - { - "rule_id": "managed-identity", - "policy_name": "managed-identity", - "description": "Legacy compat", - "recommendation": "", - "user_text": "Legacy compat", - } - ], - exchange_count=3, - ) - - with patch("azext_prototype.stages.design_stage.DiscoverySession") as MockDS: - MockDS.return_value.run.return_value = mock_result - - result = stage.execute( - mock_agent_context, - populated_registry, - context="Build a web app", - interactive=False, - ) - - assert result["status"] == "success" - state_path = project_with_config / ".prototype" / "state" / "design.json" - state = _json.loads(state_path.read_text(encoding="utf-8")) - assert len(state.get("policy_overrides", [])) == 1 - assert state["policy_overrides"][0]["rule_id"] == "managed-identity" - - -# ====================================================================== -# _clean helper -# ====================================================================== - - -class TestCleanHelper: - def test_strips_ready_marker(self): - assert DiscoverySession._clean(f"Hello {_READY_MARKER}") == "Hello" - - def test_no_marker_passthrough(self): - assert DiscoverySession._clean("Hello world") == "Hello world" - - -# ====================================================================== -# _extract_overrides helper -# ====================================================================== - - -class TestExtractOverrides: - def test_parses_bullet_list(self): - text = ( - "## Policy Overrides\n" - "- managed-identity: Legacy system needs connection strings\n" - "- network-isolation: Demo requires public access\n" - "\n## Next Steps\n" - ) - overrides = DiscoverySession._extract_overrides(text) - assert len(overrides) == 2 - assert overrides[0]["policy_name"] == "managed-identity" - assert "Legacy" in overrides[0]["description"] - - def test_empty_when_no_section(self): - assert DiscoverySession._extract_overrides("## Summary\nJust a summary.") == [] - - def test_handles_bold_names(self): - text = "## Policy Overrides\n" "- **MI-001**: User needs connection strings\n" - overrides = DiscoverySession._extract_overrides(text) - assert len(overrides) == 1 - assert overrides[0]["policy_name"] == "MI-001" - - -# ====================================================================== -# /summary slash command -# ====================================================================== - - -class TestSummaryCommand: - def test_summary_triggers_ai_call( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """/summary should call the AI for a mid-session summary.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What would you like to build?"), - _make_response("Here's a summary of what we have so far."), - _make_response("## Summary\nFinal summary."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["/summary", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # 3 AI calls: opening, /summary, final summary - assert mock_agent_context.ai_provider.chat.call_count == 3 - # /summary doesn't count as a user exchange — only the opening does - assert result.exchange_count == 1 - - def test_summary_does_not_increment_exchange_count( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """/summary is a meta-command — exchange count stays the same.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Tell me about your project."), - _make_response("Got it — an API."), - _make_response("Mid-session summary: API project."), - _make_response("## Summary\nAPI confirmed."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["I want an API", "/summary", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # Opening (1) + one real user exchange (2), /summary doesn't count - assert result.exchange_count == 2 - - -# ====================================================================== -# /restart slash command -# ====================================================================== - - -class TestRestartCommand: - def test_restart_clears_state_and_resets( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """/restart should reset state and re-send the opening.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What would you like to build?"), - _make_response("Got it — a web app."), - _make_response("Fresh start! What would you like to build?"), - _make_response("## Summary\nFresh summary."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["A web app", "/restart", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # After /restart, exchange_count resets to 1 (the new opening) - assert result.exchange_count == 1 - # Messages were cleared and rebuilt - assert len(session._messages) > 0 - - def test_restart_clears_conversation_history( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """/restart should clear the in-memory message list.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Tell me more."), - _make_response("OK — a database."), - _make_response("Starting fresh!"), - _make_response("## Summary\nEmpty."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["Need a database", "/restart", "done"]) - - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # After restart + done, messages should only contain the - # post-restart opening exchange + the summary exchange - # (pre-restart messages were cleared) - user_msgs = [m for m in session._messages if m.role == "user"] - assert not any("database" in m.content.lower() for m in user_msgs) - - -# ====================================================================== -# /why slash command -# ====================================================================== - - -class TestWhyCommand: - def test_why_no_argument_shows_usage( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """/why with no argument should show usage hint, not crash.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What would you like to build?"), - _make_response("## Summary\nNothing yet."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["/why", "done"]) - output = [] - - session.run( - input_fn=lambda _: next(inputs), - print_fn=output.append, - ) - - combined = "\n".join(str(x) for x in output) - assert "Usage" in combined or "/why" in combined - - def test_why_with_matching_query( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """/why should find exchanges mentioning the queried topic.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What would you like to build?"), - _make_response("Managed identity is the recommended auth approach."), - _make_response("## Summary\nAll confirmed."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["Use managed identity for auth", "/why managed identity", "done"]) - output = [] - - session.run( - input_fn=lambda _: next(inputs), - print_fn=output.append, - ) - - combined = "\n".join(str(x) for x in output) - assert "Exchange" in combined - - def test_why_no_matches( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - ): - """/why with no matching history should show 'no exchanges found'.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What would you like to build?"), - _make_response("## Summary\nNothing yet."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["/why kubernetes", "done"]) - output = [] - - session.run( - input_fn=lambda _: next(inputs), - print_fn=output.append, - ) - - combined = "\n".join(str(x) for x in output) - assert "No exchanges found" in combined - - -# ====================================================================== -# Multi-modal (images) support -# ====================================================================== - - -class TestMultiModalOpening: - """Test that images produce multi-modal content arrays.""" - - def test_build_opening_without_images(self, mock_agent_context, mock_registry): - session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) - result = session._build_opening("context", "artifacts") - assert isinstance(result, str) - assert "context" in result - - def test_build_opening_with_images(self, mock_agent_context, mock_registry): - session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) - images = [ - {"filename": "arch.png", "data": "abc123", "mime": "image/png"}, - {"filename": "flow.jpg", "data": "def456", "mime": "image/jpeg"}, - ] - result = session._build_opening("context", "artifacts", images=images) - assert isinstance(result, list) - # First element is text - assert result[0]["type"] == "text" - assert "context" in result[0]["text"] - # Images follow - assert result[1]["type"] == "image_url" - assert "image/png" in result[1]["image_url"]["url"] - assert result[2]["type"] == "image_url" - assert "image/jpeg" in result[2]["image_url"]["url"] - - def test_build_opening_empty_images_returns_string(self, mock_agent_context, mock_registry): - session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) - result = session._build_opening("context", "", images=[]) - assert isinstance(result, str) - - def test_chat_with_multimodal_content(self, mock_agent_context, mock_registry): - """Multi-modal content array flows through _chat successfully.""" - mock_agent_context.ai_provider.chat.return_value = _make_response("I see the diagram.") - session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) - - content = [ - {"type": "text", "text": "Review this architecture"}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, - ] - response = session._chat(content) - assert response == "I see the diagram." - # Verify AIMessage was constructed with list content - call_args = mock_agent_context.ai_provider.chat.call_args - messages = call_args[0][0] - user_msg = [m for m in messages if m.role == "user"][-1] - assert isinstance(user_msg.content, list) - - def test_chat_vision_fallback(self, mock_agent_context, mock_registry): - """When multi-modal chat fails, _chat retries as text-only.""" - # First call raises, second succeeds - mock_agent_context.ai_provider.chat.side_effect = [ - Exception("Vision not supported"), - _make_response("Got it (text only)."), - ] - session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) - - content = [ - {"type": "text", "text": "Review this"}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, - ] - response = session._chat(content) - assert response == "Got it (text only)." - # Provider was called twice - assert mock_agent_context.ai_provider.chat.call_count == 2 - # Second call has string content (fallback) - second_call = mock_agent_context.ai_provider.chat.call_args_list[1] - messages = second_call[0][0] - user_msg = [m for m in messages if m.role == "user"][-1] - assert isinstance(user_msg.content, str) - assert "[Images could not be processed" in user_msg.content - - def test_run_passes_images_to_opening(self, mock_agent_context, mock_registry): - """The run() method passes artifact_images to _build_opening.""" - mock_agent_context.ai_provider.chat.return_value = _make_response(f"Got your images! {_READY_MARKER}") - session = DiscoverySession(mock_agent_context, mock_registry, console=MagicMock()) - images = [{"filename": "x.png", "data": "abc", "mime": "image/png"}] - - result = session.run( # noqa: F841 - seed_context="test", - artifact_images=images, - input_fn=lambda _: "done", - print_fn=lambda x: None, - context_only=True, - ) - # Verify the provider received a multi-modal message - first_call = mock_agent_context.ai_provider.chat.call_args_list[0] - messages = first_call[0][0] - user_msg = [m for m in messages if m.role == "user"][0] - assert isinstance(user_msg.content, list) - - -# ====================================================================== -# Discovery state multi-modal persistence -# ====================================================================== - - -class TestDiscoveryStateMultiModal: - """Multi-modal content is persisted as text with image count.""" - - def test_update_from_exchange_multimodal(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - state = DiscoveryState(str(tmp_path)) - state.load() - multimodal = [ - {"type": "text", "text": "Here is my architecture"}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,def"}}, - ] - state.update_from_exchange(multimodal, "Looks good!", 1) - - history = state.state["conversation_history"] - assert len(history) == 1 - assert "Here is my architecture" in history[0]["user"] - assert "[2 image(s) attached]" in history[0]["user"] - assert "base64" not in history[0]["user"] - - def test_update_from_exchange_string(self, tmp_path): - """Regular string input still works.""" - from azext_prototype.stages.discovery_state import DiscoveryState - - state = DiscoveryState(str(tmp_path)) - state.load() - state.update_from_exchange("plain text", "response", 1) - - history = state.state["conversation_history"] - assert history[0]["user"] == "plain text" - - -# ====================================================================== -# Joint analyst + architect discovery -# ====================================================================== - - -class TestJointDiscovery: - """Test that both biz-analyst and cloud-architect contribute to discovery.""" - - def test_architect_context_injected_into_chat( - self, - mock_agent_context, - mock_registry, - ): - """System messages should include architect constraints.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Tell me about your project."), - _make_response("## Project Summary\nTest project."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - session.run( - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - - # Check that the first AI call includes architect context - first_call = mock_agent_context.ai_provider.chat.call_args_list[0] - messages = first_call[0][0] - system_msgs = [m.content for m in messages if m.role == "system"] - combined = "\n".join(system_msgs) - assert "Architectural Guidance" in combined - assert "Managed Identity" in combined - - def test_architect_constraints_in_system_messages( - self, - mock_agent_context, - mock_registry, - ): - """Architect's constraints should appear in system messages.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("## Project Summary\nDone."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - session.run( - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - - first_call = mock_agent_context.ai_provider.chat.call_args_list[0] - messages = first_call[0][0] - system_content = "\n".join(m.content for m in messages if m.role == "system") - assert "PaaS over IaaS" in system_content - assert "Well-Architected Framework" in system_content - - def test_single_ai_call_per_turn( - self, - mock_agent_context, - mock_registry, - ): - """Joint discovery still uses a single AI call per turn.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("Got it."), - _make_response("## Project Summary\nDone."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["A web app", "done"]) - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # 3 calls: opening + user reply + summary — NOT doubled - assert mock_agent_context.ai_provider.chat.call_count == 3 - - def test_no_architect_still_works( - self, - mock_agent_context, - mock_biz_agent, - ): - """Discovery works when no architect agent is available.""" - registry = MagicMock() - - def find_by_cap(cap): - if cap == AgentCapability.BIZ_ANALYSIS: - return [mock_biz_agent] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("## Project Summary\nDone."), - ] - - session = DiscoverySession(mock_agent_context, registry) - result = session.run( - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - assert not result.cancelled - # No architect context in messages - first_call = mock_agent_context.ai_provider.chat.call_args_list[0] - messages = first_call[0][0] - system_content = "\n".join(m.content for m in messages if m.role == "system") - assert "Architectural Guidance" not in system_content - - def test_build_architect_context_returns_empty_when_none( - self, - mock_agent_context, - mock_biz_agent, - ): - """_build_architect_context returns '' when no architect agent.""" - registry = MagicMock() - registry.find_by_capability.side_effect = lambda cap: ( - [mock_biz_agent] if cap == AgentCapability.BIZ_ANALYSIS else [] - ) - session = DiscoverySession(mock_agent_context, registry) - assert session._build_architect_context() == "" - - -# ====================================================================== -# Updated summary format -# ====================================================================== - - -class TestUpdatedSummaryFormat: - """Test that the summary prompt requests the exact heading format.""" - - def test_summary_prompt_mentions_required_headings( - self, - mock_agent_context, - mock_registry, - ): - """The summary prompt should mention the exact headings to use.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("A web API. Got it."), - _make_response("## Project Summary\nOrders API\n## Goals\n- Manage orders"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter(["An orders REST API", "done"]) - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # The summary call (last call) should mention the required headings - summary_call = mock_agent_context.ai_provider.chat.call_args_list[-1] - messages = summary_call[0][0] - user_msgs = [m.content for m in messages if m.role == "user"] - summary_prompt = user_msgs[-1] - assert "Project Summary" in summary_prompt - assert "Prototype Scope" in summary_prompt - assert "Policy Overrides" in summary_prompt - assert "In Scope" in summary_prompt - - def test_summary_prompt_asks_for_no_skipped_sections( - self, - mock_agent_context, - mock_registry, - ): - """The summary prompt should instruct not to skip sections.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Tell me more."), - _make_response("## Project Summary\nTest"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - session.run( - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - - summary_call = mock_agent_context.ai_provider.chat.call_args_list[-1] - messages = summary_call[0][0] - user_msgs = [m.content for m in messages if m.role == "user"] - summary_prompt = user_msgs[-1] - assert "None" in summary_prompt or "skip" in summary_prompt.lower() - - -# ====================================================================== -# Natural Language Intent Detection — Integration -# ====================================================================== - - -class TestNaturalLanguageIntentDiscovery: - """Test that natural language triggers the correct slash commands.""" - - def test_nl_open_items(self, mock_agent_context, mock_registry): - """'what are the open items' should trigger the /open display.""" - # Use return_value — any call returns a valid response (no headings - # to avoid triggering section-at-a-time gating) - mock_agent_context.ai_provider.chat.return_value = _make_response("Tell me about your project.") - session = DiscoverySession(mock_agent_context, mock_registry) - output = [] - inputs = iter(["what are the open items", "done"]) - session.run( - input_fn=lambda _: next(inputs), - print_fn=output.append, - ) - # The /open handler should have run and printed open items info - assert any("open" in o.lower() for o in output if isinstance(o, str)) - - def test_nl_status(self, mock_agent_context, mock_registry): - """'where do we stand' should trigger the /status display.""" - mock_agent_context.ai_provider.chat.return_value = _make_response("Tell me about your project.") - session = DiscoverySession(mock_agent_context, mock_registry) - output = [] - inputs = iter(["where do we stand", "done"]) - session.run( - input_fn=lambda _: next(inputs), - print_fn=output.append, - ) - assert any("status" in o.lower() or "discovery" in o.lower() for o in output if isinstance(o, str)) - - -# ====================================================================== -# extract_section_headers -# ====================================================================== - - -class TestExtractSectionHeaders: - """Unit tests for extract_section_headers().""" - - def test_extracts_h2_headings(self): - text = "## Project Context & Scope\nSome text\n## Data & Content\nMore text" - result = extract_section_headers(text) - assert result == [("Project Context & Scope", 2), ("Data & Content", 2)] - - def test_h3_only_returns_empty(self): - """Level-3 only responses produce no headers (subsections are not topics).""" - text = "### Authentication\nDetails\n### Authorization\nMore details" - result = extract_section_headers(text) - assert result == [] - - def test_mixed_h2_h3_returns_only_h2(self): - """Level-3 subsections are filtered out — only level-2 topics returned.""" - text = "## Overview\nText\n### Sub-section\nText\n## Architecture\nText" - result = extract_section_headers(text) - assert result == [("Overview", 2), ("Architecture", 2)] - - def test_skips_structural_headings(self): - text = "## Project Context\nText\n" "## Summary\nText\n" "## Policy Overrides\nText\n" "## Next Steps\nText\n" - result = extract_section_headers(text) - assert result == [("Project Context", 2)] - - def test_skips_policy_override_singular(self): - text = "## Policy Override\nText" - result = extract_section_headers(text) - assert result == [] - - def test_skips_short_headings(self): - text = "## AB\nText\n## OK\nMore" - result = extract_section_headers(text) - assert result == [] - - def test_empty_string(self): - assert extract_section_headers("") == [] - - def test_no_headings(self): - text = "Just plain text without any headings at all." - assert extract_section_headers(text) == [] - - def test_h1_not_extracted(self): - """Only ## and ### are extracted, not #.""" - text = "# Title\n## Section One\nContent" - result = extract_section_headers(text) - assert result == [("Section One", 2)] - - def test_strips_whitespace(self): - text = "## Padded Heading \nText" - result = extract_section_headers(text) - assert result == [("Padded Heading", 2)] - - def test_case_insensitive_skip(self): - text = "## SUMMARY\nText\n## NEXT STEPS\nText\n## Actual Content\nText" - result = extract_section_headers(text) - assert result == [("Actual Content", 2)] - - def test_bold_headings_extracted(self): - """**Bold Heading** on its own line should be extracted as level 2.""" - text = ( - "Let me ask about your project.\n" - "\n" - "**Hosting & Deployment**\n" - "How do you plan to host this?\n" - "\n" - "**Data Layer**\n" - "What database will you use?" - ) - result = extract_section_headers(text) - assert ("Hosting & Deployment", 2) in result - assert ("Data Layer", 2) in result - - def test_bold_inline_not_extracted(self): - """Bold text mid-line should NOT be extracted as a heading.""" - text = "I think **this is important** for the project." - result = extract_section_headers(text) - assert result == [] - - def test_bold_and_markdown_headings_merged(self): - """Both ## headings and **bold headings** should be found with levels.""" - text = "## Architecture Overview\n" "Details here.\n" "\n" "**Security Considerations**\n" "More details." - result = extract_section_headers(text) - assert ("Architecture Overview", 2) in result - assert ("Security Considerations", 2) in result - - def test_bold_headings_deduped(self): - """Duplicate headings (same text in both formats) should appear once.""" - text = "## Security\n" "Details.\n" "\n" "**Security**\n" "More details." - result = extract_section_headers(text) - texts = [h[0] for h in result] - assert texts.count("Security") == 1 - - def test_bold_headings_skip_structural(self): - """Bold structural headings (Summary, Next Steps) should be skipped.""" - text = "**Summary**\nText\n**Actual Topic**\nMore text" - result = extract_section_headers(text) - texts = [h[0] for h in result] - assert "Summary" not in texts - assert "Actual Topic" in texts - - def test_bold_heading_too_short(self): - """Bold headings under 3 chars should be skipped.""" - text = "**AB**\nText" - result = extract_section_headers(text) - assert result == [] - - def test_skip_what_ive_understood(self): - """'What I've Understood So Far' and variants should be filtered.""" - text = ( - "## What I've Understood So Far\nStuff\n" - "## What We've Covered\nMore stuff\n" - "## Actual Topic\nReal content" - ) - result = extract_section_headers(text) - texts = [h[0] for h in result] - assert "What I've Understood So Far" not in texts - assert "What We've Covered" not in texts - assert "Actual Topic" in texts - - def test_position_ordering(self): - """Headers should be sorted by their position in the response.""" - text = "**First Bold**\n" "Text\n" "## Second Markdown\n" "Text\n" "**Third Bold**\n" "Text" - result = extract_section_headers(text) - assert result == [("First Bold", 2), ("Second Markdown", 2), ("Third Bold", 2)] - - -# ====================================================================== -# section_fn callback integration -# ====================================================================== - - -class TestSectionFnCallback: - """Verify that section_fn is called with extracted headers during a session.""" - - def test_section_fn_receives_headers( - self, - mock_agent_context, - mock_registry, - ): - """section_fn should be called upfront with all headers from the AI response.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response( - "## Project Context & Scope\n" - "Let me ask about your project.\n" - "## Data & Content\n" - "What kind of data will you store?" - ), - # Summary after "done" exits the section loop - _make_response("## Summary\nAll done."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - captured_headers = [] - - def _section_fn(headers): - captured_headers.extend(headers) - - # "done" exits from the section loop immediately - session.run( - input_fn=lambda _: "done", - print_fn=lambda x: None, - section_fn=_section_fn, - ) - - texts = [h[0] for h in captured_headers] - assert "Project Context & Scope" in texts - assert "Data & Content" in texts - - def test_section_fn_not_called_when_none( - self, - mock_agent_context, - mock_registry, - ): - """When section_fn is None, no error should occur.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Some Heading\nContent"), - _make_response("## Summary\nDone"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - # Should not raise — section_fn defaults to None - result = session.run( - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - assert not result.cancelled - - -# ====================================================================== -# response_fn callback integration -# ====================================================================== - - -class TestResponseFnCallback: - """Verify that response_fn is called with agent responses during a session.""" - - def test_response_fn_receives_agent_responses( - self, - mock_agent_context, - mock_registry, - ): - """response_fn should be called with cleaned agent responses.""" - # Use a response without ## headings so it takes the non-sectioned path - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Let me understand your project. What are you building?"), - _make_response("An API. Got it."), - _make_response("Final summary."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - captured = [] - - def _response_fn(content): - captured.append(content) - - inputs = iter(["A REST API", "done"]) - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - response_fn=_response_fn, - ) - - # response_fn should have been called for the opening and the reply - assert len(captured) == 2 - assert "understand your project" in captured[0] - assert "API" in captured[1] - - def test_response_fn_not_called_when_none( - self, - mock_agent_context, - mock_registry, - ): - """When response_fn is None, print_fn should be used instead.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("What are you building?"), - _make_response("## Summary\nDone"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - printed = [] - - session.run( - input_fn=lambda _: "done", - print_fn=lambda x: printed.append(x), - ) - - # print_fn should have received the response - assert any("building" in p.lower() for p in printed if isinstance(p, str)) - - def test_response_fn_takes_precedence_over_print_fn( - self, - mock_agent_context, - mock_registry, - ): - """response_fn should be used instead of print_fn for agent responses.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Tell me about your project."), - _make_response("## Summary\nDone."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - printed = [] - response_captured = [] - - session.run( - input_fn=lambda _: "done", - print_fn=lambda x: printed.append(x), - response_fn=lambda x: response_captured.append(x), - ) - - # response_fn should have the agent response - assert len(response_captured) == 1 - assert "Tell me about your project" in response_captured[0] - # print_fn should NOT have the agent response text - assert not any("Tell me about your project" in p for p in printed if isinstance(p, str)) - - -# ====================================================================== -# parse_sections() -# ====================================================================== - - -class TestParseSections: - """Verify section parsing from AI responses.""" - - def test_basic_section_splitting(self): - text = ( - "Here's my analysis.\n\n" - "## Authentication\n" - "How do users sign in?\n\n" - "## Data Layer\n" - "What database do you prefer?" - ) - preamble, sections = parse_sections(text) - assert preamble == "Here's my analysis." - assert len(sections) == 2 - assert sections[0].heading == "Authentication" - assert sections[0].level == 2 - assert "How do users sign in?" in sections[0].content - assert sections[1].heading == "Data Layer" - assert "What database" in sections[1].content - - def test_preamble_only(self): - text = "No headings here, just a plain response." - preamble, sections = parse_sections(text) - assert preamble == text - assert sections == [] - - def test_empty_preamble(self): - text = "## First Topic\nQuestion here." - preamble, sections = parse_sections(text) - assert preamble == "" - assert len(sections) == 1 - - def test_skip_headings_filtered(self): - text = ( - "## Authentication\nHow do users sign in?\n\n" - "## Summary\nThis is a summary.\n\n" - "## Next Steps\nDo this next." - ) - _, sections = parse_sections(text) - assert len(sections) == 1 - assert sections[0].heading == "Authentication" - - def test_task_id_generation(self): - text = "## Data & Content\nWhat kind of data?" - _, sections = parse_sections(text) - assert len(sections) == 1 - assert sections[0].task_id == "design-section-data-content" - - def test_bold_headings(self): - text = ( - "Here's what I need to know.\n\n" - "**Authentication & Security**\n" - "How do users log in?\n\n" - "**Data Storage**\n" - "What database?" - ) - preamble, sections = parse_sections(text) - assert len(sections) == 2 - assert sections[0].heading == "Authentication & Security" - assert sections[0].level == 2 - - def test_level_3_only_returns_empty(self): - """Level-3 only response produces no sections (not treated as topics).""" - text = "### Sub-topic\nDetailed question." - _, sections = parse_sections(text) - assert len(sections) == 0 - - def test_subsections_folded_into_parent(self): - """Level-3 subsections are folded into their parent level-2 section.""" - text = "## Main Topic\nOverview.\n\n" "### Sub-topic\nDetail." - _, sections = parse_sections(text) - assert len(sections) == 1 - assert sections[0].heading == "Main Topic" - assert sections[0].level == 2 - # Subsection content is included in the parent's content - assert "### Sub-topic" in sections[0].content - assert "Detail." in sections[0].content - - def test_multiple_subsections_folded(self): - """Multiple ### subsections under one ## are all included.""" - text = ( - "## Scope Boundary\nLet's clarify scope.\n\n" - "### In Scope\n- Item A\n- Item B\n\n" - "### Out of Scope\n- Item C\n\n" - "## Next Topic\nQuestions here." - ) - _, sections = parse_sections(text) - assert len(sections) == 2 - assert sections[0].heading == "Scope Boundary" - assert "### In Scope" in sections[0].content - assert "### Out of Scope" in sections[0].content - assert "Item A" in sections[0].content - assert "Item C" in sections[0].content - assert sections[1].heading == "Next Topic" - - def test_empty_string(self): - preamble, sections = parse_sections("") - assert preamble == "" - assert sections == [] - - def test_duplicate_headings_deduped(self): - text = "## Authentication\nFirst mention.\n\n" "## Authentication\nSecond mention." - _, sections = parse_sections(text) - assert len(sections) == 1 - - -# ====================================================================== -# Section completion via AI "Yes" gate -# ====================================================================== - - -class TestSectionDoneDetection: - """Verify section completion detection via AI 'Yes' gate. - - The old heuristic-based ``_is_section_done()`` has been replaced with - an explicit AI confirmation step. When the AI responds with exactly - "Yes" (case-insensitive, optional trailing period) the section is - considered complete. - """ - - def test_continue_in_done_words(self): - """'continue' should be accepted as a done keyword.""" - assert "continue" in _DONE_WORDS - - -# ====================================================================== -# Section-at-a-time flow integration -# ====================================================================== - - -class TestSectionAtATimeFlow: - """Verify sections are shown one at a time with follow-ups.""" - - def test_sections_shown_one_at_a_time( - self, - mock_agent_context, - mock_registry, - ): - """Each section should be shown individually, collecting user input.""" - mock_agent_context.ai_provider.chat.side_effect = [ - # Initial response with 2 sections - _make_response( - "Great, let me explore a few areas.\n\n" - "## Authentication\n" - "How do users sign in?\n\n" - "## Data Layer\n" - "What database do you need?" - ), - # Follow-up for section 1 (auth) — marks section done - _make_response("Yes"), - # Follow-up for section 2 (data) — marks section done - _make_response("Yes"), - # Summary after free-form "done" - _make_response("## Summary\nAll done."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - printed = [] - inputs = iter( - [ - "We use Entra ID", # Answer for section 1 - "SQL Database", # Answer for section 2 - "done", # Exit free-form loop - ] - ) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: printed.append(x), - ) - assert not result.cancelled - # Both sections should have been displayed - printed_text = "\n".join(str(p) for p in printed) - assert "Authentication" in printed_text - assert "Data Layer" in printed_text - - def test_skip_advances_to_next_section( - self, - mock_agent_context, - mock_registry, - ): - """Typing 'skip' should advance to the next section.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Auth\nHow do users sign in?\n\n" "## Data\nWhat database?"), - # Follow-up for data section - _make_response("Yes"), - # Summary - _make_response("## Summary\nDone."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter( - [ - "skip", # Skip auth section - "Cosmos DB", # Answer data section - "done", # Exit free-form - ] - ) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - assert not result.cancelled - - def test_done_exits_section_loop( - self, - mock_agent_context, - mock_registry, - ): - """Typing 'done' during section loop should jump to summary.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Auth\nHow do users sign in?\n\n" "## Data\nWhat database?"), - # Summary produced after "done" - _make_response("## Summary\nFinal summary."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - result = session.run( - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - assert not result.cancelled - assert result.requirements # Should have summary - - def test_quit_cancels_from_section_loop( - self, - mock_agent_context, - mock_registry, - ): - """Typing 'quit' during section loop should cancel the session.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Auth\nHow do users sign in?\n\n" "## Data\nWhat database?"), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - result = session.run( - input_fn=lambda _: "quit", - print_fn=lambda x: None, - ) - assert result.cancelled - - def test_follow_ups_iterate_within_section( - self, - mock_agent_context, - mock_registry, - ): - """Multiple follow-ups within a section should work.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Auth\nHow do users sign in?"), - # First follow-up — needs more info - _make_response("What about service-to-service auth?"), - # Second follow-up — section done - _make_response("Yes"), - # Summary - _make_response("## Summary\nDone."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - inputs = iter( - [ - "Entra ID for users", # First answer - "Managed identity for services", # Second answer - "done", # Exit free-form - ] - ) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - assert not result.cancelled - assert result.exchange_count >= 3 # opening + 2 follow-ups - - def test_update_task_fn_called( - self, - mock_agent_context, - mock_registry, - ): - """update_task_fn should be called with in_progress and completed.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Auth\nHow do users sign in?"), - _make_response("Yes"), - _make_response("## Summary\nDone."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - task_updates = [] - - def _update_task_fn(tid, status): - task_updates.append((tid, status)) - - inputs = iter(["Entra ID", "done"]) - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - update_task_fn=_update_task_fn, - ) - - # Should have in_progress then completed for the auth section - assert ("design-section-auth", "in_progress") in task_updates - assert ("design-section-auth", "completed") in task_updates - - def test_no_sections_fallback( - self, - mock_agent_context, - mock_registry, - ): - """When no sections are found, should display full response.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Tell me what you want to build."), - _make_response("## Summary\nDone."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - printed = [] - - result = session.run( - input_fn=lambda _: "done", - print_fn=lambda x: printed.append(x), - ) - - assert not result.cancelled - printed_text = "\n".join(str(p) for p in printed) - assert "Tell me what you want to build" in printed_text - - def test_yes_gate_not_displayed( - self, - mock_agent_context, - mock_registry, - ): - """AI 'Yes' confirmation should not be printed to the user.""" - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Auth\nHow do users sign in?"), - _make_response("Yes"), - _make_response("## Summary\nDone."), - ] - - session = DiscoverySession(mock_agent_context, mock_registry) - printed = [] - - inputs = iter(["Entra ID", "continue"]) - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: printed.append(x), - ) - - printed_text = "\n".join(str(p) for p in printed) - # The "Yes" response should not appear in output - assert "\nYes\n" not in printed_text - - -# ====================================================================== -# Topic persistence and re-entry -# ====================================================================== - - -class TestTopicPersistence: - """Topics are established once, persisted, and immutable across re-runs.""" - - def test_topics_persisted_on_first_run( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """First run with sections should persist topics to discovery state.""" - from azext_prototype.stages.discovery_state import DiscoveryState - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Auth\nHow do users sign in?\n## Data\nWhat database?"), - _make_response("Yes"), # Auth confirmed - _make_response("Yes"), # Data confirmed - _make_response("## Summary\nDone."), - ] - - ds = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) - inputs = iter(["Entra ID", "PostgreSQL", "done"]) - - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - assert ds.has_topics - topics = ds.topics - assert len(topics) == 2 - assert topics[0].heading == "Auth" - assert topics[1].heading == "Data" - - def test_topics_marked_answered_on_confirm( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """AI 'Yes' confirmation marks topic as answered.""" - from azext_prototype.stages.discovery_state import DiscoveryState - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Auth\nHow do users sign in?\n## Data\nWhat database?"), - _make_response("Yes"), # Auth confirmed - _make_response("Yes"), # Data confirmed - _make_response("## Summary\nDone."), - ] - - ds = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) - inputs = iter(["Entra ID", "PostgreSQL", "done"]) - - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - topics = ds.topics - assert topics[0].status == "answered" - assert topics[0].answer_exchange is not None - assert topics[1].status == "answered" - - def test_topic_marked_skipped( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """Skipping a section marks the topic as skipped.""" - from azext_prototype.stages.discovery_state import DiscoveryState - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Auth\nHow do users sign in?\n## Data\nWhat database?"), - _make_response("Yes"), # Data confirmed - _make_response("## Summary\nDone."), - ] - - ds = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) - inputs = iter(["skip", "PostgreSQL", "done"]) - - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - topics = ds.topics - assert topics[0].status == "skipped" - assert topics[1].status == "answered" - - def test_topics_remain_pending_on_quit( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """Quitting mid-session leaves remaining topics as pending.""" - from azext_prototype.stages.discovery_state import DiscoveryState - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response( - "## Auth\nHow do users sign in?\n## Data\nWhat database?\n## Networking\nPublic or private?" - ), - _make_response("Yes"), # Auth confirmed - ] - - ds = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) - inputs = iter(["Entra ID", "quit"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - assert result.cancelled - topics = ds.topics - assert topics[0].status == "answered" - assert topics[1].status == "pending" - assert topics[2].status == "pending" - - -class TestTopicReentry: - """Re-entry resumes at the first unanswered topic.""" - - def test_reentry_resumes_at_first_pending( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """Re-run with existing topics resumes at first pending topic.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - # Pre-populate state with topics (Auth answered, Data pending) - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic( - heading="Auth", - detail="## Auth\nHow do users sign in?", - kind="topic", - status="answered", - answer_exchange=2, - ), - Topic( - heading="Data", - detail="## Data\nWhat database?", - kind="topic", - status="pending", - answer_exchange=None, - ), - ] - ) - ds.state["_metadata"]["exchange_count"] = 2 - ds.save() - - # Re-run: should skip Auth and start with Data - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Yes"), # Data confirmed - _make_response("## Summary\nDone."), - ] - - ds2 = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) - inputs = iter(["PostgreSQL", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - assert not result.cancelled - # Data should now be answered - topics = ds2.topics - assert topics[0].status == "answered" # Auth unchanged - assert topics[1].status == "answered" # Data now answered - - def test_reentry_shows_progress_message( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """Re-entry should show a progress message.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), - Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="pending", answer_exchange=None), - Topic(heading="Net", detail="## Net\nPublic?", kind="topic", status="pending", answer_exchange=None), - ] - ) - ds.save() - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Yes"), - _make_response("Yes"), - _make_response("## Summary\nDone."), - ] - - ds2 = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) - printed = [] - inputs = iter(["PostgreSQL", "Public", "done"]) - - session.run( - input_fn=lambda _: next(inputs), - print_fn=printed.append, - ) - - combined = "\n".join(str(p) for p in printed) - assert "1/3 topics covered" in combined - - def test_reentry_all_topics_done_falls_through( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """If all topics are done on re-entry, fall through to free-form loop.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), - Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="answered", answer_exchange=2), - ] - ) - ds.save() - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Summary\nDone."), # Summary from free-form "done" - ] - - ds2 = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) - - result = session.run( - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - - assert not result.cancelled - - def test_reentry_does_not_resend_full_history( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """Re-entry seeds messages with compact summary, not full history.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.state["project"]["summary"] = "An inventory API" - ds.set_topics( - [ - Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), - Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="pending", answer_exchange=None), - ] - ) - ds.state["_metadata"]["exchange_count"] = 3 - # Add large conversation history - for i in range(20): - ds.state["conversation_history"].append( - { - "exchange": i + 1, - "timestamp": "2026-01-01T00:00:00", - "user": f"Long user message {i}" * 50, - "assistant": f"Long assistant response {i}" * 50, - } - ) - ds.save() - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Yes"), - _make_response("## Summary\nDone."), - ] - - ds2 = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) - inputs = iter(["PostgreSQL", "done"]) - - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # The first AI call should NOT contain all 20 exchanges - first_call = mock_agent_context.ai_provider.chat.call_args_list[0] - messages = first_call[0][0] - user_msgs = [m for m in messages if m.role == "user"] - # Should have compact summary + the section follow-up prompt, not 20+ user messages - assert len(user_msgs) <= 5 - - def test_reentry_restores_exchange_count( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """Re-entry restores exchange count from metadata.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="pending", answer_exchange=None), - ] - ) - ds.state["_metadata"]["exchange_count"] = 5 - ds.save() - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Yes"), - _make_response("## Summary\nDone."), - ] - - ds2 = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) - inputs = iter(["PostgreSQL", "done"]) - - result = session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - # Exchange count should continue from 5, not restart at 0 - assert result.exchange_count == 6 - - -class TestIncrementalTopics: - """New artifacts can add topics but not replace existing ones.""" - - def test_new_artifacts_add_topics( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """Re-entry with new artifacts should add new topics.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), - Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="answered", answer_exchange=2), - ] - ) - ds.save() - - # AI identifies a new topic from the new artifact - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("## Caching\nWhat caching strategy do you need?"), # incremental context - _make_response("Yes"), # Caching confirmed - _make_response("## Summary\nDone."), - ] - - ds2 = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) - inputs = iter(["Redis", "done"]) - - session.run( - seed_context="We also need caching", - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - topics = ds2.topics - assert len(topics) == 3 - assert topics[2].heading == "Caching" - - def test_no_new_topics_marker( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """AI returns [NO_NEW_TOPICS] when artifacts don't warrant new topics.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), - ] - ) - ds.save() - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("[NO_NEW_TOPICS]"), # No new topics needed - _make_response("What are you building?"), # Free-form (all topics done) - _make_response("## Summary\nDone."), - ] - - ds2 = DiscoveryState(str(tmp_path)) - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) - - session.run( - seed_context="Same project, just more detail", - input_fn=lambda _: "done", - print_fn=lambda x: None, - ) - - # Original topics unchanged - assert len(ds2.topics) == 1 - assert ds2.topics[0].heading == "Auth" - - def test_duplicate_headings_deduplicated( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """append_topics should not add duplicates (case-insensitive).""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="answered", answer_exchange=1), - ] - ) - - ds.append_topics( - [ - Topic( - heading="auth", detail="## auth\nDuplicate?", kind="topic", status="pending", answer_exchange=None - ), - Topic( - heading="Caching", - detail="## Caching\nNew topic", - kind="topic", - status="pending", - answer_exchange=None, - ), - ] - ) - - topics = ds.topics - assert len(topics) == 2 # Auth (original) + Caching (new) - assert topics[0].heading == "Auth" - assert topics[1].heading == "Caching" - - -class TestTopicStateHelpers: - """Unit tests for Topic dataclass and DiscoveryState topic helpers.""" - - def test_topic_to_dict_roundtrip(self): - from azext_prototype.stages.discovery_state import Topic - - t = Topic(heading="Auth", detail="How do users sign in?", kind="topic", status="answered", answer_exchange=3) - d = t.to_dict() - t2 = Topic.from_dict(d) - assert t2.heading == "Auth" - assert t2.detail == "How do users sign in?" - assert t2.status == "answered" - assert t2.answer_exchange == 3 - - def test_topic_from_dict_defaults(self): - from azext_prototype.stages.discovery_state import Topic - - t = Topic.from_dict({"heading": "Auth"}) - assert t.detail == "" - assert t.status == "pending" - assert t.answer_exchange is None - - def test_default_state_has_items_key(self): - from azext_prototype.stages.discovery_state import _default_discovery_state - - state = _default_discovery_state() - assert "items" in state - assert state["items"] == [] - - def test_has_topics_false_on_empty(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - assert not ds.has_topics - - def test_first_pending_topic_index(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="A", detail="Q", kind="topic", status="answered", answer_exchange=1), - Topic(heading="B", detail="Q", kind="topic", status="skipped", answer_exchange=None), - Topic(heading="C", detail="Q", kind="topic", status="pending", answer_exchange=None), - ] - ) - assert ds.first_pending_topic_index() == 2 - - def test_first_pending_topic_index_none_when_all_done(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="A", detail="Q", kind="topic", status="answered", answer_exchange=1), - ] - ) - assert ds.first_pending_topic_index() is None - - def test_mark_topic(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="Auth", detail="Q", kind="topic", status="pending", answer_exchange=None), - ] - ) - ds.mark_topic("Auth", "answered", 5) - topics = ds.topics - assert topics[0].status == "answered" - assert topics[0].answer_exchange == 5 - - def test_backward_compat_old_yaml_without_items(self, tmp_path): - """Old discovery.yaml without items key should get items: [] via deep_merge.""" - import yaml - - from azext_prototype.stages.discovery_state import DiscoveryState - - # Write a YAML file without items key (no topics/open_items/confirmed_items either) - state_dir = tmp_path / ".prototype" / "state" - state_dir.mkdir(parents=True) - old_state = { - "project": {"summary": "Old project", "goals": ["Goal 1"]}, - "requirements": {"functional": [], "non_functional": []}, - "constraints": [], - "decisions": [], - "risks": [], - "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, - "architecture": {"services": [], "integrations": [], "data_flow": ""}, - "conversation_history": [], - "_metadata": {"created": "2026-01-01", "last_updated": "2026-01-01", "exchange_count": 3}, - } - with open(state_dir / "discovery.yaml", "w") as f: - yaml.dump(old_state, f) - - ds = DiscoveryState(str(tmp_path)) - ds.load() - assert not ds.has_items # Empty list = no items - assert ds.state.get("items") == [] - # Old data preserved - assert ds.state["project"]["summary"] == "Old project" - - -class TestLegacyMigration: - """Verify old-format YAML (topics + open_items + confirmed_items) migrates on load.""" - - def test_migrate_old_topics(self, tmp_path): - """Legacy topics field is migrated into unified items.""" - import yaml - - from azext_prototype.stages.discovery_state import DiscoveryState - - state_dir = tmp_path / ".prototype" / "state" - state_dir.mkdir(parents=True) - old_state = { - "project": {"summary": "", "goals": []}, - "requirements": {"functional": [], "non_functional": []}, - "constraints": [], - "decisions": [], - "topics": [ - {"heading": "Auth", "questions": "How do users sign in?", "status": "answered", "answer_exchange": 1}, - {"heading": "Data", "questions": "What database?", "status": "pending", "answer_exchange": None}, - ], - "open_items": [], - "confirmed_items": [], - "risks": [], - "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, - "architecture": {"services": [], "integrations": [], "data_flow": ""}, - "conversation_history": [], - "_metadata": {"created": "2026-01-01", "last_updated": "2026-01-01", "exchange_count": 2}, - } - with open(state_dir / "discovery.yaml", "w") as f: - yaml.dump(old_state, f) - - ds = DiscoveryState(str(tmp_path)) - ds.load() - - assert "topics" not in ds.state - assert "open_items" not in ds.state - assert "confirmed_items" not in ds.state - assert len(ds.items) == 2 - assert ds.items[0].heading == "Auth" - assert ds.items[0].detail == "How do users sign in?" - assert ds.items[0].kind == "topic" - assert ds.items[0].status == "answered" - assert ds.items[1].heading == "Data" - assert ds.items[1].status == "pending" - - def test_migrate_old_open_and_confirmed_items(self, tmp_path): - """Legacy open_items and confirmed_items migrate as decisions.""" - import yaml - - from azext_prototype.stages.discovery_state import DiscoveryState - - state_dir = tmp_path / ".prototype" / "state" - state_dir.mkdir(parents=True) - old_state = { - "project": {"summary": "", "goals": []}, - "requirements": {"functional": [], "non_functional": []}, - "constraints": [], - "decisions": [], - "open_items": ["Which region?", "Auth method?"], - "confirmed_items": ["Use PostgreSQL"], - "risks": [], - "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, - "architecture": {"services": [], "integrations": [], "data_flow": ""}, - "conversation_history": [], - "_metadata": {"created": "2026-01-01", "last_updated": "2026-01-01", "exchange_count": 3}, - } - with open(state_dir / "discovery.yaml", "w") as f: - yaml.dump(old_state, f) - - ds = DiscoveryState(str(tmp_path)) - ds.load() - - assert "open_items" not in ds.state - assert "confirmed_items" not in ds.state - assert len(ds.items) == 3 - # Two pending decisions from open_items - pending = ds.items_by_status("pending") - assert len(pending) == 2 - assert all(i.kind == "decision" for i in pending) - # One confirmed decision from confirmed_items - confirmed = ds.items_by_status("confirmed") - assert len(confirmed) == 1 - assert confirmed[0].heading == "Use PostgreSQL" - - def test_migrate_combined_topics_and_items(self, tmp_path): - """Legacy state with both topics AND open_items merges correctly.""" - import yaml - - from azext_prototype.stages.discovery_state import DiscoveryState - - state_dir = tmp_path / ".prototype" / "state" - state_dir.mkdir(parents=True) - old_state = { - "project": {"summary": "", "goals": []}, - "requirements": {"functional": [], "non_functional": []}, - "constraints": [], - "decisions": [], - "topics": [ - {"heading": "Auth", "questions": "How?", "status": "answered", "answer_exchange": 1}, - ], - "open_items": ["Which region?"], - "confirmed_items": ["Use Terraform"], - "risks": [], - "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, - "architecture": {"services": [], "integrations": [], "data_flow": ""}, - "conversation_history": [], - "_metadata": {"created": "2026-01-01", "last_updated": "2026-01-01", "exchange_count": 2}, - } - with open(state_dir / "discovery.yaml", "w") as f: - yaml.dump(old_state, f) - - ds = DiscoveryState(str(tmp_path)) - ds.load() - - assert len(ds.items) == 3 - assert ds.items[0].kind == "topic" # Auth - assert ds.items[1].kind == "decision" # Which region? - assert ds.items[1].status == "pending" - assert ds.items[2].kind == "decision" # Use Terraform - assert ds.items[2].status == "confirmed" - - -class TestUnifiedStatusCommands: - """Verify /status, /open, /confirmed show data from unified items.""" - - def test_status_shows_topics(self, tmp_path): - """format_status_summary counts topics as items.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - Topic(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=1), - Topic(heading="Data", detail="Q?", kind="topic", status="pending", answer_exchange=None), - Topic(heading="Net", detail="Q?", kind="topic", status="pending", answer_exchange=None), - ] - ) - - assert ds.open_count == 2 - assert ds.confirmed_count == 1 - summary = ds.format_status_summary() - assert "1 confirmed" in summary - assert "2 open" in summary - - def test_open_items_shows_pending_topics(self, tmp_path): - """format_open_items lists pending topics.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - Topic(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=1), - Topic(heading="Data", detail="Q?", kind="topic", status="pending", answer_exchange=None), - ] - ) - - text = ds.format_open_items() - assert "Data" in text - assert "Auth" not in text - assert "Topics:" in text - - def test_confirmed_items_shows_answered_topics(self, tmp_path): - """format_confirmed_items lists answered topics.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - Topic(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=1), - Topic(heading="Data", detail="Q?", kind="topic", status="pending", answer_exchange=None), - ] - ) - - text = ds.format_confirmed_items() - assert "Auth" in text - assert "Data" not in text - - def test_status_no_items(self, tmp_path): - """format_status_summary with no items.""" - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - - assert ds.format_status_summary() == "No items tracked yet." - assert "No open items" in ds.format_open_items() - assert "No items confirmed" in ds.format_confirmed_items() - - def test_mixed_kinds_in_open(self, tmp_path): - """format_open_items groups topics and decisions separately.""" - from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - TrackedItem(heading="Auth", detail="Q?", kind="topic", status="pending", answer_exchange=None), - TrackedItem( - heading="Which region?", - detail="Which region?", - kind="decision", - status="pending", - answer_exchange=None, - ), - ] - ) - - text = ds.format_open_items() - assert "Topics:" in text - assert "Auth" in text - assert "Decisions:" in text - assert "Which region?" in text - - -class TestArtifactInventoryState: - """Tests for artifact inventory and context hash tracking in DiscoveryState.""" - - def test_default_state_has_inventory_keys(self): - from azext_prototype.stages.discovery_state import _default_discovery_state - - state = _default_discovery_state() - assert "artifact_inventory" in state - assert state["artifact_inventory"] == {} - assert "context_hash" in state - assert state["context_hash"] == "" - - def test_artifact_inventory_roundtrip(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.update_artifact_inventory({"/abs/path/file.txt": "abc123", "/abs/path/img.png": "def456"}) - - # Reload from disk - ds2 = DiscoveryState(str(tmp_path)) - ds2.load() - hashes = ds2.get_artifact_hashes() - assert hashes == {"/abs/path/file.txt": "abc123", "/abs/path/img.png": "def456"} - - def test_get_artifact_hashes_flat_mapping(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.update_artifact_inventory({"/a/b.txt": "hash1", "/c/d.txt": "hash2"}) - - hashes = ds.get_artifact_hashes() - assert isinstance(hashes, dict) - assert hashes["/a/b.txt"] == "hash1" - assert hashes["/c/d.txt"] == "hash2" - - def test_update_artifact_inventory_is_additive(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.update_artifact_inventory({"/a/first.txt": "aaa"}) - ds.update_artifact_inventory({"/b/second.txt": "bbb"}) - - hashes = ds.get_artifact_hashes() - assert len(hashes) == 2 - assert hashes["/a/first.txt"] == "aaa" - assert hashes["/b/second.txt"] == "bbb" - - def test_update_artifact_inventory_overwrites_hash(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.update_artifact_inventory({"/a/file.txt": "old_hash"}) - ds.update_artifact_inventory({"/a/file.txt": "new_hash"}) - - assert ds.get_artifact_hashes()["/a/file.txt"] == "new_hash" - - def test_context_hash_roundtrip(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.update_context_hash("ctx_hash_abc") - - ds2 = DiscoveryState(str(tmp_path)) - ds2.load() - assert ds2.get_context_hash() == "ctx_hash_abc" - - def test_reset_clears_inventory(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.update_artifact_inventory({"/a/file.txt": "hash1"}) - ds.update_context_hash("ctx_hash") - - ds.reset() - assert ds.get_artifact_hashes() == {} - assert ds.get_context_hash() == "" - - def test_legacy_state_without_inventory_loads(self, tmp_path): - """Old discovery.yaml without inventory keys loads cleanly via _deep_merge.""" - import yaml - - from azext_prototype.stages.discovery_state import DiscoveryState - - state_dir = tmp_path / ".prototype" / "state" - state_dir.mkdir(parents=True) - # Write a minimal legacy state without the new keys - legacy = { - "project": {"summary": "test", "goals": []}, - "requirements": {"functional": [], "non_functional": []}, - "constraints": [], - "decisions": [], - "items": [], - "risks": [], - "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, - "architecture": {"services": [], "integrations": [], "data_flow": ""}, - "conversation_history": [], - "_metadata": {"created": None, "last_updated": None, "exchange_count": 0}, - } - with open(state_dir / "discovery.yaml", "w") as f: - yaml.dump(legacy, f) - - ds = DiscoveryState(str(tmp_path)) - ds.load() - # New keys should be present with defaults - assert ds.get_artifact_hashes() == {} - assert ds.get_context_hash() == "" - assert ds.state["project"]["summary"] == "test" - - -class TestSectionLoopSlashCommands: - """Verify that slash commands do NOT consume inner loop iterations.""" - - def test_slash_commands_do_not_advance_topic( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """Issuing 5+ slash commands should NOT mark a topic as answered.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="pending", answer_exchange=None), - Topic(heading="Data", detail="## Data\nWhat?", kind="topic", status="pending", answer_exchange=None), - ] - ) - ds.save() - - # AI identifies no new topics (re-entry), then confirms section after real answer - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Yes"), # Auth confirmed after real answer - _make_response("Yes"), # Data confirmed after real answer - _make_response("## Summary\nDone."), - ] - - ds2 = DiscoveryState(str(tmp_path)) - # 6 slash commands first (more than old limit of 5), then a real answer, then done - inputs = iter( - [ - "/status", - "/open", - "/confirmed", - "/status", - "/open", - "/confirmed", - "Use Azure AD B2C", # Real answer for Auth - "Use Cosmos DB", # Real answer for Data - "done", - ] - ) - - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - topics = ds2.topics - # Auth should be answered (via real AI exchange), not prematurely - assert topics[0].status == "answered" - assert topics[0].answer_exchange is not None - # Data should also be answered - assert topics[1].status == "answered" - - def test_empty_input_does_not_advance_topic( - self, - mock_agent_context, - mock_registry, - mock_biz_agent, - tmp_path, - ): - """Pressing Enter 5+ times should NOT mark a topic as answered.""" - from azext_prototype.stages.discovery_state import DiscoveryState, Topic - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_topics( - [ - Topic(heading="Auth", detail="## Auth\nHow?", kind="topic", status="pending", answer_exchange=None), - ] - ) - ds.save() - - mock_agent_context.ai_provider.chat.side_effect = [ - _make_response("Yes"), # Auth confirmed after real answer - _make_response("## Summary\nDone."), - ] - - ds2 = DiscoveryState(str(tmp_path)) - # 6 empty inputs, then a real answer, then done - inputs = iter(["", "", "", "", "", "", "Use Azure AD", "done"]) - - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds2) - session.run( - input_fn=lambda _: next(inputs), - print_fn=lambda x: None, - ) - - topics = ds2.topics - assert topics[0].status == "answered" - assert topics[0].answer_exchange is not None - - -class TestRestartSignal: - """Verify /restart breaks out of section loop.""" - - def test_restart_returns_signal_from_handler(self, mock_agent_context, mock_registry, mock_biz_agent, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - - mock_agent_context.ai_provider.chat.return_value = _make_response("Welcome!") - session = DiscoverySession(mock_agent_context, mock_registry, discovery_state=ds) - # Set up I/O attributes that _handle_slash_command needs - session._print = lambda x: None - session._use_styled = False - session._status_fn = None - session._response_fn = None - session._messages = [] - result = session._handle_slash_command("/restart") - assert result == "restart" - - def test_non_restart_returns_none(self, mock_agent_context, mock_registry, mock_biz_agent): - session = DiscoverySession(mock_agent_context, mock_registry) - session._print = lambda x: None - session._use_styled = False - result = session._handle_slash_command("/status") - assert result is None - - result = session._handle_slash_command("/open") - assert result is None - - -class TestTopicAtExchange: - """Verify topic_at_exchange() cross-references exchanges with topics.""" - - def test_finds_topic_at_exchange(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), - TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=4), - TrackedItem(heading="Scale", detail="Q?", kind="topic", status="pending", answer_exchange=None), - ] - ) - - assert ds.topic_at_exchange(1) == "Auth" - assert ds.topic_at_exchange(2) == "Auth" - assert ds.topic_at_exchange(3) == "Data" - assert ds.topic_at_exchange(4) == "Data" - assert ds.topic_at_exchange(5) is None # Beyond all answered topics - - def test_no_answered_topics(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - TrackedItem(heading="Auth", detail="Q?", kind="topic", status="pending", answer_exchange=None), - ] - ) - - assert ds.topic_at_exchange(1) is None - - def test_empty_state(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - assert ds.topic_at_exchange(1) is None - - -# ====================================================================== -# _chat_lightweight edge cases -# ====================================================================== - - -class TestChatLightweight: - """Tests for _chat_lightweight — minimal AI call for classification tasks.""" - - def test_empty_content(self, mock_agent_context, mock_registry): - """Empty string content should still work.""" - mock_agent_context.ai_provider.chat.return_value = _make_response("[NO_NEW_TOPICS]") - session = DiscoverySession(mock_agent_context, mock_registry) - - result = session._chat_lightweight("") - assert result == "[NO_NEW_TOPICS]" - - # Verify it used a minimal system prompt (not the full governance payload) - call_args = mock_agent_context.ai_provider.chat.call_args - messages = call_args[0][0] - system_msgs = [m for m in messages if m.role == "system"] - assert len(system_msgs) == 1 - assert len(system_msgs[0].content) < 200 # Lightweight — not 69KB - - def test_does_not_add_to_messages(self, mock_agent_context, mock_registry): - """_chat_lightweight is ephemeral — should NOT add to self._messages.""" - mock_agent_context.ai_provider.chat.return_value = _make_response("analysis result") - session = DiscoverySession(mock_agent_context, mock_registry) - - initial_count = len(session._messages) - session._chat_lightweight("classify this") - assert len(session._messages) == initial_count - - def test_records_tokens(self, mock_agent_context, mock_registry): - """Token usage from lightweight calls should be tracked.""" - mock_agent_context.ai_provider.chat.return_value = _make_response("result") - session = DiscoverySession(mock_agent_context, mock_registry) - - session._chat_lightweight("test prompt") - # TokenTracker.record was called (uses AIResponse) - assert session._token_tracker._turn_count >= 1 - - def test_uses_low_temperature(self, mock_agent_context, mock_registry): - """Lightweight calls use temperature=0.3 for determinism.""" - mock_agent_context.ai_provider.chat.return_value = _make_response("ok") - session = DiscoverySession(mock_agent_context, mock_registry) - - session._chat_lightweight("test") - call_kwargs = mock_agent_context.ai_provider.chat.call_args[1] - assert call_kwargs.get("temperature") == 0.3 - - -# ====================================================================== -# _handle_incremental_context edge cases -# ====================================================================== - - -class TestHandleIncrementalContext: - """Tests for _handle_incremental_context — re-entry topic detection.""" - - def test_returns_false_no_topics_no_seed_context( - self, - mock_agent_context, - mock_registry, - ): - """When AI says [NO_NEW_TOPICS] and no seed_context, returns False.""" - mock_agent_context.ai_provider.chat.return_value = _make_response("[NO_NEW_TOPICS]") - session = DiscoverySession(mock_agent_context, mock_registry) - - result = session._handle_incremental_context( - seed_context="", - artifacts="some artifact text", - artifact_images=None, - _print=lambda x: None, - use_styled=False, - status_fn=None, - ) - assert result is False - - def test_returns_false_no_topics_with_seed_context( - self, - mock_agent_context, - mock_registry, - ): - """When AI says [NO_NEW_TOPICS] with seed_context, records decision.""" - mock_agent_context.ai_provider.chat.return_value = _make_response("[NO_NEW_TOPICS]") - session = DiscoverySession(mock_agent_context, mock_registry) - - printed = [] - result = session._handle_incremental_context( - seed_context="Change app name to Contoso", - artifacts="", - artifact_images=None, - _print=printed.append, - use_styled=False, - status_fn=None, - ) - assert result is False - # Seed context should be recorded as a confirmed decision - decisions = session._discovery_state.state["decisions"] - assert "Change app name to Contoso" in decisions - assert any("Context recorded" in p for p in printed) - - def test_returns_true_when_new_topics_found( - self, - mock_agent_context, - mock_registry, - ): - """When AI returns new sections, topics are appended and returns True.""" - new_topics_response = ( - "## Authentication Strategy\n" - "1. What identity provider?\n" - "2. SSO required?\n\n" - "## Data Residency\n" - "1. Which region?\n" - "2. Compliance needs?\n" - ) - mock_agent_context.ai_provider.chat.return_value = _make_response(new_topics_response) - session = DiscoverySession(mock_agent_context, mock_registry) - - result = session._handle_incremental_context( - seed_context="Add GDPR compliance", - artifacts="", - artifact_images=None, - _print=lambda x: None, - use_styled=False, - status_fn=None, - ) - assert result is True - # Topics should be appended to discovery state - assert session._discovery_state.has_items - - def test_no_parseable_sections_records_decision( - self, - mock_agent_context, - mock_registry, - ): - """When AI response has no parseable sections, seed_context is saved as decision.""" - mock_agent_context.ai_provider.chat.return_value = _make_response( - "The new information is already covered by existing topics." - ) - session = DiscoverySession(mock_agent_context, mock_registry) - - result = session._handle_incremental_context( - seed_context="Use Redis for caching", - artifacts="", - artifact_images=None, - _print=lambda x: None, - use_styled=False, - status_fn=None, - ) - assert result is False - decisions = session._discovery_state.state["decisions"] - assert "Use Redis for caching" in decisions - - -# ====================================================================== -# add_confirmed_decision deduplication -# ====================================================================== - - -class TestAddConfirmedDecisionDedup: - """Test that add_confirmed_decision deduplicates.""" - - def test_same_decision_not_duplicated(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - - ds.add_confirmed_decision("Use Redis for caching") - ds.add_confirmed_decision("Use Redis for caching") - ds.add_confirmed_decision("Use Redis for caching") - - assert ds.state["decisions"].count("Use Redis for caching") == 1 - - def test_different_decisions_both_stored(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - - ds.add_confirmed_decision("Use Redis") - ds.add_confirmed_decision("Use PostgreSQL") - - assert "Use Redis" in ds.state["decisions"] - assert "Use PostgreSQL" in ds.state["decisions"] - assert len(ds.state["decisions"]) == 2 - - def test_empty_string_not_stored(self, tmp_path): - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_path)) - ds.load() - - ds.add_confirmed_decision("") - assert len(ds.state["decisions"]) == 0 - - -# ====================================================================== -# topic_at_exchange — overlapping exchanges -# ====================================================================== - - -class TestTopicAtExchangeOverlapping: - """Test topic_at_exchange with overlapping and edge case exchange ranges.""" - - def test_overlapping_exchange_numbers(self, tmp_path): - """When multiple topics have the same answer_exchange, first by sort wins.""" - from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), - TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=2), - TrackedItem(heading="Scale", detail="Q?", kind="topic", status="answered", answer_exchange=5), - ] - ) - - # Exchange 2 maps to the first answered topic with answer_exchange >= 2 - result = ds.topic_at_exchange(2) - assert result in ("Auth", "Data") # Either is valid — both have exchange 2 - - def test_exchange_between_topics(self, tmp_path): - """Exchange number between two answer_exchanges maps to the later topic.""" - from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), - TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=5), - ] - ) - - # Exchange 3 is after Auth (2) but before Data (5) → Data - assert ds.topic_at_exchange(3) == "Data" - - def test_exchange_zero(self, tmp_path): - """Exchange 0 should return the first topic.""" - from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), - ] - ) - - assert ds.topic_at_exchange(0) == "Auth" - - def test_exchange_beyond_all_returns_none(self, tmp_path): - """Exchange after all answer_exchanges returns None (free-form).""" - from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), - TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=5), - ] - ) - - assert ds.topic_at_exchange(10) is None - - def test_single_topic_covers_all_earlier_exchanges(self, tmp_path): - """A single answered topic covers all exchanges up to its answer_exchange.""" - from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=5), - ] - ) - - assert ds.topic_at_exchange(1) == "Auth" - assert ds.topic_at_exchange(3) == "Auth" - assert ds.topic_at_exchange(5) == "Auth" - assert ds.topic_at_exchange(6) is None - - def test_mixed_answered_and_pending(self, tmp_path): - """Pending topics (no answer_exchange) don't appear in results.""" - from azext_prototype.stages.discovery_state import DiscoveryState, TrackedItem - - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.set_items( - [ - TrackedItem(heading="Auth", detail="Q?", kind="topic", status="answered", answer_exchange=2), - TrackedItem(heading="Pending Topic", detail="Q?", kind="topic", status="pending", answer_exchange=None), - TrackedItem(heading="Data", detail="Q?", kind="topic", status="answered", answer_exchange=5), - ] - ) - - assert ds.topic_at_exchange(1) == "Auth" - assert ds.topic_at_exchange(3) == "Data" - assert ds.topic_at_exchange(6) is None diff --git a/tests/test_discovery_state_scope.py b/tests/test_discovery_state_scope.py deleted file mode 100644 index eeeab8c..0000000 --- a/tests/test_discovery_state_scope.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Tests for discovery_state scope management.""" - -from azext_prototype.stages.discovery_state import ( - DiscoveryState, - _default_discovery_state, -) - - -class TestDiscoveryStateScope: - """Test the scope fields in DiscoveryState.""" - - def test_default_state_has_scope(self): - state = _default_discovery_state() - assert "scope" in state - assert state["scope"] == { - "in_scope": [], - "out_of_scope": [], - "deferred": [], - } - - def test_merge_learnings_with_scope(self, tmp_path): - ds = DiscoveryState(str(tmp_path)) - ds.load() - - learnings = { - "scope": { - "in_scope": ["REST API", "SQL Database"], - "out_of_scope": ["Mobile app"], - "deferred": ["CI/CD pipeline"], - }, - } - ds.merge_learnings(learnings) - - assert ds.state["scope"]["in_scope"] == ["REST API", "SQL Database"] - assert ds.state["scope"]["out_of_scope"] == ["Mobile app"] - assert ds.state["scope"]["deferred"] == ["CI/CD pipeline"] - - def test_merge_learnings_deduplicates_scope(self, tmp_path): - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.state["scope"]["in_scope"] = ["REST API"] - - learnings = { - "scope": { - "in_scope": ["REST API", "SQL Database"], - }, - } - ds.merge_learnings(learnings) - - assert ds.state["scope"]["in_scope"] == ["REST API", "SQL Database"] - - def test_merge_learnings_partial_scope(self, tmp_path): - ds = DiscoveryState(str(tmp_path)) - ds.load() - - learnings = { - "scope": { - "in_scope": ["API endpoints"], - }, - } - ds.merge_learnings(learnings) - - assert ds.state["scope"]["in_scope"] == ["API endpoints"] - assert ds.state["scope"]["out_of_scope"] == [] - assert ds.state["scope"]["deferred"] == [] - - def test_merge_learnings_without_scope(self, tmp_path): - """Learnings without scope should not break merge.""" - ds = DiscoveryState(str(tmp_path)) - ds.load() - - learnings = { - "project": {"summary": "Test", "goals": ["Goal 1"]}, - } - ds.merge_learnings(learnings) - - assert ds.state["scope"]["in_scope"] == [] - - def test_format_as_context_includes_scope(self, tmp_path): - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds._loaded = True - ds.state["scope"] = { - "in_scope": ["REST API"], - "out_of_scope": ["Mobile app"], - "deferred": ["CI/CD"], - } - - context = ds.format_as_context() - assert "## Prototype Scope" in context - assert "### In Scope" in context - assert "REST API" in context - assert "### Out of Scope" in context - assert "Mobile app" in context - assert "### Deferred / Future Work" in context - assert "CI/CD" in context - - def test_format_as_context_partial_scope(self, tmp_path): - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds._loaded = True - ds.state["scope"]["in_scope"] = ["REST API"] - - context = ds.format_as_context() - assert "### In Scope" in context - assert "### Out of Scope" not in context - assert "### Deferred" not in context - - def test_format_as_context_omits_empty_scope(self, tmp_path): - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds._loaded = True - ds.state["project"]["summary"] = "Test project" - - context = ds.format_as_context() - assert "Prototype Scope" not in context - - def test_format_as_context_falls_back_to_conversation(self, tmp_path): - """When structured fields are empty, format_as_context uses conversation history.""" - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds._loaded = True - # Structured fields are all empty (default), but conversation has content - ds.state["conversation_history"] = [ - {"exchange": 1, "assistant": "Tell me more."}, - { - "exchange": 2, - "assistant": ( - "## Project Summary\nA web app for email drafting.\n\n" - "## Confirmed Functional Requirements\n- Feature A\n\n" - "[READY]" - ), - }, - ] - - context = ds.format_as_context() - assert "## Project Summary" in context - assert "email drafting" in context - assert "Feature A" in context - assert "[READY]" not in context - - def test_format_as_context_prefers_structured_fields(self, tmp_path): - """When structured fields are populated, those are used instead of conversation.""" - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds._loaded = True - ds.state["project"]["summary"] = "Structured summary" - ds.state["conversation_history"] = [ - { - "exchange": 1, - "assistant": "## Project Summary\nConversation summary.\n\n## Confirmed Functional Requirements\n- X", - }, - ] - - context = ds.format_as_context() - assert "Structured summary" in context - assert "Conversation summary" not in context - - def test_extract_conversation_summary(self, tmp_path): - """extract_conversation_summary returns last assistant message with summary headings.""" - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.state["conversation_history"] = [ - {"exchange": 1, "assistant": "Tell me more."}, - { - "exchange": 2, - "assistant": "## Project Summary\nA web app.\n\n[READY]", - }, - ] - - result = ds.extract_conversation_summary() - assert "## Project Summary" in result - assert "[READY]" not in result - - def test_extract_conversation_summary_empty_history(self, tmp_path): - ds = DiscoveryState(str(tmp_path)) - ds.load() - - assert ds.extract_conversation_summary() == "" - - def test_scope_persists_to_yaml(self, tmp_path): - ds = DiscoveryState(str(tmp_path)) - ds.load() - ds.state["scope"]["in_scope"] = ["API endpoints"] - ds.state["scope"]["out_of_scope"] = ["Mobile app"] - ds.save() - - ds2 = DiscoveryState(str(tmp_path)) - ds2.load() - assert ds2.state["scope"]["in_scope"] == ["API endpoints"] - assert ds2.state["scope"]["out_of_scope"] == ["Mobile app"] - assert ds2.state["scope"]["deferred"] == [] diff --git a/tests/test_escalation.py b/tests/test_escalation.py deleted file mode 100644 index 369b43b..0000000 --- a/tests/test_escalation.py +++ /dev/null @@ -1,636 +0,0 @@ -"""Tests for azext_prototype.stages.escalation — blocker tracking and escalation chain. - -Covers: -- EscalationEntry serialization and defaults -- EscalationTracker state management (record, attempt, resolve, save/load) -- Escalation chain (level 1→2 technical, 1→2 scope, 2→3 web, 3→4 human) -- Auto-escalation timing -- Integration with qa_router -- Edge cases -- Report formatting -- State persistence across sessions -""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from pathlib import Path -from unittest.mock import MagicMock, patch - -import yaml - -from azext_prototype.stages.escalation import EscalationEntry, EscalationTracker - -# ====================================================================== -# Helpers -# ====================================================================== - - -def _make_entry(**kwargs) -> EscalationEntry: - defaults = { - "task_description": "Build Stage 3: Data Layer", - "blocker": "Cosmos DB requires premium tier", - "source_agent": "terraform-agent", - "source_stage": "build", - "created_at": datetime.now(timezone.utc).isoformat(), - "last_escalated_at": datetime.now(timezone.utc).isoformat(), - } - defaults.update(kwargs) - return EscalationEntry(**defaults) - - -def _make_registry(architect_response=None, pm_response=None): - from azext_prototype.agents.base import AgentCapability - - architect = MagicMock() - architect.name = "cloud-architect" - if architect_response: - architect.execute.return_value = architect_response - else: - architect.execute.return_value = MagicMock(content="Use Standard tier instead") - - pm = MagicMock() - pm.name = "project-manager" - if pm_response: - pm.execute.return_value = pm_response - else: - pm.execute.return_value = MagicMock(content="Descope this item") - - registry = MagicMock() - - def find_by_cap(cap): - if cap == AgentCapability.ARCHITECT: - return [architect] - if cap == AgentCapability.BACKLOG_GENERATION: - return [pm] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - return registry, architect, pm - - -def _make_context(): - from azext_prototype.agents.base import AgentContext - - return AgentContext( - project_config={"project": {"name": "test"}}, - project_dir="/tmp/test", - ai_provider=MagicMock(), - ) - - -# ====================================================================== -# EscalationEntry tests -# ====================================================================== - - -class TestEscalationEntry: - - def test_default_values(self): - entry = EscalationEntry(task_description="task", blocker="blocked") - assert entry.escalation_level == 1 - assert entry.resolved is False - assert entry.resolution == "" - assert entry.attempted_solutions == [] - - def test_to_dict_roundtrip(self): - entry = _make_entry(attempted_solutions=["Try A", "Try B"]) - d = entry.to_dict() - restored = EscalationEntry.from_dict(d) - - assert restored.task_description == entry.task_description - assert restored.blocker == entry.blocker - assert restored.attempted_solutions == ["Try A", "Try B"] - assert restored.escalation_level == entry.escalation_level - assert restored.source_agent == entry.source_agent - - def test_from_dict_missing_keys(self): - entry = EscalationEntry.from_dict({}) - assert entry.task_description == "" - assert entry.blocker == "" - assert entry.escalation_level == 1 - - -# ====================================================================== -# EscalationTracker state management tests -# ====================================================================== - - -class TestEscalationTrackerState: - - def test_record_blocker(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - - entry = tracker.record_blocker( - "Deploy Redis", - "Premium tier required", - "terraform-agent", - "deploy", - ) - - assert entry.task_description == "Deploy Redis" - assert entry.blocker == "Premium tier required" - assert entry.escalation_level == 1 - assert entry.created_at != "" - assert len(tracker.get_active_blockers()) == 1 - - def test_record_attempted_solution(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - - tracker.record_attempted_solution(entry, "Tried standard tier") - tracker.record_attempted_solution(entry, "Tried basic tier") - - assert len(entry.attempted_solutions) == 2 - assert "Tried standard tier" in entry.attempted_solutions - - def test_resolve_blocker(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - - tracker.resolve(entry, "Used standard tier instead") - - assert entry.resolved is True - assert entry.resolution == "Used standard tier instead" - assert len(tracker.get_active_blockers()) == 0 - - def test_get_active_blockers_filters_resolved(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - e1 = tracker.record_blocker("task1", "blocked1", "a1", "s1") - e2 = tracker.record_blocker("task2", "blocked2", "a2", "s2") # noqa: F841 - tracker.resolve(e1, "fixed") - - active = tracker.get_active_blockers() - assert len(active) == 1 - assert active[0].task_description == "task2" - - def test_save_load_roundtrip(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - tracker.record_blocker("task1", "blocked1", "agent1", "stage1") - tracker.record_blocker("task2", "blocked2", "agent2", "stage2") - - tracker2 = EscalationTracker(str(tmp_project)) - tracker2.load() - - assert len(tracker2.get_active_blockers()) == 2 - assert tracker2.get_active_blockers()[0].task_description == "task1" - - def test_save_creates_yaml(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - tracker.record_blocker("task", "blocked", "agent", "stage") - - yaml_path = Path(str(tmp_project)) / ".prototype" / "state" / "escalation.yaml" - assert yaml_path.exists() - - with open(yaml_path) as f: - data = yaml.safe_load(f) - assert len(data["entries"]) == 1 - - def test_exists_property(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - assert not tracker.exists - - tracker.record_blocker("task", "blocked", "agent", "stage") - assert tracker.exists - - def test_empty_load(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - tracker.load() # No file exists - assert tracker.get_active_blockers() == [] - - def test_multiple_records_and_resolves(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - e1 = tracker.record_blocker("t1", "b1", "a", "s") - e2 = tracker.record_blocker("t2", "b2", "a", "s") # noqa: F841 - e3 = tracker.record_blocker("t3", "b3", "a", "s") - - tracker.resolve(e1, "fixed") - tracker.resolve(e3, "workaround") - - assert len(tracker.get_active_blockers()) == 1 - assert tracker.get_active_blockers()[0].task_description == "t2" - - -# ====================================================================== -# Escalation chain tests -# ====================================================================== - - -class TestEscalationChain: - - def test_level_1_to_2_technical(self, tmp_project): - """Technical blocker escalates to architect.""" - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker( - "Deploy Cosmos DB", - "Premium tier required for multi-region", - "terraform-agent", - "build", - ) - - registry, architect, pm = _make_registry() - ctx = _make_context() - printed = [] - - result = tracker.escalate(entry, registry, ctx, printed.append) - - assert result["escalated"] is True - assert result["level"] == 2 - assert entry.escalation_level == 2 - architect.execute.assert_called_once() - pm.execute.assert_not_called() - - def test_level_1_to_2_scope(self, tmp_project): - """Scope blocker escalates to project-manager.""" - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker( - "Backlog items", - "Scope of feature is unclear", - "biz-analyst", - "design", - ) - - registry, architect, pm = _make_registry() - ctx = _make_context() - printed = [] - - result = tracker.escalate(entry, registry, ctx, printed.append) - - assert result["escalated"] is True - assert result["level"] == 2 - pm.execute.assert_called_once() - architect.execute.assert_not_called() - - @patch("azext_prototype.stages.escalation.EscalationTracker._escalate_to_web_search") - def test_level_2_to_3_web_search(self, mock_web, tmp_project): - """Level 2→3 triggers web search.""" - mock_web.return_value = "Found: Azure docs suggest..." - - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - entry.escalation_level = 2 # Already at level 2 - - registry, _, _ = _make_registry() - ctx = _make_context() - printed = [] - - result = tracker.escalate(entry, registry, ctx, printed.append) - - assert result["escalated"] is True - assert result["level"] == 3 - mock_web.assert_called_once() - - def test_level_3_to_4_human(self, tmp_project): - """Level 3→4 flags for human intervention.""" - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - entry.escalation_level = 3 # Already at level 3 - - registry, _, _ = _make_registry() - ctx = _make_context() - printed = [] - - result = tracker.escalate(entry, registry, ctx, printed.append) - - assert result["escalated"] is True - assert result["level"] == 4 - assert any("HUMAN INTERVENTION" in p for p in printed) - - def test_already_at_level_4_no_escalation(self, tmp_project): - """Cannot escalate past level 4.""" - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - entry.escalation_level = 4 - - registry, _, _ = _make_registry() - ctx = _make_context() - printed = [] - - result = tracker.escalate(entry, registry, ctx, printed.append) - - assert result["escalated"] is False - assert result["level"] == 4 - - def test_no_agent_available_for_escalation(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - - registry = MagicMock() - registry.find_by_capability.return_value = [] - ctx = _make_context() - printed = [] - - result = tracker.escalate(entry, registry, ctx, printed.append) - - assert result["level"] == 2 - assert "No cloud-architect available" in result["content"] - - def test_agent_escalation_failure(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - - registry, architect, _ = _make_registry() - architect.execute.side_effect = RuntimeError("AI crashed") - ctx = _make_context() - printed = [] - - result = tracker.escalate(entry, registry, ctx, printed.append) - - assert result["level"] == 2 - assert "failed" in result["content"].lower() - - def test_web_search_failure_graceful(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - entry.escalation_level = 2 - - printed = [] - - with patch("azext_prototype.stages.escalation.EscalationTracker._escalate_to_web_search") as mock_ws: - mock_ws.return_value = "Web search failed: connection error" - - registry, _, _ = _make_registry() - ctx = _make_context() - result = tracker.escalate(entry, registry, ctx, printed.append) - - assert result["level"] == 3 - assert "failed" in result["content"].lower() - - -# ====================================================================== -# Auto-escalation tests -# ====================================================================== - - -class TestAutoEscalation: - - def test_timeout_triggers_escalation(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - - # Set last_escalated_at to 5 minutes ago - old_time = datetime.now(timezone.utc) - timedelta(minutes=5) - entry.last_escalated_at = old_time.isoformat() - - assert tracker.should_auto_escalate(entry, timeout_seconds=120) - - def test_not_yet_timed_out(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - - # Just created, so not timed out - assert not tracker.should_auto_escalate(entry, timeout_seconds=120) - - def test_resolved_stops_escalation(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - tracker.resolve(entry, "fixed") - - old_time = datetime.now(timezone.utc) - timedelta(minutes=5) - entry.last_escalated_at = old_time.isoformat() - - assert not tracker.should_auto_escalate(entry) - - def test_level_4_stops_escalation(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - entry.escalation_level = 4 - - old_time = datetime.now(timezone.utc) - timedelta(minutes=5) - entry.last_escalated_at = old_time.isoformat() - - assert not tracker.should_auto_escalate(entry) - - def test_invalid_timestamp_returns_false(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - entry = tracker.record_blocker("task", "blocked", "agent", "stage") - entry.last_escalated_at = "not-a-timestamp" - - assert not tracker.should_auto_escalate(entry) - - -# ====================================================================== -# Integration with qa_router -# ====================================================================== - - -class TestQARouterIntegration: - - def test_qa_router_records_blocker_on_undiagnosed(self, tmp_project): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.stages.qa_router import route_error_to_qa - - tracker = EscalationTracker(str(tmp_project)) - - # QA returns empty — undiagnosed - qa = MagicMock() - qa.execute.return_value = AIResponse(content="", model="gpt-4o", usage={}) - - ctx = _make_context() - - result = route_error_to_qa( - "Deployment failed", - "Deploy Stage 1", - qa, - ctx, - None, - lambda m: None, - escalation_tracker=tracker, - source_agent="terraform-agent", - source_stage="deploy", - ) - - assert result["diagnosed"] is False - assert len(tracker.get_active_blockers()) == 1 - blocker = tracker.get_active_blockers()[0] - assert blocker.source_agent == "terraform-agent" - assert blocker.source_stage == "deploy" - - def test_qa_router_no_tracker_no_error(self, tmp_project): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.stages.qa_router import route_error_to_qa - - qa = MagicMock() - qa.execute.return_value = AIResponse(content="", model="gpt-4o", usage={}) - - ctx = _make_context() - - # No escalation tracker — should not raise - result = route_error_to_qa( - "error", - "context", - qa, - ctx, - None, - lambda m: None, - escalation_tracker=None, - ) - - assert result["diagnosed"] is False - - @patch("azext_prototype.stages.qa_router._submit_knowledge") - def test_qa_router_diagnosed_no_blocker(self, mock_knowledge, tmp_project): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.stages.qa_router import route_error_to_qa - - tracker = EscalationTracker(str(tmp_project)) - - qa = MagicMock() - qa.execute.return_value = AIResponse(content="Root cause: X", model="gpt-4o", usage={}) - - ctx = _make_context() - - result = route_error_to_qa( - "error", - "context", - qa, - ctx, - None, - lambda m: None, - escalation_tracker=tracker, - ) - - assert result["diagnosed"] is True - # No blocker should be recorded when QA diagnoses successfully - assert len(tracker.get_active_blockers()) == 0 - - def test_build_session_has_escalation_tracker(self, tmp_project): - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.build_session import BuildSession - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - registry = MagicMock() - registry.find_by_capability.return_value = [] - - with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": "terraform", - "project.name": "test", - }.get(k, d) - mock_config.return_value.to_dict.return_value = { - "naming": {"strategy": "simple"}, - "project": {"name": "test"}, - } - session = BuildSession(ctx, registry) - - assert hasattr(session, "_escalation_tracker") - assert isinstance(session._escalation_tracker, EscalationTracker) - - def test_deploy_session_has_escalation_tracker(self, tmp_project): - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.deploy_session import DeploySession - from azext_prototype.stages.deploy_state import DeployState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - registry = MagicMock() - registry.find_by_capability.return_value = [] - - with patch("azext_prototype.stages.deploy_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": "terraform", - }.get(k, d) - session = DeploySession(ctx, registry, deploy_state=DeployState(str(tmp_project))) - - assert hasattr(session, "_escalation_tracker") - assert isinstance(session._escalation_tracker, EscalationTracker) - - def test_backlog_session_has_escalation_tracker(self, tmp_project): - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.backlog_session import BacklogSession - from azext_prototype.stages.backlog_state import BacklogState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - registry = MagicMock() - registry.find_by_capability.return_value = [] - - session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) - - assert hasattr(session, "_escalation_tracker") - assert isinstance(session._escalation_tracker, EscalationTracker) - - -# ====================================================================== -# Report formatting tests -# ====================================================================== - - -class TestReportFormatting: - - def test_empty_report(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - report = tracker.format_escalation_report() - assert "No blockers recorded" in report - - def test_report_with_active_and_resolved(self, tmp_project): - tracker = EscalationTracker(str(tmp_project)) - e1 = tracker.record_blocker("Deploy Redis", "Premium needed", "tf", "build") # noqa: F841 - e2 = tracker.record_blocker("Deploy Cosmos", "Multi-region", "tf", "build") - tracker.resolve(e2, "Used single region") - - report = tracker.format_escalation_report() - - assert "Active Blockers (1)" in report - assert "Deploy Redis" in report - assert "Resolved (1)" in report - assert "Used single region" in report - - -# ====================================================================== -# State persistence across sessions -# ====================================================================== - - -class TestStatePersistence: - - def test_state_survives_session_restart(self, tmp_project): - tracker1 = EscalationTracker(str(tmp_project)) - tracker1.record_blocker("task1", "b1", "a1", "s1") - e2 = tracker1.record_blocker("task2", "b2", "a2", "s2") - tracker1.record_attempted_solution(e2, "Tried A") - tracker1.resolve(e2, "Used workaround B") - - # Simulate session restart - tracker2 = EscalationTracker(str(tmp_project)) - tracker2.load() - - assert len(tracker2.get_active_blockers()) == 1 - assert tracker2.get_active_blockers()[0].task_description == "task1" - - # Check resolved entry - all_entries = tracker2._entries - resolved = [e for e in all_entries if e.resolved] - assert len(resolved) == 1 - assert resolved[0].resolution == "Used workaround B" - assert resolved[0].attempted_solutions == ["Tried A"] - - def test_escalation_level_persists(self, tmp_project): - tracker1 = EscalationTracker(str(tmp_project)) - entry = tracker1.record_blocker("task", "blocked", "agent", "stage") - - registry, _, _ = _make_registry() - ctx = _make_context() - tracker1.escalate(entry, registry, ctx, lambda m: None) - - # Simulate restart - tracker2 = EscalationTracker(str(tmp_project)) - tracker2.load() - - assert tracker2.get_active_blockers()[0].escalation_level == 2 diff --git a/tests/test_generate_backlog.py b/tests/test_generate_backlog.py deleted file mode 100644 index 6f07d14..0000000 --- a/tests/test_generate_backlog.py +++ /dev/null @@ -1,2399 +0,0 @@ -"""Tests for backlog generation — BacklogState, BacklogSession, push helpers, scope injection. - -Keeps the new backlog tests separate from test_custom.py to prevent file bloat. -""" - -import json -from unittest.mock import MagicMock, patch - -import pytest -import yaml -from knack.util import CLIError - -_CUSTOM_MODULE = "azext_prototype.custom" - - -# ====================================================================== -# BacklogState Tests -# ====================================================================== - - -class TestBacklogState: - """Test BacklogState YAML persistence.""" - - def test_default_structure(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - assert state.state["items"] == [] - assert state.state["provider"] == "" - assert state.state["push_status"] == [] - assert state.state["context_hash"] == "" - assert state.state["conversation_history"] == [] - - def test_save_and_load_round_trip(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state._state["provider"] = "github" - state._state["org"] = "myorg" - state._state["project"] = "myrepo" - state.save() - - assert state.exists - - state2 = BacklogState(str(tmp_project)) - loaded = state2.load() - assert loaded["provider"] == "github" - assert loaded["org"] == "myorg" - assert loaded["project"] == "myrepo" - assert loaded["_metadata"]["created"] is not None - - def test_set_items(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - items = [ - {"epic": "Infra", "title": "VNet", "effort": "M", "tasks": []}, - {"epic": "Infra", "title": "KeyVault", "effort": "S", "tasks": []}, - ] - state.set_items(items) - - assert len(state.state["items"]) == 2 - assert state.state["push_status"] == ["pending", "pending"] - assert state.state["push_results"] == [None, None] - assert state.exists - - def test_mark_item_pushed(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state.set_items([{"title": "Item1"}, {"title": "Item2"}]) - - state.mark_item_pushed(0, "https://github.com/org/repo/issues/1") - - assert state.state["push_status"][0] == "pushed" - assert state.state["push_results"][0] == "https://github.com/org/repo/issues/1" - assert state.state["_metadata"]["last_pushed"] is not None - - def test_mark_item_failed(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state.set_items([{"title": "Item1"}]) - - state.mark_item_failed(0, "gh: command failed") - - assert state.state["push_status"][0] == "failed" - assert "gh: command failed" in state.state["push_results"][0] - - def test_get_pending_items(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state.set_items([{"title": "A"}, {"title": "B"}, {"title": "C"}]) - state.mark_item_pushed(1, "url") - - pending = state.get_pending_items() - assert len(pending) == 2 - assert pending[0][0] == 0 # idx - assert pending[1][0] == 2 - - def test_get_pushed_items(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state.set_items([{"title": "A"}, {"title": "B"}]) - state.mark_item_pushed(0, "url1") - state.mark_item_pushed(1, "url2") - - pushed = state.get_pushed_items() - assert len(pushed) == 2 - - def test_context_hash(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - context = "Some architecture design" - scope = {"in_scope": ["API"], "out_of_scope": [], "deferred": []} - - state.set_context_hash(context, scope) - assert state.matches_context(context, scope) - assert not state.matches_context("Different context", scope) - - def test_format_backlog_summary(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state.set_items( - [ - {"epic": "Infra", "title": "VNet Setup", "effort": "M", "tasks": ["T1"]}, - {"epic": "App", "title": "API Gateway", "effort": "L", "tasks": ["T2"]}, - ] - ) - - summary = state.format_backlog_summary() - assert "2 item(s)" in summary - assert "VNet Setup" in summary - assert "API Gateway" in summary - assert "Infra" in summary - assert "App" in summary - - def test_format_item_detail(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state.set_items( - [ - { - "epic": "Infra", - "title": "VNet Setup", - "description": "Configure virtual network", - "acceptance_criteria": ["AC1: VNet created"], - "tasks": ["Create VNet", "Create Subnets"], - "effort": "M", - } - ] - ) - - detail = state.format_item_detail(0) - assert "VNet Setup" in detail - assert "Configure virtual network" in detail - assert "AC1: VNet created" in detail - assert "Create VNet" in detail - - def test_reset(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state.set_items([{"title": "Item1"}]) - assert len(state.state["items"]) == 1 - - state.reset() - assert state.state["items"] == [] - assert state.exists # File still exists (reset saves) - - def test_update_from_exchange(self, tmp_project): - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state.update_from_exchange("add a story", "Added", 1) - - assert len(state.state["conversation_history"]) == 1 - assert state.state["conversation_history"][0]["user"] == "add a story" - - -# ====================================================================== -# Backlog Push Helper Tests -# ====================================================================== - - -class TestBacklogPushHelpers: - """Test GitHub/DevOps push helper functions.""" - - def test_check_gh_auth_pass(self): - from azext_prototype.stages.backlog_push import check_gh_auth - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - assert check_gh_auth() is True - - def test_check_gh_auth_fail(self): - from azext_prototype.stages.backlog_push import check_gh_auth - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=1) - assert check_gh_auth() is False - - def test_check_gh_auth_not_installed(self): - from azext_prototype.stages.backlog_push import check_gh_auth - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError - assert check_gh_auth() is False - - def test_check_devops_ext_pass(self): - from azext_prototype.stages.backlog_push import check_devops_ext - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - assert check_devops_ext() is True - - def test_check_devops_ext_fail(self): - from azext_prototype.stages.backlog_push import check_devops_ext - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=1) - assert check_devops_ext() is False - - def test_format_github_body(self): - from azext_prototype.stages.backlog_push import format_github_body - - item = { - "epic": "Infra", - "title": "VNet Setup", - "description": "Configure VNet", - "acceptance_criteria": ["VNet exists", "Subnets configured"], - "tasks": ["Create VNet", "Create Subnets"], - "effort": "M", - } - body = format_github_body(item) - assert "## Description" in body - assert "Configure VNet" in body - assert "## Acceptance Criteria" in body - assert "- [ ] Create VNet" in body - assert "`effort/M`" in body - assert "`infra`" in body - - def test_format_devops_description(self): - from azext_prototype.stages.backlog_push import format_devops_description - - item = { - "description": "Configure VNet", - "acceptance_criteria": ["VNet exists"], - "tasks": ["Create VNet"], - "effort": "M", - } - desc = format_devops_description(item) - assert "

    Configure VNet

    " in desc - assert "
  • VNet exists
  • " in desc - assert "
  • Create VNet
  • " in desc - assert "Effort" in desc - - def test_push_github_issue_success(self): - from azext_prototype.stages.backlog_push import push_github_issue - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=0, - stdout="https://github.com/myorg/myrepo/issues/42\n", - ) - - result = push_github_issue( - "myorg", - "myrepo", - {"epic": "Infra", "title": "VNet", "description": "desc", "effort": "M"}, - ) - assert result["url"] == "https://github.com/myorg/myrepo/issues/42" - assert result["number"] == "42" - - def test_push_github_issue_failure(self): - from azext_prototype.stages.backlog_push import push_github_issue - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=1, - stderr="authentication failed", - stdout="", - ) - - result = push_github_issue( - "myorg", - "myrepo", - {"title": "VNet"}, - ) - assert "error" in result - assert "authentication" in result["error"] - - def test_push_devops_feature_success(self): - from azext_prototype.stages.backlog_push import push_devops_feature - - devops_response = json.dumps( - { - "id": 123, - "_links": {"html": {"href": "https://dev.azure.com/org/proj/_workitems/edit/123"}}, - } - ) - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=0, - stdout=devops_response, - ) - - result = push_devops_feature( - "myorg", - "myproj", - {"title": "VNet", "description": "desc"}, - ) - assert result["id"] == 123 - assert "dev.azure.com" in result["url"] - - def test_push_devops_feature_failure(self): - from azext_prototype.stages.backlog_push import push_devops_feature - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=1, - stderr="project not found", - stdout="", - ) - - result = push_devops_feature("myorg", "myproj", {"title": "VNet"}) - assert "error" in result - - # --- Lines 48-49: check_devops_ext FileNotFoundError --- - - def test_check_devops_ext_not_installed(self): - from azext_prototype.stages.backlog_push import check_devops_ext - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError - assert check_devops_ext() is False - - # --- Lines 83-84: format_github_body with dict tasks --- - - def test_format_github_body_dict_tasks(self): - from azext_prototype.stages.backlog_push import format_github_body - - item = { - "description": "desc", - "tasks": [ - {"title": "Done task", "done": True}, - {"title": "Open task", "done": False}, - ], - } - body = format_github_body(item) - assert "- [x] Done task" in body - assert "- [ ] Open task" in body - - # --- Lines 92-114: format_github_body with children --- - - def test_format_github_body_children(self): - from azext_prototype.stages.backlog_push import format_github_body - - item = { - "description": "Parent", - "children": [ - { - "title": "Child Story", - "effort": "S", - "description": "Child desc", - "acceptance_criteria": ["AC1"], - "tasks": [ - {"title": "Sub done", "done": True}, - "Sub open", - ], - }, - ], - } - body = format_github_body(item) - assert "## Stories" in body - assert "### Child Story [S]" in body - assert "Child desc" in body - assert "1. AC1" in body - assert "- [x] Sub done" in body - assert "- [ ] Sub open" in body - - # --- Lines 150-153: format_devops_description with dict tasks --- - - def test_format_devops_description_dict_tasks(self): - from azext_prototype.stages.backlog_push import format_devops_description - - item = { - "tasks": [ - {"title": "Done", "done": True}, - {"title": "Open", "done": False}, - ], - } - desc = format_devops_description(item) - assert "☑ Done" in desc - assert "☐ Open" in desc - - # --- Lines 230-231: push_github_issue FileNotFoundError --- - - def test_push_github_issue_not_installed(self): - from azext_prototype.stages.backlog_push import push_github_issue - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError - result = push_github_issue("org", "repo", {"title": "T"}) - assert "error" in result - assert "gh CLI not found" in result["error"] - - # --- Lines 261, 280: push_devops_story / push_devops_task --- - - def test_push_devops_story_success(self): - from azext_prototype.stages.backlog_push import push_devops_story - - resp = json.dumps( - { - "id": 200, - "_links": {"html": {"href": "https://dev.azure.com/o/p/_workitems/edit/200"}}, - } - ) - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout=resp) - result = push_devops_story("o", "p", {"title": "Story"}, parent_id=100) - assert result["id"] == 200 - - def test_push_devops_task_success(self): - from azext_prototype.stages.backlog_push import push_devops_task - - resp = json.dumps( - { - "id": 300, - "_links": {"html": {"href": "https://dev.azure.com/o/p/_workitems/edit/300"}}, - } - ) - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout=resp) - result = push_devops_task("o", "p", {"title": "Task"}, parent_id=200) - assert result["id"] == 300 - - # --- Line 326: _push_devops_work_item with epic (area path) --- - - def test_push_devops_feature_with_epic_area(self): - from azext_prototype.stages.backlog_push import push_devops_feature - - resp = json.dumps( - { - "id": 10, - "_links": {"html": {"href": "https://dev.azure.com/o/p/_workitems/edit/10"}}, - } - ) - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout=resp) - result = push_devops_feature("o", "p", {"title": "T", "epic": "Infra"}) - assert result["id"] == 10 - cmd_args = mock_run.call_args[0][0] - assert "--area" in cmd_args - assert "p\\Infra" in cmd_args - - # --- Line 350: url fallback to data["url"] --- - - def test_push_devops_url_fallback(self): - from azext_prototype.stages.backlog_push import push_devops_feature - - resp = json.dumps({"id": 50, "url": "https://fallback-url", "_links": {}}) - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout=resp) - result = push_devops_feature("o", "p", {"title": "T"}) - assert result["url"] == "https://fallback-url" - - # --- Line 354: parent linking path --- - - def test_push_devops_story_calls_link_parent(self): - from azext_prototype.stages.backlog_push import push_devops_story - - resp = json.dumps( - { - "id": 77, - "_links": {"html": {"href": "https://dev.azure.com/o/p/_workitems/edit/77"}}, - } - ) - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout=resp) - result = push_devops_story("o", "p", {"title": "S"}, parent_id=10) - assert result["id"] == 77 - # Second call should be the _link_parent relation add - assert mock_run.call_count == 2 - link_cmd = mock_run.call_args_list[1][0][0] - assert "relation" in link_cmd - assert "parent" in link_cmd - - # --- Lines 357-358: JSONDecodeError fallback --- - - def test_push_devops_json_decode_error(self): - from azext_prototype.stages.backlog_push import push_devops_feature - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="not-json-output") - result = push_devops_feature("o", "p", {"title": "T"}) - assert result["url"] == "" - assert result["id"] == "not-json-output" - - # --- Lines 360-361: _push_devops_work_item FileNotFoundError --- - - def test_push_devops_feature_not_installed(self): - from azext_prototype.stages.backlog_push import push_devops_feature - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError - result = push_devops_feature("o", "p", {"title": "T"}) - assert "error" in result - assert "az CLI not found" in result["error"] - - # --- Lines 366-388: _link_parent error handling --- - - def test_link_parent_file_not_found(self): - from azext_prototype.stages.backlog_push import _link_parent - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError - # Should not raise — just logs a warning - _link_parent("o", "p", 10, 5) - - def test_link_parent_subprocess_error(self): - import subprocess as sp - - from azext_prototype.stages.backlog_push import _link_parent - - with patch("azext_prototype.stages.backlog_push.subprocess.run") as mock_run: - mock_run.side_effect = sp.SubprocessError("fail") - _link_parent("o", "p", 10, 5) - - -# ====================================================================== -# BacklogSession Tests -# ====================================================================== - - -class TestBacklogSession: - """Test the interactive backlog session.""" - - def _make_session(self, project_dir, mock_ai_provider, items_response="[]"): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.builtin import register_all_builtin - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.ai.provider import AIResponse - from azext_prototype.stages.backlog_session import BacklogSession - from azext_prototype.stages.backlog_state import BacklogState - - mock_ai_provider.chat.return_value = AIResponse( - content=items_response, - model="test", - ) - - registry = AgentRegistry() - register_all_builtin(registry) - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(project_dir), - ai_provider=mock_ai_provider, - ) - - backlog_state = BacklogState(str(project_dir)) - session = BacklogSession( - ctx, - registry, - backlog_state=backlog_state, - ) - return session, backlog_state - - def test_generate_from_ai(self, tmp_project, mock_ai_provider): - items_json = json.dumps( - [ - { - "epic": "Infra", - "title": "VNet", - "effort": "M", - "tasks": ["T1"], - "description": "d", - "acceptance_criteria": ["AC1"], - }, - ] - ) - session, state = self._make_session(tmp_project, mock_ai_provider, items_json) - - output = [] - result = session.run( - design_context="Sample arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: "done", - print_fn=output.append, - ) - - assert result.items_generated == 1 - assert not result.cancelled - - def test_resume_from_state(self, tmp_project, mock_ai_provider): - from azext_prototype.stages.backlog_state import BacklogState - - # Pre-populate state - state = BacklogState(str(tmp_project)) - state.set_items([{"epic": "Pre", "title": "Existing", "effort": "S"}]) - state.set_context_hash("Sample arch") - - session, _ = self._make_session(tmp_project, mock_ai_provider) - session._backlog_state = state - - output = [] - result = session.run( - design_context="Sample arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: "done", - print_fn=output.append, - ) - - assert result.items_generated == 1 - # Should have used cached items - joined = "\n".join(output) - assert "cached" in joined.lower() or "resumed" in joined.lower() - - def test_slash_list(self, tmp_project, mock_ai_provider): - items_json = json.dumps( - [ - {"epic": "Infra", "title": "VNet", "effort": "M"}, - ] - ) - session, state = self._make_session(tmp_project, mock_ai_provider, items_json) - - inputs = iter(["/list", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "VNet" in joined - - def test_slash_show(self, tmp_project, mock_ai_provider): - items_json = json.dumps( - [ - { - "epic": "Infra", - "title": "VNet", - "description": "Configure virtual network", - "effort": "M", - "acceptance_criteria": ["AC1"], - "tasks": ["T1"], - }, - ] - ) - session, state = self._make_session(tmp_project, mock_ai_provider, items_json) - - inputs = iter(["/show 1", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "Configure virtual network" in joined - - def test_slash_save(self, tmp_project, mock_ai_provider): - items_json = json.dumps( - [ - { - "epic": "Infra", - "title": "VNet", - "effort": "M", - "description": "d", - "acceptance_criteria": ["AC1"], - "tasks": ["T1"], - }, - ] - ) - session, state = self._make_session(tmp_project, mock_ai_provider, items_json) - - inputs = iter(["/save", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - - backlog_md = tmp_project / "concept" / "docs" / "BACKLOG.md" - assert backlog_md.exists() - content = backlog_md.read_text() - assert "VNet" in content - - def test_slash_quit(self, tmp_project, mock_ai_provider): - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state = self._make_session(tmp_project, mock_ai_provider, items_json) - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: "/quit", - print_fn=output.append, - ) - assert result.cancelled - - def test_eof_cancels_session(self, tmp_project, mock_ai_provider): - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state = self._make_session(tmp_project, mock_ai_provider, items_json) - - def eof_input(p): - raise EOFError - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=eof_input, - print_fn=output.append, - ) - assert result.cancelled - - def test_quick_mode_cancel(self, tmp_project, mock_ai_provider): - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state = self._make_session(tmp_project, mock_ai_provider, items_json) - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - quick=True, - input_fn=lambda p: "n", - print_fn=output.append, - ) - assert result.cancelled or result.items_pushed == 0 - - def test_refresh_forces_regeneration(self, tmp_project, mock_ai_provider): - """Even with cached state, --refresh forces new AI generation.""" - from azext_prototype.ai.provider import AIResponse - from azext_prototype.stages.backlog_state import BacklogState - - state = BacklogState(str(tmp_project)) - state.set_items([{"epic": "Old", "title": "Old Item", "effort": "S"}]) - state.set_context_hash("arch") - - new_items_json = json.dumps( - [ - {"epic": "New", "title": "New Item", "effort": "M"}, - ] - ) - - # Create session first, THEN override the mock return value - # (_make_session defaults items_response="[]" which overwrites the mock) - session, _ = self._make_session(tmp_project, mock_ai_provider) - session._backlog_state = state - mock_ai_provider.chat.return_value = AIResponse(content=new_items_json, model="t") - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - refresh=True, - input_fn=lambda p: "done", - print_fn=output.append, - ) - - assert result.items_generated == 1 - assert state.state["items"][0]["title"] == "New Item" - - def test_slash_remove(self, tmp_project, mock_ai_provider): - items_json = json.dumps( - [ - {"epic": "A", "title": "Item1", "effort": "S"}, - {"epic": "A", "title": "Item2", "effort": "M"}, - ] - ) - session, state = self._make_session(tmp_project, mock_ai_provider, items_json) - - inputs = iter(["/remove 1", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - assert len(state.state["items"]) == 1 - assert state.state["items"][0]["title"] == "Item2" - - -# ====================================================================== -# Scope Injection Tests -# ====================================================================== - - -class TestScopeInjection: - """Test scope loading and injection into backlog generation.""" - - def test_load_scope_from_discovery(self, tmp_project): - from azext_prototype.custom import _load_discovery_scope - - # Create discovery state with scope - state_dir = tmp_project / ".prototype" / "state" - state_dir.mkdir(parents=True, exist_ok=True) - discovery_data = { - "scope": { - "in_scope": ["API Gateway", "Database"], - "out_of_scope": ["Mobile app"], - "deferred": ["Analytics dashboard"], - }, - "project": {"summary": ""}, - "requirements": {"functional": [], "non_functional": []}, - } - with open(state_dir / "discovery.yaml", "w") as f: - yaml.dump(discovery_data, f) - - scope = _load_discovery_scope(str(tmp_project)) - assert scope is not None - assert "API Gateway" in scope["in_scope"] - assert "Mobile app" in scope["out_of_scope"] - assert "Analytics dashboard" in scope["deferred"] - - def test_load_scope_no_discovery(self, tmp_project): - from azext_prototype.custom import _load_discovery_scope - - scope = _load_discovery_scope(str(tmp_project)) - assert scope is None - - def test_load_scope_empty_scope(self, tmp_project): - from azext_prototype.custom import _load_discovery_scope - - state_dir = tmp_project / ".prototype" / "state" - state_dir.mkdir(parents=True, exist_ok=True) - discovery_data = { - "scope": {"in_scope": [], "out_of_scope": [], "deferred": []}, - "project": {"summary": ""}, - "requirements": {"functional": [], "non_functional": []}, - } - with open(state_dir / "discovery.yaml", "w") as f: - yaml.dump(discovery_data, f) - - scope = _load_discovery_scope(str(tmp_project)) - assert scope is None - - -# ====================================================================== -# AI-Populated Templates Tests -# ====================================================================== - - -class TestAIPopulatedTemplates: - """Test AI-populated doc/speckit templates.""" - - @patch(f"{_CUSTOM_MODULE}._get_project_dir") - def test_generate_docs_static_fallback(self, mock_dir, project_with_config): - """Without design context, uses static template rendering.""" - from azext_prototype.custom import prototype_generate_docs - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - out_dir = str(project_with_config / "docs") - result = prototype_generate_docs(cmd, path=out_dir, json_output=True) - assert result["status"] == "generated" - - docs_path = project_with_config / "docs" - assert docs_path.is_dir() - - @patch(f"{_CUSTOM_MODULE}._get_project_dir") - def test_generate_speckit_with_manifest(self, mock_dir, project_with_config): - """Speckit includes manifest.json.""" - from azext_prototype.custom import prototype_generate_speckit - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - out_dir = str(project_with_config / "concept" / ".specify") - prototype_generate_speckit(cmd, path=out_dir) - - manifest_path = project_with_config / "concept" / ".specify" / "manifest.json" - assert manifest_path.exists() - - with open(manifest_path) as f: - manifest = json.load(f) - assert "templates" in manifest - - @patch(f"{_CUSTOM_MODULE}._get_project_dir") - def test_generate_docs_with_design_context(self, mock_dir, project_with_design, mock_ai_provider): - """When design context exists, doc-agent is attempted for population.""" - from azext_prototype.custom import prototype_generate_docs - - mock_dir.return_value = str(project_with_design) - cmd = MagicMock() - - # AI provider factory is imported locally inside prototype_generate_docs - with patch("azext_prototype.ai.factory.create_ai_provider") as mock_factory: - mock_factory.return_value = mock_ai_provider - - out_dir = str(project_with_design / "docs") - result = prototype_generate_docs(cmd, path=out_dir, json_output=True) - - assert result["status"] == "generated" - - def test_generate_templates_uses_rich_ui(self, project_with_config): - """_generate_templates uses console.print_file_list instead of print().""" - from pathlib import Path - - from azext_prototype.custom import _generate_templates - - output_dir = Path(str(project_with_config)) / "test_docs" - project_dir = str(project_with_config) - project_config = {"project": {"name": "test"}} - - # Patch the module-level console singleton. We must use importlib - # because `import azext_prototype.ui.console` can resolve to the - # `console` variable re-exported in azext_prototype.ui.__init__ - # instead of the submodule (name collision on Python 3.10). - import importlib - - _console_mod = importlib.import_module("azext_prototype.ui.console") - - with patch.object(_console_mod, "console") as mock_console: - generated = _generate_templates(output_dir, project_dir, project_config, "docs") - - # Should use console.print_file_list instead of bare print() - mock_console.print_file_list.assert_called_once() - mock_console.print_dim.assert_called_once() - assert len(generated) >= 1 - - -# ====================================================================== -# Command-level Integration Tests -# ====================================================================== - - -class TestBacklogCommandIntegration: - """Test the prototype_generate_backlog command with new session delegation.""" - - @patch(f"{_CUSTOM_MODULE}._prepare_command") - def test_backlog_status_no_state(self, mock_prepare, project_with_config, mock_ai_provider): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.custom import prototype_generate_backlog - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(project_with_config), - ai_provider=mock_ai_provider, - ) - from azext_prototype.config import ProjectConfig - - config = ProjectConfig(str(project_with_config)) - mock_prepare.return_value = (str(project_with_config), config, AgentRegistry(), ctx) - cmd = MagicMock() - - result = prototype_generate_backlog(cmd, status=True, json_output=True) - assert result["status"] == "displayed" - - @patch(f"{_CUSTOM_MODULE}._prepare_command") - def test_backlog_status_with_state(self, mock_prepare, project_with_design, mock_ai_provider): - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.custom import prototype_generate_backlog - from azext_prototype.stages.backlog_state import BacklogState - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(project_with_design), - ai_provider=mock_ai_provider, - ) - from azext_prototype.config import ProjectConfig - - config = ProjectConfig(str(project_with_design)) - mock_prepare.return_value = (str(project_with_design), config, AgentRegistry(), ctx) - cmd = MagicMock() - - # Create backlog state - state = BacklogState(str(project_with_design)) - state.set_items([{"epic": "Infra", "title": "VNet", "effort": "M"}]) - - result = prototype_generate_backlog(cmd, status=True, json_output=True) - assert result["status"] == "displayed" - - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") - def test_backlog_invalid_provider_raises(self, mock_dir, mock_check_req, project_with_design, mock_ai_provider): - from azext_prototype.custom import prototype_generate_backlog - - mock_dir.return_value = str(project_with_design) - cmd = MagicMock() - - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: - from azext_prototype.agents.base import AgentContext - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(project_with_design), - ai_provider=mock_ai_provider, - ) - mock_ctx.return_value = ctx - - with pytest.raises(CLIError, match="Unsupported backlog provider"): - prototype_generate_backlog(cmd, provider="jira", org="x", project="y") - - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") - def test_backlog_no_design_raises(self, mock_dir, mock_check_req, project_with_config, mock_ai_provider): - from azext_prototype.custom import prototype_generate_backlog - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: - from azext_prototype.agents.base import AgentContext - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(project_with_config), - ai_provider=mock_ai_provider, - ) - mock_ctx.return_value = ctx - - with pytest.raises(CLIError, match="No architecture design found"): - prototype_generate_backlog(cmd, provider="github", org="x", project="y") - - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") - def test_backlog_delegates_to_session(self, mock_dir, mock_check_req, project_with_design, mock_ai_provider): - """The command delegates to BacklogSession.run().""" - from azext_prototype.ai.provider import AIResponse - from azext_prototype.custom import prototype_generate_backlog - - mock_dir.return_value = str(project_with_design) - cmd = MagicMock() - - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - mock_ai_provider.chat.return_value = AIResponse(content=items_json, model="t") - - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: - from azext_prototype.agents.base import AgentContext - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(project_with_design), - ai_provider=mock_ai_provider, - ) - mock_ctx.return_value = ctx - - with patch("azext_prototype.stages.backlog_session.BacklogSession.run") as mock_run: - from azext_prototype.stages.backlog_session import BacklogResult - - mock_run.return_value = BacklogResult( - items_generated=1, - items_pushed=0, - ) - - result = prototype_generate_backlog( - cmd, - provider="github", - org="o", - project="p", - json_output=True, - ) - - assert result["status"] == "generated" - assert result["items_generated"] == 1 - - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") - def test_backlog_cancelled_returns_cancelled(self, mock_dir, mock_check_req, project_with_design, mock_ai_provider): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.custom import prototype_generate_backlog - - mock_dir.return_value = str(project_with_design) - cmd = MagicMock() - - mock_ai_provider.chat.return_value = AIResponse(content="[]", model="t") - - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: - from azext_prototype.agents.base import AgentContext - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(project_with_design), - ai_provider=mock_ai_provider, - ) - mock_ctx.return_value = ctx - - with patch("azext_prototype.stages.backlog_session.BacklogSession.run") as mock_run: - from azext_prototype.stages.backlog_session import BacklogResult - - mock_run.return_value = BacklogResult(cancelled=True) - - result = prototype_generate_backlog( - cmd, - provider="github", - org="o", - project="p", - json_output=True, - ) - - assert result["status"] == "cancelled" - - -# ====================================================================== -# /add enrichment tests (Phase 9) -# ====================================================================== - - -class TestAddEnrichment: - """Test that /add uses PM agent to enrich items.""" - - def _make_session(self, tmp_project, pm_response=None, pm_raises=False): - from azext_prototype.agents.base import AgentCapability, AgentContext - from azext_prototype.ai.provider import AIResponse - from azext_prototype.stages.backlog_session import BacklogSession - from azext_prototype.stages.backlog_state import BacklogState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - pm = MagicMock() - pm.name = "project-manager" - pm.get_system_messages.return_value = [] - - if pm_raises: - ctx.ai_provider.chat.side_effect = RuntimeError("AI error") - elif pm_response: - ctx.ai_provider.chat.return_value = AIResponse( - content=pm_response, - model="gpt-4o", - usage={"prompt_tokens": 10, "completion_tokens": 5}, - ) - else: - ctx.ai_provider.chat.return_value = AIResponse( - content=json.dumps( - { - "epic": "API", - "title": "Add rate limiting", - "description": "Implement API rate limiting for all endpoints", - "acceptance_criteria": ["AC1: Rate limit headers returned", "AC2: 429 status on exceed"], - "tasks": ["Add middleware", "Configure limits", "Add tests"], - "effort": "L", - } - ), - model="gpt-4o", - usage={"prompt_tokens": 10, "completion_tokens": 5}, - ) - - registry = MagicMock() - - def find_by_cap(cap): - if cap == AgentCapability.BACKLOG_GENERATION: - return [pm] - if cap == AgentCapability.QA: - return [] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - state = BacklogState(str(tmp_project)) - state.set_items([{"title": "Existing"}]) - - session = BacklogSession(ctx, registry, backlog_state=state) - return session, pm, state - - def test_add_enriched_via_pm(self, tmp_project): - session, pm, state = self._make_session(tmp_project) - - result = session._enrich_new_item("Add rate limiting to the API") - - assert result["title"] == "Add rate limiting" - assert result["epic"] == "API" - assert len(result["acceptance_criteria"]) == 2 - assert len(result["tasks"]) == 3 - assert result["effort"] == "L" - - def test_add_pm_failure_falls_back_to_bare(self, tmp_project): - session, pm, state = self._make_session(tmp_project, pm_raises=True) - - result = session._enrich_new_item("Add rate limiting") - - assert result["title"] == "Add rate limiting" - assert result["epic"] == "Added" - assert result["acceptance_criteria"] == [] - - def test_add_pm_invalid_json_falls_back(self, tmp_project): - session, pm, state = self._make_session( - tmp_project, - pm_response="Sure, here's a rate limiting story with details...", - ) - - result = session._enrich_new_item("Add rate limiting") - - assert result["title"] == "Add rate limiting" - assert result["epic"] == "Added" - - def test_add_no_pm_agent_uses_bare(self, tmp_project): - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.backlog_session import BacklogSession - from azext_prototype.stages.backlog_state import BacklogState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - registry = MagicMock() - registry.find_by_capability.return_value = [] - - session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) - - result = session._enrich_new_item("Add rate limiting") - - assert result["title"] == "Add rate limiting" - assert result["epic"] == "Added" - - def test_add_enriched_missing_fields_get_defaults(self, tmp_project): - session, pm, state = self._make_session( - tmp_project, - pm_response=json.dumps({"title": "Rate Limiting", "effort": "S"}), - ) - - result = session._enrich_new_item("Add rate limiting") - - assert result["title"] == "Rate Limiting" - assert result["epic"] == "Added" # defaulted - assert result["acceptance_criteria"] == [] # defaulted - assert result["tasks"] == [] # defaulted - assert result["effort"] == "S" - - -# ====================================================================== -# BacklogSession Coverage — additional tests for uncovered lines -# ====================================================================== - -_SESSION_MODULE = "azext_prototype.stages.backlog_session" - - -class TestBacklogSessionCoverage: - """Additional tests to cover uncovered lines in backlog_session.py.""" - - def _make_session( - self, - project_dir, - mock_ai_provider=None, - items_response="[]", - *, - with_qa=True, - ): - from azext_prototype.agents.base import AgentCapability, AgentContext - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.ai.provider import AIResponse - from azext_prototype.stages.backlog_session import BacklogSession - from azext_prototype.stages.backlog_state import BacklogState - - if mock_ai_provider is None: - mock_ai_provider = MagicMock() - - mock_ai_provider.chat.return_value = AIResponse( - content=items_response, - model="test", - usage={"prompt_tokens": 10, "completion_tokens": 5}, - ) - - pm = MagicMock() - pm.name = "project-manager" - pm.get_system_messages.return_value = [] - - qa = MagicMock() - qa.name = "qa-engineer" - - registry = MagicMock(spec=AgentRegistry) - - def find_by_cap(cap): - if cap == AgentCapability.BACKLOG_GENERATION: - return [pm] - if cap == AgentCapability.QA: - return [qa] if with_qa else [] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(project_dir), - ai_provider=mock_ai_provider, - ) - - backlog_state = BacklogState(str(project_dir)) - session = BacklogSession(ctx, registry, backlog_state=backlog_state) - return session, backlog_state, mock_ai_provider - - # ---------------------------------------------------------- - # Line 151: escalation tracker load - # ---------------------------------------------------------- - - def test_escalation_tracker_loaded_when_exists(self, tmp_project): - """When escalation.yaml exists, __init__ loads it (line 151).""" - import yaml as _yaml - - esc_path = tmp_project / ".prototype" / "state" / "escalation.yaml" - esc_path.parent.mkdir(parents=True, exist_ok=True) - esc_path.write_text(_yaml.dump({"entries": [], "active_count": 0})) - - session, _, _ = self._make_session(tmp_project) - # If it loaded without error, the path is covered - assert session._escalation_tracker is not None - - # ---------------------------------------------------------- - # Lines 227-228: no PM agent - # ---------------------------------------------------------- - - def test_run_no_pm_agent_returns_cancelled(self, tmp_project): - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.backlog_session import BacklogSession - from azext_prototype.stages.backlog_state import BacklogState - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - registry = MagicMock() - registry.find_by_capability.return_value = [] - - session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: "done", - print_fn=output.append, - ) - assert result.cancelled - joined = "\n".join(output) - assert "No project-manager agent" in joined - - # ---------------------------------------------------------- - # Lines 231-232: no AI provider - # ---------------------------------------------------------- - - def test_run_no_ai_provider_returns_cancelled(self, tmp_project): - from azext_prototype.agents.base import AgentCapability, AgentContext - from azext_prototype.stages.backlog_session import BacklogSession - from azext_prototype.stages.backlog_state import BacklogState - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(tmp_project), - ai_provider=None, - ) - - pm = MagicMock() - pm.name = "project-manager" - registry = MagicMock() - - def find_by_cap(cap): - if cap == AgentCapability.BACKLOG_GENERATION: - return [pm] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: "done", - print_fn=output.append, - ) - assert result.cancelled - joined = "\n".join(output) - assert "No AI provider" in joined - - # ---------------------------------------------------------- - # Lines 297: empty input skip in interactive loop - # ---------------------------------------------------------- - - def test_empty_input_skipped(self, tmp_project): - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - inputs = iter(["", "done"]) - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - assert not result.cancelled - assert result.items_generated == 1 - - # ---------------------------------------------------------- - # Lines 328-364: intent classification to command + NL mutate - # ---------------------------------------------------------- - - def test_intent_command_routes_to_slash(self, tmp_project): - """Natural language classified as COMMAND is routed (lines 328-342).""" - from azext_prototype.stages.intent import IntentKind, IntentResult - - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - # Mock the intent classifier to return a COMMAND - session._intent_classifier = MagicMock() - session._intent_classifier.classify.return_value = IntentResult( - kind=IntentKind.COMMAND, - command="/list", - args="", - original_input="show all items", - confidence=0.9, - ) - - inputs = iter(["show all items", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - # /list should have been handled -- items are listed - joined = "\n".join(output) - assert "B" in joined - - def test_intent_command_push_breaks_loop(self, tmp_project): - """Intent push that succeeds returns 'pushed' (line 340-341).""" - from azext_prototype.stages.intent import IntentKind, IntentResult - - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - session._intent_classifier = MagicMock() - session._intent_classifier.classify.return_value = IntentResult( - kind=IntentKind.COMMAND, - command="/push", - args="", - original_input="push items", - confidence=0.9, - ) - - with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( - f"{_SESSION_MODULE}.push_github_issue" - ) as mock_push: - mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: "push items", - print_fn=output.append, - ) - assert result.items_pushed == 1 - - def test_natural_language_mutate_items(self, tmp_project): - """NL CONVERSATIONAL triggers _mutate_items (lines 344-364).""" - from azext_prototype.ai.provider import AIResponse - from azext_prototype.stages.intent import IntentKind, IntentResult - - items_json = json.dumps([{"epic": "A", "title": "Original", "effort": "S"}]) - session, state, ai = self._make_session(tmp_project, items_response=items_json) - - updated_json = json.dumps([{"epic": "A", "title": "Updated", "effort": "M"}]) - - session._intent_classifier = MagicMock() - session._intent_classifier.classify.return_value = IntentResult( - kind=IntentKind.CONVERSATIONAL, - original_input="change title to Updated", - ) - - call_count = [0] - - def side_effect_chat(msgs, **kwargs): - call_count[0] += 1 - if call_count[0] == 1: - return AIResponse( - content=items_json, - model="t", - usage={"prompt_tokens": 10, "completion_tokens": 5}, - ) - else: - return AIResponse( - content=updated_json, - model="t", - usage={"prompt_tokens": 10, "completion_tokens": 5}, - ) - - ai.chat.side_effect = side_effect_chat - - inputs = iter(["change title to Updated", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - assert state.state["items"][0]["title"] == "Updated" - - def test_natural_language_mutate_returns_none(self, tmp_project): - """When _mutate_items returns None, user sees error (line 362).""" - from azext_prototype.stages.intent import IntentKind, IntentResult - - items_json = json.dumps([{"epic": "A", "title": "T", "effort": "S"}]) - session, state, ai = self._make_session(tmp_project, items_response=items_json) - - session._intent_classifier = MagicMock() - session._intent_classifier.classify.return_value = IntentResult( - kind=IntentKind.CONVERSATIONAL, - original_input="do something weird", - ) - - # Force _mutate_items to return None (the path that shows the error) - session._mutate_items = MagicMock(return_value=None) - - inputs = iter(["do something weird", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "Could not update" in joined - - # ---------------------------------------------------------- - # Lines 374, 378: report phase with push_urls - # ---------------------------------------------------------- - - def test_report_collects_push_urls(self, tmp_project): - """Report phase extracts urls from push_results (lines 374-378).""" - session, state, _ = self._make_session(tmp_project) - - state.set_items([{"epic": "A", "title": "B", "effort": "S"}]) - state.mark_item_pushed(0, "https://github.com/o/p/issues/1") - state.set_context_hash("arch") - session._backlog_state = state - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: "done", - print_fn=output.append, - ) - assert result.items_pushed == 1 - assert "https://github.com/o/p/issues/1" in result.push_urls - - # ---------------------------------------------------------- - # Lines 407, 410-411: quick mode EOF - # ---------------------------------------------------------- - - def test_quick_mode_eof_cancels(self, tmp_project): - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - def eof_input(p): - raise EOFError - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - quick=True, - input_fn=eof_input, - print_fn=output.append, - ) - assert result.cancelled - - def test_quick_mode_confirm_push(self, tmp_project): - """Quick mode confirm=yes triggers push (line 417).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( - f"{_SESSION_MODULE}.push_github_issue" - ) as mock_push: - mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - quick=True, - input_fn=lambda p: "y", - print_fn=output.append, - ) - assert result.items_pushed == 1 - - # ---------------------------------------------------------- - # Lines 440-448, 494, 504: scope text + devops provider - # ---------------------------------------------------------- - - def test_generate_items_with_full_scope(self, tmp_project): - """Scope in/out/deferred all present (lines 440-448, 494).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, ai = self._make_session(tmp_project, items_response=items_json) - - scope = { - "in_scope": ["API Gateway"], - "out_of_scope": ["Mobile app"], - "deferred": ["Analytics"], - } - - output = [] - session.run( - design_context="arch", - scope=scope, - provider="github", - org="o", - project="p", - input_fn=lambda p: "done", - print_fn=output.append, - ) - - call_args = ai.chat.call_args - messages = call_args[0][0] - content = messages[-1].content - assert "In Scope" in content - assert "API Gateway" in content - assert "Out of Scope" in content - assert "Mobile app" in content - assert "Deferred" in content - assert "Analytics" in content - - def test_generate_items_devops_format(self, tmp_project): - """DevOps provider uses hierarchical JSON schema (line 504).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, ai = self._make_session(tmp_project, items_response=items_json) - - output = [] - session.run( - design_context="arch", - provider="devops", - org="o", - project="p", - input_fn=lambda p: "done", - print_fn=output.append, - ) - - call_args = ai.chat.call_args - messages = call_args[0][0] - content = messages[-1].content - assert "Azure DevOps hierarchy" in content - assert "children" in content - - # ---------------------------------------------------------- - # Lines 571-599: _mutate_items - # ---------------------------------------------------------- - - def test_mutate_items_no_pm_returns_none(self, tmp_project): - """_mutate_items returns None when no PM agent (line 571).""" - from azext_prototype.agents.base import AgentContext - from azext_prototype.stages.backlog_session import BacklogSession - from azext_prototype.stages.backlog_state import BacklogState - - ctx = AgentContext( - project_config={"project": {"name": "test"}}, - project_dir=str(tmp_project), - ai_provider=None, - ) - registry = MagicMock() - registry.find_by_capability.return_value = [] - - session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) - - result = session._mutate_items("add an item", "arch") - assert result is None - - def test_mutate_items_success(self, tmp_project): - """_mutate_items calls AI and parses JSON (lines 571-599).""" - from azext_prototype.ai.provider import AIResponse - - updated = [{"epic": "A", "title": "Updated", "effort": "M"}] - session, state, ai = self._make_session(tmp_project) - state.set_items([{"epic": "A", "title": "Old", "effort": "S"}]) - - ai.chat.return_value = AIResponse( - content=json.dumps(updated), - model="t", - usage={"prompt_tokens": 10, "completion_tokens": 5}, - ) - - result = session._mutate_items("rename to Updated", "arch") - assert result is not None - assert result[0]["title"] == "Updated" - - # ---------------------------------------------------------- - # Lines 606-608: _parse_items with markdown fences - # ---------------------------------------------------------- - - def test_parse_items_markdown_fences(self): - from azext_prototype.stages.backlog_session import BacklogSession - - raw = '```json\n[{"title": "A"}]\n```' - items = BacklogSession._parse_items(raw) - assert len(items) == 1 - assert items[0]["title"] == "A" - - def test_parse_items_bad_json_returns_empty(self): - from azext_prototype.stages.backlog_session import BacklogSession - - items = BacklogSession._parse_items("this is not json") - assert items == [] - - # ---------------------------------------------------------- - # Lines 634-637: push_all no pending items - # ---------------------------------------------------------- - - def test_push_all_no_pending(self, tmp_project): - """_push_all with no pending items returns early (lines 634-637).""" - session, state, _ = self._make_session(tmp_project) - - state.set_items([{"epic": "A", "title": "B", "effort": "S"}]) - state.mark_item_pushed(0, "url") - - output = [] - result = session._push_all("github", "o", "p", output.append, False) - assert result.items_pushed == 1 - joined = "\n".join(output) - assert "No pending" in joined - - # ---------------------------------------------------------- - # Lines 645-653: push auth check fails - # ---------------------------------------------------------- - - def test_push_all_github_no_auth(self, tmp_project): - session, state, _ = self._make_session(tmp_project) - state.set_items([{"title": "A"}]) - - with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=False): - output = [] - result = session._push_all("github", "o", "p", output.append, False) - assert result.cancelled - joined = "\n".join(output) - assert "not authenticated" in joined.lower() - - def test_push_all_devops_no_ext(self, tmp_project): - """DevOps push fails when extension missing (lines 651-656).""" - session, state, _ = self._make_session(tmp_project) - state.set_items([{"title": "A"}]) - - with patch(f"{_SESSION_MODULE}.check_devops_ext", return_value=False): - output = [] - result = session._push_all("devops", "o", "p", output.append, False) - assert result.cancelled - joined = "\n".join(output) - assert "not available" in joined.lower() - - # ---------------------------------------------------------- - # Lines 672, 687-714: push devops feature with children - # ---------------------------------------------------------- - - def test_push_all_devops_with_children_and_tasks(self, tmp_project): - """DevOps push: Feature -> Stories -> Tasks (lines 687-714).""" - session, state, _ = self._make_session(tmp_project) - state.set_items( - [ - { - "title": "Feature1", - "children": [ - { - "title": "Story1", - "tasks": [ - {"title": "Task1", "done": False}, - {"title": "Task2", "done": True}, - ], - }, - ], - } - ] - ) - - with patch(f"{_SESSION_MODULE}.check_devops_ext", return_value=True), patch( - f"{_SESSION_MODULE}.push_devops_feature" - ) as mock_feat, patch(f"{_SESSION_MODULE}.push_devops_story") as mock_story, patch( - f"{_SESSION_MODULE}.push_devops_task" - ) as mock_task: - mock_feat.return_value = { - "id": 100, - "url": "https://dev.azure.com/o/p/_workitems/100", - } - mock_story.return_value = { - "id": 101, - "url": "https://dev.azure.com/o/p/_workitems/101", - } - mock_task.return_value = {"id": 102, "url": ""} - - output = [] - result = session._push_all("devops", "o", "p", output.append, False) - - assert result.items_pushed == 1 - assert len(result.push_urls) == 2 # feature + story - mock_story.assert_called_once() - # Only Task1 (done=False) should be pushed - mock_task.assert_called_once() - task_arg = mock_task.call_args[0][2] - assert task_arg["title"] == "Task1" - - def test_push_all_item_error_routes_to_qa(self, tmp_project): - """Push failure routes to QA (lines 674-685).""" - session, state, _ = self._make_session(tmp_project) - state.set_items([{"title": "FailItem"}]) - - with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( - f"{_SESSION_MODULE}.push_github_issue" - ) as mock_push, patch(f"{_SESSION_MODULE}.route_error_to_qa") as mock_qa: - mock_push.return_value = {"error": "auth failed"} - - output = [] - result = session._push_all("github", "o", "p", output.append, False) - - assert result.items_failed == 1 - mock_qa.assert_called_once() - - # ---------------------------------------------------------- - # Lines 737-779: _push_single - # ---------------------------------------------------------- - - def test_push_single_invalid_index(self, tmp_project): - """_push_single with out-of-range index (lines 738-740).""" - session, state, _ = self._make_session(tmp_project) - state.set_items([{"title": "Only"}]) - - output = [] - session._push_single(5, "github", "o", "p", output.append, False) - joined = "\n".join(output) - assert "not found" in joined.lower() - - def test_push_single_github_success(self, tmp_project): - """_push_single pushes a single github issue (lines 742-757).""" - session, state, _ = self._make_session(tmp_project) - state.set_items([{"title": "Item1"}]) - - with patch(f"{_SESSION_MODULE}.push_github_issue") as mock_push: - mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} - - output = [] - session._push_single(0, "github", "o", "p", output.append, False) - - assert state.state["push_status"][0] == "pushed" - joined = "\n".join(output) - assert "github.com" in joined - - def test_push_single_error(self, tmp_project): - """_push_single error marks item failed (lines 751-753).""" - session, state, _ = self._make_session(tmp_project) - state.set_items([{"title": "Item1"}]) - - with patch(f"{_SESSION_MODULE}.push_github_issue") as mock_push: - mock_push.return_value = {"error": "not found"} - - output = [] - session._push_single(0, "github", "o", "p", output.append, False) - - assert state.state["push_status"][0] == "failed" - - def test_push_single_devops_with_children(self, tmp_project): - """_push_single devops creates children + tasks (lines 759-779).""" - session, state, _ = self._make_session(tmp_project) - state.set_items( - [ - { - "title": "Feature", - "children": [ - { - "title": "Story", - "tasks": [{"title": "Task", "done": False}], - } - ], - } - ] - ) - - with patch(f"{_SESSION_MODULE}.push_devops_feature") as mock_feat, patch( - f"{_SESSION_MODULE}.push_devops_story" - ) as mock_story, patch(f"{_SESSION_MODULE}.push_devops_task") as mock_task: - mock_feat.return_value = {"id": 10, "url": "http://f"} - mock_story.return_value = {"id": 11, "url": "http://s"} - mock_task.return_value = {"id": 12, "url": ""} - - output = [] - session._push_single(0, "devops", "o", "p", output.append, False) - - mock_story.assert_called_once() - mock_task.assert_called_once() - - # ---------------------------------------------------------- - # Lines 812, 815-829: slash commands /show, /add - # ---------------------------------------------------------- - - def test_slash_show_no_arg(self, tmp_project): - """/show without number prints usage (line 812).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - inputs = iter(["/show", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "Usage: /show N" in joined - - def test_slash_add_with_description(self, tmp_project): - """/add prompts for description and enriches (lines 815-829).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - inputs = iter(["/add", "New item description", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - assert len(state.state["items"]) == 2 - joined = "\n".join(output) - assert "Added item 2" in joined - - def test_slash_add_eof(self, tmp_project): - """/add with EOF during description input (lines 821-822).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - call_count = [0] - - def eof_on_second(p): - call_count[0] += 1 - if call_count[0] == 1: - return "/add" - elif call_count[0] == 2: - raise EOFError - return "done" - - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=eof_on_second, - print_fn=output.append, - ) - # Items unchanged -- the add was cancelled - assert len(state.state["items"]) == 1 - - # ---------------------------------------------------------- - # Lines 840-842: /remove edge cases - # ---------------------------------------------------------- - - def test_slash_remove_invalid_arg(self, tmp_project): - """/remove without number prints usage (lines 841-842).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - inputs = iter(["/remove", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "Usage: /remove N" in joined - - def test_slash_remove_out_of_range(self, tmp_project): - """/remove with index out of range (line 840).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - inputs = iter(["/remove 99", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "not found" in joined.lower() - - # ---------------------------------------------------------- - # Lines 845-857: /preview command - # ---------------------------------------------------------- - - def test_slash_preview_github(self, tmp_project): - items_json = json.dumps( - [ - {"epic": "Infra", "title": "VNet", "effort": "M"}, - {"epic": "App", "title": "API", "effort": "L"}, - ] - ) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - inputs = iter(["/preview", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "GitHub Issues" in joined - assert "[Infra] VNet" in joined - assert "[App] API" in joined - - def test_slash_preview_devops(self, tmp_project): - """/preview for devops provider (no epic prefix, line 856).""" - items_json = json.dumps([{"title": "Feature1", "effort": "M"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - inputs = iter(["/preview", "done"]) - output = [] - session.run( - design_context="arch", - provider="devops", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "DevOps Work Items" in joined - assert "Feature1" in joined - - # ---------------------------------------------------------- - # Lines 862-907: /push, /status, /help - # ---------------------------------------------------------- - - def test_slash_push_single(self, tmp_project): - """/push N pushes single item (lines 862-865).""" - items_json = json.dumps( - [ - {"epic": "A", "title": "Item1", "effort": "S"}, - {"epic": "A", "title": "Item2", "effort": "M"}, - ] - ) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - with patch(f"{_SESSION_MODULE}.push_github_issue") as mock_push: - mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} - - inputs = iter(["/push 1", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - - assert state.state["push_status"][0] == "pushed" - assert state.state["push_status"][1] == "pending" - - def test_slash_push_all_breaks_on_success(self, tmp_project): - """/push (all) breaks loop on success (line 868-869).""" - items_json = json.dumps([{"epic": "A", "title": "Item1", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( - f"{_SESSION_MODULE}.push_github_issue" - ) as mock_push: - mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: "/push", - print_fn=output.append, - ) - - assert result.items_pushed == 1 - - def test_slash_status(self, tmp_project): - """Show push status per item (lines 871-880).""" - session, state, _ = self._make_session(tmp_project) - - state.set_items( - [ - {"epic": "A", "title": "Item1", "effort": "S"}, - {"epic": "A", "title": "Item2", "effort": "M"}, - ] - ) - state.mark_item_pushed(0, "url") - state.set_context_hash("arch") - session._backlog_state = state - - inputs = iter(["/status", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "pushed" in joined - assert "pending" in joined - - def test_slash_help(self, tmp_project): - """Display help text (lines 882-907).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - inputs = iter(["/help", "done"]) - output = [] - session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: next(inputs), - print_fn=output.append, - ) - joined = "\n".join(output) - assert "/list" in joined - assert "/push" in joined - assert "/remove" in joined - assert "/preview" in joined - assert "/status" in joined - assert "natural language" in joined.lower() - - # ---------------------------------------------------------- - # Lines 961-963: enrich with markdown fences - # ---------------------------------------------------------- - - def test_enrich_strips_markdown_fences(self, tmp_project): - from azext_prototype.ai.provider import AIResponse - - item_json = json.dumps({"title": "Rate Limiting", "effort": "L"}) - fenced = f"```json\n{item_json}\n```" - - session, state, ai = self._make_session(tmp_project) - ai.chat.return_value = AIResponse( - content=fenced, - model="t", - usage={"prompt_tokens": 10, "completion_tokens": 5}, - ) - - result = session._enrich_new_item("Rate limiting") - assert result["title"] == "Rate Limiting" - assert result["effort"] == "L" - - # ---------------------------------------------------------- - # Lines 987-988: _save_backlog_md with no items - # ---------------------------------------------------------- - - def test_save_backlog_md_no_items(self, tmp_project): - session, state, _ = self._make_session(tmp_project) - state.set_items([]) - - output = [] - session._save_backlog_md(output.append) - joined = "\n".join(output) - assert "No items to save" in joined - - # ---------------------------------------------------------- - # Lines 1020-1021: save with dict tasks - # ---------------------------------------------------------- - - def test_save_backlog_md_dict_tasks(self, tmp_project): - session, state, _ = self._make_session(tmp_project) - state.set_items( - [ - { - "epic": "Infra", - "title": "VNet", - "description": "Configure VNet", - "effort": "M", - "acceptance_criteria": ["AC1"], - "tasks": [ - {"title": "Create VNet", "done": True}, - {"title": "Create Subnets", "done": False}, - ], - } - ] - ) - - output = [] - session._save_backlog_md(output.append) - - md_path = tmp_project / "concept" / "docs" / "BACKLOG.md" - assert md_path.exists() - content = md_path.read_text() - assert "- [x] Create VNet" in content - assert "- [ ] Create Subnets" in content - - # ---------------------------------------------------------- - # Lines 1055, 1067-1069: _get_production_items - # ---------------------------------------------------------- - - def test_get_production_items_no_services(self, tmp_project): - """Returns empty string when no services (line 1055).""" - from azext_prototype.stages.discovery_state import DiscoveryState - - ds = DiscoveryState(str(tmp_project)) - ds._state["architecture"] = {"services": []} - ds.save() - - session, _, _ = self._make_session(tmp_project) - result = session._get_production_items() - assert result == "" - - def test_get_production_items_exception(self, tmp_project): - """Returns empty string on exception (lines 1067-1069).""" - session, _, _ = self._make_session(tmp_project) - - with patch( - "azext_prototype.stages.discovery_state.DiscoveryState.load", - side_effect=Exception("boom"), - ): - result = session._get_production_items() - assert result == "" - - # ---------------------------------------------------------- - # Lines 1075-1076, 1078-1082: _maybe_spinner - # ---------------------------------------------------------- - - def test_maybe_spinner_with_status_fn(self, tmp_project): - """_maybe_spinner with status_fn calls start/end (1078-1082).""" - session, _, _ = self._make_session(tmp_project) - - calls = [] - - def status_fn(msg, phase): - calls.append((msg, phase)) - - with session._maybe_spinner("Working...", False, status_fn=status_fn): - pass - - assert ("Working...", "start") in calls - assert ("Working...", "end") in calls - - def test_maybe_spinner_plain_noop(self, tmp_project): - """_maybe_spinner with no styling and no status_fn is a no-op.""" - session, _, _ = self._make_session(tmp_project) - - with session._maybe_spinner("msg", False): - pass - - # ---------------------------------------------------------- - # Line 324: slash command push breaks interactive loop - # ---------------------------------------------------------- - - def test_slash_command_push_breaks_loop(self, tmp_project): - """When /push returns 'pushed', the loop breaks (line 324).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - with patch(f"{_SESSION_MODULE}.check_gh_auth", return_value=True), patch( - f"{_SESSION_MODULE}.push_github_issue" - ) as mock_push: - mock_push.return_value = {"url": "https://github.com/o/p/issues/1"} - - output = [] - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - input_fn=lambda p: "/push", - print_fn=output.append, - ) - - assert result.items_pushed == 1 - assert not result.cancelled - - # ---------------------------------------------------------- - # Line 672: push_all devops feature direct call - # ---------------------------------------------------------- - - def test_push_all_devops_feature_direct(self, tmp_project): - """_push_all with devops calls push_devops_feature (line 672).""" - session, state, _ = self._make_session(tmp_project) - state.set_items([{"title": "F1"}]) - - with patch(f"{_SESSION_MODULE}.check_devops_ext", return_value=True), patch( - f"{_SESSION_MODULE}.push_devops_feature" - ) as mock_feat: - mock_feat.return_value = { - "id": 1, - "url": "https://dev.azure.com/o/p/1", - } - - output = [] - result = session._push_all("devops", "o", "p", output.append, False) - - assert result.items_pushed == 1 - mock_feat.assert_called_once() - - # ---------------------------------------------------------- - # Line 283: styled prompt (test use_styled=True paths - # via console mock) - # ---------------------------------------------------------- - - def test_use_styled_calls_prompt(self, tmp_project): - """With use_styled=True, prompt is used (line 283).""" - items_json = json.dumps([{"epic": "A", "title": "B", "effort": "S"}]) - session, state, _ = self._make_session(tmp_project, items_response=items_json) - - # Mock the prompt to return "done" - session._prompt = MagicMock() - session._prompt.prompt.return_value = "done" - - # Run without input_fn/print_fn (use_styled=True) - # But we need to suppress real console output - session._console = MagicMock() - session._console.print = MagicMock() - session._console.spinner = MagicMock() - session._console.spinner.return_value.__enter__ = MagicMock() - session._console.spinner.return_value.__exit__ = MagicMock(return_value=False) - - result = session.run( - design_context="arch", - provider="github", - org="o", - project="p", - ) - session._prompt.prompt.assert_called() - assert result.items_generated == 1 diff --git a/tests/test_knowledge_contributor.py b/tests/test_knowledge_contributor.py deleted file mode 100644 index 13f5742..0000000 --- a/tests/test_knowledge_contributor.py +++ /dev/null @@ -1,572 +0,0 @@ -"""Tests for knowledge contribution helpers. - -Covers gap detection, formatting, submission via ``gh`` CLI, QA integration, -the fire-and-forget wrapper, and the CLI command ``az prototype knowledge -contribute``. -""" - -from unittest.mock import MagicMock, patch - -import pytest - -_KC_MODULE = "azext_prototype.stages.knowledge_contributor" -_BP_MODULE = "azext_prototype.stages.backlog_push" -_CUSTOM_MODULE = "azext_prototype.custom" - - -# ====================================================================== -# Helpers -# ====================================================================== - - -def _make_finding(**overrides) -> dict: - """Create a minimal finding dict with optional overrides.""" - finding = { - "service": "cosmos-db", - "type": "Pitfall", - "file": "knowledge/services/cosmos-db.md", - "section": "Terraform Patterns", - "context": "RU throughput must be set to at least 400 for serverless", - "rationale": "Setting below 400 causes deployment failure", - "content": "minimum_throughput = 400", - "source": "QA diagnosis", - } - finding.update(overrides) - return finding - - -def _make_loader(service_content: str = "") -> MagicMock: - """Create a mock KnowledgeLoader that returns *service_content*.""" - loader = MagicMock() - loader.load_service.return_value = service_content - return loader - - -# ====================================================================== -# TestFormatContributionBody -# ====================================================================== - - -class TestFormatContributionBody: - """Tests for ``format_contribution_body()``.""" - - def test_basic_format(self): - from azext_prototype.stages.knowledge_contributor import ( - format_contribution_body, - ) - - finding = _make_finding() - body = format_contribution_body(finding) - - assert "## Knowledge Contribution" in body - assert "**Type:** Pitfall" in body - assert "**File:** `knowledge/services/cosmos-db.md`" in body - assert "**Section to update:** Terraform Patterns" in body - assert "### Context" in body - assert "RU throughput" in body - assert "### Rationale" in body - assert "### Content to Add" in body - assert "minimum_throughput = 400" in body - assert "### Source" in body - assert "QA diagnosis" in body - - def test_missing_fields_defaults(self): - from azext_prototype.stages.knowledge_contributor import ( - format_contribution_body, - ) - - finding = {"service": "redis"} - body = format_contribution_body(finding) - - # "redis" doesn't match a knowledge file (it's redis-cache.md), - # so it's auto-upgraded to "New service" - assert "**Type:** New service" in body - assert "`knowledge/services/redis.md`" in body - assert "NEW FILE" in body - assert "No context provided." in body - assert "No rationale provided." in body - assert "No specific content provided" in body - - def test_empty_content(self): - from azext_prototype.stages.knowledge_contributor import ( - format_contribution_body, - ) - - finding = _make_finding(content="") - body = format_contribution_body(finding) - - assert "No specific content provided" in body - - -# ====================================================================== -# TestFormatContributionTitle -# ====================================================================== - - -class TestFormatContributionTitle: - """Tests for ``format_contribution_title()``.""" - - def test_basic_title(self): - from azext_prototype.stages.knowledge_contributor import ( - format_contribution_title, - ) - - finding = _make_finding() - title = format_contribution_title(finding) - - assert title.startswith("[Knowledge] cosmos-db:") - assert "RU throughput" in title - - def test_truncation_at_60(self): - from azext_prototype.stages.knowledge_contributor import ( - format_contribution_title, - ) - - long_context = "A" * 100 - finding = _make_finding(context=long_context) - title = format_contribution_title(finding) - - # Title should contain truncated context + ellipsis - assert "..." in title - # The service prefix + 60 chars + "..." should be in there - assert len(title) < 120 - - def test_missing_service(self): - from azext_prototype.stages.knowledge_contributor import ( - format_contribution_title, - ) - - finding = _make_finding(service="") - # Falls back to "unknown" since service key exists but is empty - # Actually the default in the function is "unknown" for missing key - finding.pop("service") - title = format_contribution_title(finding) - - assert "[Knowledge] unknown:" in title - - def test_description_fallback(self): - from azext_prototype.stages.knowledge_contributor import ( - format_contribution_title, - ) - - finding = _make_finding(context="", description="fallback description") - title = format_contribution_title(finding) - - assert "fallback description" in title - - -# ====================================================================== -# TestCheckKnowledgeGap -# ====================================================================== - - -class TestCheckKnowledgeGap: - """Tests for ``check_knowledge_gap()``.""" - - def test_no_file_is_gap(self): - from azext_prototype.stages.knowledge_contributor import check_knowledge_gap - - loader = _make_loader("") # empty = no file - finding = _make_finding() - - assert check_knowledge_gap(finding, loader) is True - - def test_content_not_found_is_gap(self): - from azext_prototype.stages.knowledge_contributor import check_knowledge_gap - - # Service file exists but doesn't contain the finding's context - loader = _make_loader("Some unrelated content about key vault.") - finding = _make_finding() - - assert check_knowledge_gap(finding, loader) is True - - def test_content_found_is_not_gap(self): - from azext_prototype.stages.knowledge_contributor import check_knowledge_gap - - # The first 80 chars of context appear in the service file - finding = _make_finding() - context_snippet = finding["context"][:80].lower() - loader = _make_loader(f"Some preamble. {context_snippet} and more details.") - - assert check_knowledge_gap(finding, loader) is False - - def test_empty_finding_is_not_gap(self): - from azext_prototype.stages.knowledge_contributor import check_knowledge_gap - - loader = _make_loader("") - assert check_knowledge_gap({}, loader) is False - assert check_knowledge_gap(None, loader) is False - - def test_missing_service_is_not_gap(self): - from azext_prototype.stages.knowledge_contributor import check_knowledge_gap - - loader = _make_loader("") - finding = _make_finding(service="") - assert check_knowledge_gap(finding, loader) is False - - def test_missing_context_is_not_gap(self): - from azext_prototype.stages.knowledge_contributor import check_knowledge_gap - - loader = _make_loader("") - finding = _make_finding(context="") - assert check_knowledge_gap(finding, loader) is False - - def test_loader_exception_treated_as_gap(self): - from azext_prototype.stages.knowledge_contributor import check_knowledge_gap - - loader = MagicMock() - loader.load_service.side_effect = Exception("file not found") - finding = _make_finding() - - # Exception means no content found => gap - assert check_knowledge_gap(finding, loader) is True - - -# ====================================================================== -# TestSubmitContribution -# ====================================================================== - - -class TestSubmitContribution: - """Tests for ``submit_contribution()``.""" - - def test_success(self): - from azext_prototype.stages.knowledge_contributor import submit_contribution - - with patch(f"{_BP_MODULE}.subprocess.run") as mock_auth, patch(f"{_KC_MODULE}.subprocess.run") as mock_create: - mock_auth.return_value = MagicMock(returncode=0) - mock_create.return_value = MagicMock( - returncode=0, - stdout="https://github.com/Azure/az-prototype/issues/42\n", - ) - - result = submit_contribution(_make_finding()) - - assert result["url"] == "https://github.com/Azure/az-prototype/issues/42" - assert result["number"] == "42" - - def test_gh_not_authed(self): - from azext_prototype.stages.knowledge_contributor import submit_contribution - - with patch(f"{_BP_MODULE}.subprocess.run") as mock_auth: - mock_auth.return_value = MagicMock(returncode=1) - - result = submit_contribution(_make_finding()) - assert "error" in result - assert "not authenticated" in result["error"].lower() - - def test_create_fails(self): - from azext_prototype.stages.knowledge_contributor import submit_contribution - - with patch(f"{_BP_MODULE}.subprocess.run") as mock_auth, patch(f"{_KC_MODULE}.subprocess.run") as mock_create: - mock_auth.return_value = MagicMock(returncode=0) - mock_create.return_value = MagicMock( - returncode=1, - stderr="label 'pitfall' not found", - stdout="", - ) - - result = submit_contribution(_make_finding()) - assert "error" in result - - def test_labels_include_service_and_type(self): - from azext_prototype.stages.knowledge_contributor import submit_contribution - - with patch(f"{_BP_MODULE}.subprocess.run") as mock_auth, patch(f"{_KC_MODULE}.subprocess.run") as mock_create: - mock_auth.return_value = MagicMock(returncode=0) - mock_create.return_value = MagicMock( - returncode=0, - stdout="https://github.com/Azure/az-prototype/issues/99\n", - ) - - finding = _make_finding(service="key-vault", type="Service pattern update") - submit_contribution(finding) - - # Check the command args include service and type labels - call_args = mock_create.call_args[0][0] - label_indices = [i for i, a in enumerate(call_args) if a == "--label"] - labels = [call_args[i + 1] for i in label_indices] - assert "knowledge-contribution" in labels - assert "service/key-vault" in labels - assert "pattern-update" in labels - - def test_custom_repo(self): - from azext_prototype.stages.knowledge_contributor import submit_contribution - - with patch(f"{_BP_MODULE}.subprocess.run") as mock_auth, patch(f"{_KC_MODULE}.subprocess.run") as mock_create: - mock_auth.return_value = MagicMock(returncode=0) - mock_create.return_value = MagicMock( - returncode=0, - stdout="https://github.com/myorg/myrepo/issues/1\n", - ) - - result = submit_contribution(_make_finding(), repo="myorg/myrepo") - - call_args = mock_create.call_args[0][0] - repo_idx = call_args.index("--repo") - assert call_args[repo_idx + 1] == "myorg/myrepo" - assert result["url"] == "https://github.com/myorg/myrepo/issues/1" - - def test_gh_not_installed(self): - from azext_prototype.stages.knowledge_contributor import submit_contribution - - # Mock check_gh_auth at its source (both modules share the subprocess object) - with patch(f"{_BP_MODULE}.check_gh_auth", return_value=True), patch( - f"{_KC_MODULE}.subprocess.run" - ) as mock_create: - mock_create.side_effect = FileNotFoundError - - result = submit_contribution(_make_finding()) - assert "error" in result - assert "not found" in result["error"].lower() - - -# ====================================================================== -# TestBuildFindingFromQa -# ====================================================================== - - -class TestBuildFindingFromQa: - """Tests for ``build_finding_from_qa()``.""" - - def test_builds_from_qa_text(self): - from azext_prototype.stages.knowledge_contributor import build_finding_from_qa - - qa_text = "The Cosmos DB RU throughput was set below the minimum of 400." - finding = build_finding_from_qa(qa_text, service="cosmos-db", source="Deploy failure: Stage 2") - - assert finding["service"] == "cosmos-db" - assert finding["type"] == "Pitfall" - assert finding["source"] == "Deploy failure: Stage 2" - assert "cosmos-db" in finding["file"] - assert "400" in finding["context"] - assert "400" in finding["content"] - - def test_truncates_long_content(self): - from azext_prototype.stages.knowledge_contributor import build_finding_from_qa - - long_text = "X" * 1000 - finding = build_finding_from_qa(long_text, service="redis") - - assert len(finding["context"]) <= 500 - assert len(finding["content"]) <= 200 - - def test_empty_qa_text(self): - from azext_prototype.stages.knowledge_contributor import build_finding_from_qa - - finding = build_finding_from_qa("", service="redis") - assert finding["context"] == "" - assert finding["content"] == "" - - def test_defaults(self): - from azext_prototype.stages.knowledge_contributor import build_finding_from_qa - - finding = build_finding_from_qa("some content") - assert finding["service"] == "unknown" - assert finding["source"] == "QA diagnosis" - - -# ====================================================================== -# TestSubmitIfGap -# ====================================================================== - - -class TestSubmitIfGap: - """Tests for ``submit_if_gap()``.""" - - def test_submits_when_gap(self): - from azext_prototype.stages.knowledge_contributor import submit_if_gap - - loader = _make_loader("") # no content = gap - printed: list[str] = [] - - with patch(f"{_BP_MODULE}.subprocess.run") as mock_auth, patch(f"{_KC_MODULE}.subprocess.run") as mock_create: - mock_auth.return_value = MagicMock(returncode=0) - mock_create.return_value = MagicMock( - returncode=0, - stdout="https://github.com/Azure/az-prototype/issues/7\n", - ) - - result = submit_if_gap( - _make_finding(), - loader, - print_fn=printed.append, - ) - - assert result is not None - assert result["url"] == "https://github.com/Azure/az-prototype/issues/7" - assert any("submitted" in p.lower() for p in printed) - - def test_skips_when_no_gap(self): - from azext_prototype.stages.knowledge_contributor import submit_if_gap - - # Content already exists in knowledge file - finding = _make_finding() - loader = _make_loader(finding["context"][:80].lower() + " more details") - printed: list[str] = [] - - result = submit_if_gap(finding, loader, print_fn=printed.append) - - assert result is None - assert len(printed) == 0 - - def test_never_raises(self): - from azext_prototype.stages.knowledge_contributor import submit_if_gap - - # Loader throws an exception - loader = MagicMock() - loader.load_service.side_effect = RuntimeError("kaboom") - - # Even if gap check raises inside, submit_if_gap should not propagate - # Actually check_knowledge_gap catches it and returns True, then - # submit_contribution is called — let's make that fail too - with patch(f"{_KC_MODULE}.submit_contribution") as mock_submit: - mock_submit.side_effect = RuntimeError("double kaboom") - - result = submit_if_gap(_make_finding(), loader) - - # Should return None, not raise - assert result is None - - def test_no_print_when_no_url(self): - from azext_prototype.stages.knowledge_contributor import submit_if_gap - - loader = _make_loader("") # gap - printed: list[str] = [] - - with patch(f"{_BP_MODULE}.subprocess.run") as mock_auth, patch(f"{_KC_MODULE}.subprocess.run") as mock_create: - mock_auth.return_value = MagicMock(returncode=0) - mock_create.return_value = MagicMock( - returncode=1, - stderr="error", - stdout="", - ) - - submit_if_gap( - _make_finding(), - loader, - print_fn=printed.append, - ) - - # Error result, no URL to print - assert len(printed) == 0 - - -# ====================================================================== -# TestKnowledgeContributeCommand -# ====================================================================== - - -class TestKnowledgeContributeCommand: - """Tests for ``prototype_knowledge_contribute()`` CLI command.""" - - def test_draft_mode(self, project_with_config): - from azext_prototype.custom import prototype_knowledge_contribute - - cmd = MagicMock() - with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)): - result = prototype_knowledge_contribute( - cmd, - service="cosmos-db", - description="RU throughput must be >= 400", - draft=True, - json_output=True, - ) - - assert result["status"] == "draft" - assert "cosmos-db" in result["title"] - - def test_noninteractive_submit(self, project_with_config): - from azext_prototype.custom import prototype_knowledge_contribute - - cmd = MagicMock() - with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)), patch( - f"{_BP_MODULE}.subprocess.run" - ) as mock_auth, patch(f"{_KC_MODULE}.subprocess.run") as mock_create: - mock_auth.return_value = MagicMock(returncode=0) - mock_create.return_value = MagicMock( - returncode=0, - stdout="https://github.com/Azure/az-prototype/issues/55\n", - ) - - result = prototype_knowledge_contribute( - cmd, - service="cosmos-db", - description="RU throughput must be >= 400", - json_output=True, - ) - - assert result["status"] == "submitted" - assert result["url"] == "https://github.com/Azure/az-prototype/issues/55" - - def test_gh_not_authed_raises(self, project_with_config): - from knack.util import CLIError - - from azext_prototype.custom import prototype_knowledge_contribute - - cmd = MagicMock() - with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)), patch( - f"{_BP_MODULE}.subprocess.run" - ) as mock_auth: - mock_auth.return_value = MagicMock(returncode=1) - - with pytest.raises(CLIError, match="not authenticated"): - prototype_knowledge_contribute( - cmd, - service="cosmos-db", - description="RU throughput", - ) - - def test_file_input(self, project_with_config): - from azext_prototype.custom import prototype_knowledge_contribute - - # Create a finding file - finding_file = project_with_config / "finding.md" - finding_file.write_text( - "Service: cosmos-db\nContext: RU must be >= 400\nContent: min_ru = 400", - encoding="utf-8", - ) - - cmd = MagicMock() - with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)): - result = prototype_knowledge_contribute( - cmd, - file=str(finding_file), - draft=True, - json_output=True, - ) - - assert result["status"] == "draft" - - def test_file_not_found_raises(self, project_with_config): - from knack.util import CLIError - - from azext_prototype.custom import prototype_knowledge_contribute - - cmd = MagicMock() - with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)): - with pytest.raises(CLIError, match="not found"): - prototype_knowledge_contribute( - cmd, - file="/nonexistent/path/finding.md", - draft=True, - ) - - def test_contribution_type_forwarded(self, project_with_config): - from azext_prototype.custom import prototype_knowledge_contribute - - cmd = MagicMock() - with patch(f"{_CUSTOM_MODULE}._get_project_dir", return_value=str(project_with_config)): - result = prototype_knowledge_contribute( - cmd, - service="redis", - description="Cache eviction pitfall", - contribution_type="Service pattern update", - section="Pitfalls", - draft=True, - json_output=True, - ) - - assert result["status"] == "draft" - assert "Service pattern update" in result["body"] - assert "Pitfalls" in result["body"] diff --git a/tests/test_qa_router.py b/tests/test_qa_router.py deleted file mode 100644 index 5fa8a03..0000000 --- a/tests/test_qa_router.py +++ /dev/null @@ -1,727 +0,0 @@ -"""Tests for azext_prototype.stages.qa_router — shared QA error routing.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from azext_prototype.agents.base import AgentContext -from azext_prototype.ai.provider import AIResponse -from azext_prototype.stages.qa_router import route_error_to_qa - -# ====================================================================== -# Helpers -# ====================================================================== - - -def _make_response(content: str = "Root cause: X. Fix: do Y.") -> AIResponse: - return AIResponse(content=content, model="gpt-4o", usage={}) - - -def _make_qa_agent(response: AIResponse | None = None, raises: Exception | None = None): - agent = MagicMock() - agent.name = "qa-engineer" - if raises: - agent.execute.side_effect = raises - else: - agent.execute.return_value = response or _make_response() - return agent - - -def _make_context(): - return AgentContext( - project_config={"project": {"name": "test"}}, - project_dir="/tmp/test", - ai_provider=MagicMock(), - ) - - -def _make_tracker(): - tracker = MagicMock() - return tracker - - -# ====================================================================== -# Core routing tests -# ====================================================================== - - -class TestRouteErrorToQA: - """Tests for route_error_to_qa().""" - - def test_qa_agent_available_diagnoses_error(self): - qa = _make_qa_agent() - ctx = _make_context() - tracker = _make_tracker() - printed = [] - - result = route_error_to_qa( - "Something broke", - "Build Stage 1", - qa, - ctx, - tracker, - printed.append, - ) - - assert result["diagnosed"] is True - assert result["content"] == "Root cause: X. Fix: do Y." - assert result["response"] is not None - qa.execute.assert_called_once() - tracker.record.assert_called_once() - - def test_qa_agent_none_returns_graceful_fallback(self): - ctx = _make_context() - printed = [] - - result = route_error_to_qa( - "Something broke", - "Build Stage 1", - None, - ctx, - None, - printed.append, - ) - - assert result["diagnosed"] is False - assert result["content"] == "Something broke" - assert result["response"] is None - assert len(printed) == 0 # no output when undiagnosed - - def test_string_error_input(self): - qa = _make_qa_agent() - ctx = _make_context() - printed = [] - - result = route_error_to_qa( - "Connection refused", - "Deploy Stage 2", - qa, - ctx, - None, - printed.append, - ) - - assert result["diagnosed"] is True - assert "Connection refused" in qa.execute.call_args[0][1] - - def test_exception_error_input(self): - qa = _make_qa_agent() - ctx = _make_context() - printed = [] - - result = route_error_to_qa( - ValueError("bad value"), - "Build Stage 3", - qa, - ctx, - None, - printed.append, - ) - - assert result["diagnosed"] is True - assert "bad value" in qa.execute.call_args[0][1] - - def test_long_error_truncated_at_max_chars(self): - qa = _make_qa_agent() - ctx = _make_context() - printed = [] - - long_error = "x" * 5000 - - result = route_error_to_qa( - long_error, - "Build Stage 1", - qa, - ctx, - None, - printed.append, - max_error_chars=100, - ) - - assert result["diagnosed"] is True - task_text = qa.execute.call_args[0][1] - # The error in the task should be truncated - assert "x" * 100 in task_text - assert "x" * 5000 not in task_text - - def test_qa_agent_raises_returns_undiagnosed(self): - qa = _make_qa_agent(raises=RuntimeError("QA crashed")) - ctx = _make_context() - printed = [] - - result = route_error_to_qa( - "Original error", - "Build Stage 1", - qa, - ctx, - None, - printed.append, - ) - - assert result["diagnosed"] is False - assert result["content"] == "Original error" - assert result["response"] is None - - def test_token_tracker_records_response(self): - qa = _make_qa_agent() - ctx = _make_context() - tracker = _make_tracker() - - route_error_to_qa( - "error", - "context", - qa, - ctx, - tracker, - lambda m: None, - ) - - tracker.record.assert_called_once() - - def test_token_tracker_none_does_not_crash(self): - qa = _make_qa_agent() - ctx = _make_context() - - result = route_error_to_qa( - "error", - "context", - qa, - ctx, - None, - lambda m: None, - ) - - assert result["diagnosed"] is True - - def test_print_fn_called_with_diagnosis(self): - qa = _make_qa_agent(_make_response("Fix: restart the service")) - ctx = _make_context() - printed = [] - - route_error_to_qa( - "error", - "context", - qa, - ctx, - None, - printed.append, - ) - - assert any("QA Diagnosis" in p for p in printed) - assert any("Fix: restart the service" in p for p in printed) - - def test_display_truncated_at_max_display_chars(self): - long_response = "a" * 3000 - qa = _make_qa_agent(_make_response(long_response)) - ctx = _make_context() - printed = [] - - route_error_to_qa( - "error", - "context", - qa, - ctx, - None, - printed.append, - max_display_chars=500, - ) - - # One of the printed lines should be truncated - display_lines = [p for p in printed if "a" in p] - assert any(len(p) <= 500 for p in display_lines) - - def test_no_ai_provider_returns_undiagnosed(self): - qa = _make_qa_agent() - ctx = _make_context() - ctx.ai_provider = None - printed = [] - - result = route_error_to_qa( - "error", - "context", - qa, - ctx, - None, - printed.append, - ) - - assert result["diagnosed"] is False - - def test_empty_error_uses_unknown(self): - qa = _make_qa_agent() - ctx = _make_context() - - result = route_error_to_qa( - "", - "context", - qa, - ctx, - None, - lambda m: None, - ) - - assert result["diagnosed"] is True - # Should have used "Unknown error" - task_text = qa.execute.call_args[0][1] - assert "Unknown error" in task_text - - def test_none_error_uses_unknown(self): - qa = _make_qa_agent() - ctx = _make_context() - - result = route_error_to_qa( - None, - "context", - qa, - ctx, - None, - lambda m: None, - ) - - assert result["diagnosed"] is True - task_text = qa.execute.call_args[0][1] - assert "Unknown error" in task_text - - def test_qa_returns_empty_content(self): - qa = _make_qa_agent(_make_response("")) - ctx = _make_context() - printed = [] - - result = route_error_to_qa( - "error", - "context", - qa, - ctx, - None, - printed.append, - ) - - assert result["diagnosed"] is False - - @patch("azext_prototype.stages.qa_router._submit_knowledge") - def test_knowledge_contribution_attempted(self, mock_submit): - qa = _make_qa_agent() - ctx = _make_context() - - route_error_to_qa( - "error", - "Build Stage 1", - qa, - ctx, - None, - lambda m: None, - services=["key-vault"], - ) - - mock_submit.assert_called_once() - args = mock_submit.call_args[0] - assert args[0] == "Root cause: X. Fix: do Y." - assert args[1] == "Build Stage 1" - assert args[2] == ["key-vault"] - - @patch("azext_prototype.stages.qa_router._submit_knowledge", side_effect=Exception("boom")) - def test_knowledge_failure_swallowed(self, mock_submit): - qa = _make_qa_agent() - ctx = _make_context() - - # Should not raise - result = route_error_to_qa( - "error", - "context", - qa, - ctx, - None, - lambda m: None, - services=["svc"], - ) - - assert result["diagnosed"] is True - - def test_services_none_no_knowledge_submitted(self): - qa = _make_qa_agent() - ctx = _make_context() - - with patch("azext_prototype.stages.qa_router._submit_knowledge") as mock_submit: - route_error_to_qa( - "error", - "context", - qa, - ctx, - None, - lambda m: None, - ) - - mock_submit.assert_called_once() - # services should be None - assert mock_submit.call_args[0][2] is None - - def test_context_label_in_task_prompt(self): - qa = _make_qa_agent() - ctx = _make_context() - - route_error_to_qa( - "error", - "Deploy Stage 5: Redis Cache", - qa, - ctx, - None, - lambda m: None, - ) - - task_text = qa.execute.call_args[0][1] - assert "Deploy Stage 5: Redis Cache" in task_text - - def test_token_tracker_record_failure_swallowed(self): - qa = _make_qa_agent() - ctx = _make_context() - tracker = MagicMock() - tracker.record.side_effect = Exception("tracker boom") - - # Should not raise - result = route_error_to_qa( - "error", - "context", - qa, - ctx, - tracker, - lambda m: None, - ) - - assert result["diagnosed"] is True - - -# ====================================================================== -# Integration: Build session QA routing -# ====================================================================== - - -class TestBuildSessionQARouting: - """Test that build session routes errors through qa_router.""" - - def _make_session(self, tmp_project, qa_agent=None, response=None): - from azext_prototype.stages.build_session import BuildSession - from azext_prototype.stages.build_state import BuildState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - registry = MagicMock() - - # IaC agent that fails - iac_agent = MagicMock() - iac_agent.name = "terraform-agent" - if response is not None: - iac_agent.execute.return_value = response - else: - iac_agent.execute.side_effect = RuntimeError("AI exploded") - - doc_agent = MagicMock() - doc_agent.name = "doc-agent" - doc_agent.execute.return_value = _make_response("# Docs") - - qa = qa_agent or _make_qa_agent() - - def find_by_cap(cap): - from azext_prototype.agents.base import AgentCapability - - if cap == AgentCapability.TERRAFORM: - return [iac_agent] - if cap == AgentCapability.QA: - return [qa] - if cap == AgentCapability.DOCUMENT: - return [doc_agent] - if cap == AgentCapability.ARCHITECT: - return [] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - build_state = BuildState(str(tmp_project)) - build_state.set_deployment_plan( - [ - { - "stage": 1, - "name": "Foundation", - "category": "infra", - "dir": "concept/infra/terraform/stage-1-foundation", - "services": [{"name": "key-vault", "computed_name": "kv-1", "resource_type": "", "sku": ""}], - "status": "pending", - "files": [], - }, - ] - ) - - with patch("azext_prototype.stages.build_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": "terraform", - "project.name": "test", - }.get(k, d) - mock_config.return_value.to_dict.return_value = { - "naming": {"strategy": "simple"}, - "project": {"name": "test"}, - } - session = BuildSession(ctx, registry, build_state=build_state) - - return session, qa - - @patch("azext_prototype.stages.qa_router._submit_knowledge") - def test_stage_generation_failure_routes_to_qa(self, mock_knowledge, tmp_project): - session, qa = self._make_session(tmp_project) - printed = [] - - session.run( - design={"architecture": "Simple web app"}, - input_fn=lambda p: "done", - print_fn=printed.append, - ) - - qa.execute.assert_called() - assert any("QA Diagnosis" in p for p in printed) - - @patch("azext_prototype.stages.qa_router._submit_knowledge") - def test_empty_response_routes_to_qa(self, mock_knowledge, tmp_project): - empty_resp = AIResponse(content="", model="gpt-4o", usage={}) - session, qa = self._make_session(tmp_project, response=empty_resp) - printed = [] - - session.run( - design={"architecture": "Simple web app"}, - input_fn=lambda p: "done", - print_fn=printed.append, - ) - - # QA should be called for empty response - qa.execute.assert_called() - - -# ====================================================================== -# Integration: Discovery session QA routing -# ====================================================================== - - -class TestDiscoveryQARouting: - """Test that discovery routes non-vision errors through qa_router.""" - - @patch("azext_prototype.stages.qa_router._submit_knowledge") - def test_non_vision_error_routes_to_qa(self, mock_knowledge, tmp_project): - from azext_prototype.stages.discovery import DiscoverySession - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - biz_agent = MagicMock() - biz_agent.name = "biz-analyst" - biz_agent.capabilities = [] - biz_agent._temperature = 0.5 - biz_agent._max_tokens = 8192 - biz_agent.get_system_messages.return_value = [] - - qa = _make_qa_agent() - - registry = MagicMock() - - from azext_prototype.agents.base import AgentCapability - - def find_by_cap(cap): - if cap == AgentCapability.BIZ_ANALYSIS: - return [biz_agent] - if cap == AgentCapability.QA: - return [qa] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - ctx.ai_provider.chat.side_effect = RuntimeError("API error") - - session = DiscoverySession(ctx, registry) - - with pytest.raises(RuntimeError, match="API error"): - session.run( - seed_context="test", - input_fn=lambda p: "done", - print_fn=lambda m: None, - ) - - # QA should have been called for the error diagnosis - qa.execute.assert_called_once() - - -# ====================================================================== -# Integration: Backlog session QA routing -# ====================================================================== - - -class TestBacklogQARouting: - """Test that backlog session routes errors through qa_router.""" - - def _make_session(self, tmp_project, items_response="[]"): - from azext_prototype.stages.backlog_session import BacklogSession - from azext_prototype.stages.backlog_state import BacklogState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - pm = MagicMock() - pm.name = "project-manager" - pm.get_system_messages.return_value = [] - qa = _make_qa_agent() - - registry = MagicMock() - from azext_prototype.agents.base import AgentCapability - - def find_by_cap(cap): - if cap == AgentCapability.BACKLOG_GENERATION: - return [pm] - if cap == AgentCapability.QA: - return [qa] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - ctx.ai_provider.chat.return_value = AIResponse( - content=items_response, - model="gpt-4o", - usage={"prompt_tokens": 10, "completion_tokens": 5}, - ) - - session = BacklogSession(ctx, registry, backlog_state=BacklogState(str(tmp_project))) - return session, qa, ctx - - @patch("azext_prototype.stages.qa_router._submit_knowledge") - def test_empty_parse_triggers_qa(self, mock_knowledge, tmp_project): - session, qa, ctx = self._make_session(tmp_project, items_response="not valid json at all") - printed = [] - - result = session.run( - design_context="web app architecture", - input_fn=lambda p: "done", - print_fn=printed.append, - ) - - qa.execute.assert_called() - assert result.cancelled is True - - @patch("azext_prototype.stages.qa_router._submit_knowledge") - @patch("azext_prototype.stages.backlog_session.check_gh_auth", return_value=True) - @patch("azext_prototype.stages.backlog_session.push_github_issue") - def test_push_error_triggers_qa(self, mock_push, mock_auth, mock_knowledge, tmp_project): - import json - - items = [{"epic": "Infra", "title": "Setup VNet", "description": "Create VNet", "tasks": [], "effort": "M"}] - session, qa, ctx = self._make_session(tmp_project, items_response=json.dumps(items)) - - mock_push.return_value = {"error": "gh: auth required"} - - printed = [] - session.run( - design_context="web app", - provider="github", - org="myorg", - project="myrepo", - quick=True, - input_fn=lambda p: "y", - print_fn=printed.append, - ) - - qa.execute.assert_called() - - -# ====================================================================== -# Integration: Deploy session refactored QA routing -# ====================================================================== - - -class TestDeploySessionRefactoredQA: - """Test that refactored deploy session still works correctly.""" - - def test_handle_deploy_failure_uses_qa_router(self, tmp_project): - from azext_prototype.stages.deploy_session import DeploySession - from azext_prototype.stages.deploy_state import DeployState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - qa = _make_qa_agent(_make_response("Root cause: missing permissions")) - registry = MagicMock() - from azext_prototype.agents.base import AgentCapability - - def find_by_cap(cap): - if cap == AgentCapability.QA: - return [qa] - return [] - - registry.find_by_capability.side_effect = find_by_cap - - with patch("azext_prototype.stages.deploy_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": "terraform", - }.get(k, d) - session = DeploySession(ctx, registry, deploy_state=DeployState(str(tmp_project))) - - printed = [] - stage = {"stage": 1, "name": "Foundation", "services": [{"name": "rg"}]} - result = {"error": "Deployment failed: access denied"} - - session._handle_deploy_failure( - stage, - result, - False, - printed.append, - lambda p: "", - ) - - qa.execute.assert_called_once() - assert any("QA Diagnosis" in p for p in printed) - assert any("missing permissions" in p for p in printed) - assert any("Options:" in p for p in printed) - - def test_handle_deploy_failure_no_qa_shows_error(self, tmp_project): - from azext_prototype.stages.deploy_session import DeploySession - from azext_prototype.stages.deploy_state import DeployState - - ctx = AgentContext( - project_config={"project": {"name": "test", "location": "eastus"}}, - project_dir=str(tmp_project), - ai_provider=MagicMock(), - ) - - registry = MagicMock() - registry.find_by_capability.return_value = [] - - with patch("azext_prototype.stages.deploy_session.ProjectConfig") as mock_config: - mock_config.return_value.load.return_value = None - mock_config.return_value.get.side_effect = lambda k, d=None: { - "project.iac_tool": "terraform", - }.get(k, d) - session = DeploySession(ctx, registry, deploy_state=DeployState(str(tmp_project))) - - printed = [] - stage = {"stage": 1, "name": "Foundation", "services": []} - result = {"error": "access denied"} - - session._handle_deploy_failure( - stage, - result, - False, - printed.append, - lambda p: "", - ) - - assert any("Error:" in p for p in printed) - assert any("Options:" in p for p in printed) diff --git a/tests/test_stages_extended.py b/tests/test_stages_extended.py deleted file mode 100644 index 11d38a2..0000000 --- a/tests/test_stages_extended.py +++ /dev/null @@ -1,558 +0,0 @@ -"""Tests for deploy_stage.py, build_stage.py, and init_stage.py — full coverage.""" - -from unittest.mock import MagicMock, patch - -import pytest -from knack.util import CLIError - -from azext_prototype.ai.provider import AIResponse -from azext_prototype.stages.build_session import BuildResult - -# ====================================================================== -# DeployStage -# ====================================================================== - - -class TestDeployStageExecution: - """Test DeployStage orchestration and deploy_helpers functions.""" - - def _make_stage(self): - from azext_prototype.stages.deploy_stage import DeployStage - - return DeployStage() - - def test_deploy_guards(self): - stage = self._make_stage() - guards = stage.get_guards() - names = [g.name for g in guards] - assert "project_initialized" in names - assert "build_complete" in names - assert "az_logged_in" in names - - @patch("subprocess.run") - def test_check_az_login_true(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - mock_run.return_value = MagicMock(returncode=0) - assert check_az_login() is True - - @patch("subprocess.run") - def test_check_az_login_false(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - mock_run.return_value = MagicMock(returncode=1) - assert check_az_login() is False - - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_check_az_login_not_installed(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - assert check_az_login() is False - - @patch("subprocess.run") - def test_get_current_subscription(self, mock_run): - from azext_prototype.stages.deploy_helpers import get_current_subscription - - mock_run.return_value = MagicMock(returncode=0, stdout="abc-123\n") - result = get_current_subscription() - assert result == "abc-123" - - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_get_current_subscription_not_installed(self, mock_run): - from azext_prototype.stages.deploy_helpers import get_current_subscription - - assert get_current_subscription() == "" - - @patch("subprocess.run") - def test_deploy_terraform_success(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_terraform - - infra_dir = tmp_path / "tf" - infra_dir.mkdir() - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - - result = deploy_terraform(infra_dir, "sub-123") - assert result["status"] == "deployed" - - @patch("subprocess.run") - def test_deploy_terraform_failure(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_terraform - - infra_dir = tmp_path / "tf" - infra_dir.mkdir() - mock_run.return_value = MagicMock(returncode=1, stderr="init failed", stdout="") - - result = deploy_terraform(infra_dir, "sub-123") - assert result["status"] == "failed" - - @patch("subprocess.run") - def test_deploy_bicep_failure(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_bicep - - (tmp_path / "main.bicep").write_text("resource x 'y' = {}", encoding="utf-8") - mock_run.return_value = MagicMock(returncode=1, stderr="Deployment failed", stdout="") - - result = deploy_bicep(tmp_path, "sub-123", "my-rg") - assert result["status"] == "failed" - - def test_deploy_app_stage_with_deploy_script(self, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_app_stage - - app_dir = tmp_path / "app" - app_dir.mkdir() - (app_dir / "deploy.sh").write_text("echo deployed", encoding="utf-8") - - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - result = deploy_app_stage(app_dir, "sub-123", "my-rg") - assert result["status"] == "deployed" - - def test_deploy_app_stage_sub_apps(self, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_app_stage - - stage_dir = tmp_path / "stage" - stage_dir.mkdir() - backend = stage_dir / "backend" - backend.mkdir() - (backend / "deploy.sh").write_text("echo ok", encoding="utf-8") - - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - result = deploy_app_stage(stage_dir, "sub-123", "my-rg") - assert result["status"] == "deployed" - assert "backend" in result["apps"] - - def test_deploy_app_stage_no_scripts(self, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_app_stage - - empty_dir = tmp_path / "empty" - empty_dir.mkdir() - result = deploy_app_stage(empty_dir, "sub-123", "my-rg") - assert result["status"] == "skipped" - - @patch("subprocess.run") - def test_whatif_bicep_no_files(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import whatif_bicep - - empty_dir = tmp_path / "empty" - empty_dir.mkdir() - result = whatif_bicep(empty_dir, "sub-123", "my-rg") - assert result["status"] == "skipped" - - @patch("subprocess.run") - def test_whatif_bicep_no_rg_skips(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import whatif_bicep - - (tmp_path / "main.bicep").write_text("resource x 'y' = {}", encoding="utf-8") - result = whatif_bicep(tmp_path, "sub-123", "") - assert result["status"] == "skipped" - - def test_get_deploy_location_main_params(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - (tmp_path / "main.parameters.json").write_text( - '{"parameters": {"location": {"value": "northeurope"}}}', encoding="utf-8" - ) - result = get_deploy_location(tmp_path) - assert result == "northeurope" - - def test_get_deploy_location_string_value(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - (tmp_path / "parameters.json").write_text('{"location": "uksouth"}', encoding="utf-8") - result = get_deploy_location(tmp_path) - assert result == "uksouth" - - def test_execute_status(self, project_with_build, mock_agent_context, populated_registry): - """Deploy with --status shows state and returns.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - mock_agent_context.project_dir = str(project_with_build) - - result = stage.execute( - mock_agent_context, - populated_registry, - status=True, - ) - assert result["status"] == "status_displayed" - - def test_execute_reset(self, project_with_build, mock_agent_context, populated_registry): - """Deploy with --reset clears state and returns.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - mock_agent_context.project_dir = str(project_with_build) - - result = stage.execute( - mock_agent_context, - populated_registry, - reset=True, - ) - assert result["status"] == "reset" - - -# ====================================================================== -# BuildStage -# ====================================================================== - - -class TestBuildStageExecution: - """Test BuildStage methods.""" - - def _make_stage(self): - from azext_prototype.stages.build_stage import BuildStage - - return BuildStage() - - def test_build_guards(self): - stage = self._make_stage() - guards = stage.get_guards() - names = [g.name for g in guards] - assert "project_initialized" in names - assert "discovery_complete" in names - assert "design_complete" in names - - def test_load_design(self, project_with_design): - stage = self._make_stage() - design = stage._load_design(str(project_with_design)) - assert "architecture" in design - - def test_load_design_missing(self, tmp_project): - stage = self._make_stage() - result = stage._load_design(str(tmp_project)) - assert result == {} - - def test_execute_no_design_raises(self, project_with_config, mock_agent_context, populated_registry): - stage = self._make_stage() - stage.get_guards = lambda: [] - mock_agent_context.project_dir = str(project_with_config) - - with pytest.raises(CLIError, match="No architecture design"): - stage.execute(mock_agent_context, populated_registry) - - def test_execute_dry_run(self, project_with_design, mock_agent_context, populated_registry): - stage = self._make_stage() - stage.get_guards = lambda: [] - mock_agent_context.project_dir = str(project_with_design) - mock_agent_context.ai_provider.chat.return_value = AIResponse(content="Generated code", model="gpt-4o") - - result = stage.execute(mock_agent_context, populated_registry, scope="docs", dry_run=True) - assert result["status"] == "dry-run" - - def test_execute_all_scopes_dry_run(self, project_with_design, mock_agent_context, populated_registry): - stage = self._make_stage() - stage.get_guards = lambda: [] - mock_agent_context.project_dir = str(project_with_design) - - result = stage.execute(mock_agent_context, populated_registry, scope="all", dry_run=True) - assert result["status"] == "dry-run" - assert result["scope"] == "all" - - @patch("azext_prototype.stages.build_stage.BuildSession") - def test_execute_interactive_delegates_to_session( - self, mock_session_cls, project_with_design, mock_agent_context, populated_registry - ): - stage = self._make_stage() - stage.get_guards = lambda: [] - mock_agent_context.project_dir = str(project_with_design) - - mock_result = BuildResult( - files_generated=["main.tf"], - deployment_stages=[{"stage": 1, "name": "Foundation"}], - policy_overrides=[], - resources=[{"resourceType": "Microsoft.Compute/virtualMachines", "sku": "Standard_B2s"}], - review_accepted=True, - cancelled=False, - ) - mock_session_cls.return_value.run.return_value = mock_result - - result = stage.execute(mock_agent_context, populated_registry, scope="all", dry_run=False) - assert result["status"] == "success" - assert result["scope"] == "all" - assert result["files_generated"] == ["main.tf"] - mock_session_cls.return_value.run.assert_called_once() - - -# ====================================================================== -# InitStage -# ====================================================================== - - -class TestInitStageExecution: - """Test InitStage methods.""" - - def _make_stage(self): - from azext_prototype.stages.init_stage import InitStage - - return InitStage() - - def test_init_guards(self): - """Init has no unconditional guards; gh check is conditional inside execute().""" - stage = self._make_stage() - guards = stage.get_guards() - assert len(guards) == 0 - - @patch("subprocess.run") - def test_check_gh_true(self, mock_run): - stage = self._make_stage() - mock_run.return_value = MagicMock(returncode=0) - assert stage._check_gh() is True - - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_check_gh_false(self, mock_run): - stage = self._make_stage() - assert stage._check_gh() is False - - def test_create_scaffold(self, tmp_path): - stage = self._make_stage() - project_dir = tmp_path / "my-project" - stage._create_scaffold(project_dir) - - assert (project_dir / "concept" / "docs").is_dir() - assert (project_dir / ".prototype" / "agents").is_dir() - # infra, apps, db dirs are NOT created at init — only during build - assert not (project_dir / "concept" / "apps").exists() - assert not (project_dir / "concept" / "infra").exists() - assert not (project_dir / "concept" / "db").exists() - - def test_create_gitignore(self, tmp_path): - stage = self._make_stage() - stage._create_gitignore(tmp_path) - gi = tmp_path / ".gitignore" - assert gi.exists() - content = gi.read_text() - assert ".terraform/" in content - assert "__pycache__/" in content - - def test_create_gitignore_no_overwrite(self, tmp_path): - stage = self._make_stage() - gi = tmp_path / ".gitignore" - gi.write_text("custom content", encoding="utf-8") - stage._create_gitignore(tmp_path) - assert gi.read_text() == "custom content" - - @patch("azext_prototype.auth.copilot_license.CopilotLicenseValidator") - @patch("azext_prototype.auth.github_auth.GitHubAuthManager") - @patch("azext_prototype.stages.init_stage.InitStage._check_gh", return_value=True) - def test_execute_full(self, mock_gh, mock_auth_cls, mock_lic_cls, tmp_path): - stage = self._make_stage() - stage.get_guards = lambda: [] - - mock_auth = MagicMock() - mock_auth.ensure_authenticated.return_value = {"login": "devuser"} - mock_auth_cls.return_value = mock_auth - mock_lic = MagicMock() - mock_lic.validate_license.return_value = {"plan": "business"} - mock_lic_cls.return_value = mock_lic - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - - ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) - registry = AgentRegistry() - - out = tmp_path / "test-proj" - result = stage.execute( - ctx, - registry, - name="test-proj", - location="westus2", - iac_tool="bicep", - ai_provider="github-models", - output_dir=str(out), - ) - assert result["status"] == "success" - assert (out / "prototype.yaml").exists() - - @patch("azext_prototype.auth.copilot_license.CopilotLicenseValidator") - @patch("azext_prototype.auth.github_auth.GitHubAuthManager") - @patch("azext_prototype.stages.init_stage.InitStage._check_gh", return_value=True) - def test_execute_license_failure_continues(self, mock_gh, mock_auth_cls, mock_lic_cls, tmp_path): - """License validation failure should warn but continue.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - - mock_auth = MagicMock() - mock_auth.ensure_authenticated.return_value = {"login": "devuser"} - mock_auth_cls.return_value = mock_auth - mock_lic = MagicMock() - mock_lic.validate_license.side_effect = CLIError("No license") - mock_lic_cls.return_value = mock_lic - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - - ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) - registry = AgentRegistry() - - result = stage.execute( - ctx, - registry, - name="lic-test", - location="eastus", - ai_provider="github-models", - output_dir=str(tmp_path / "lic-test"), - ) - assert result["status"] == "success" - assert result["copilot_license"]["status"] == "unverified" - - def test_execute_no_name_raises(self, tmp_path): - stage = self._make_stage() - stage.get_guards = lambda: [] - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - - ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) - registry = AgentRegistry() - - with pytest.raises(CLIError, match="Project name"): - stage.execute(ctx, registry, name="", output_dir=str(tmp_path / "empty-name")) - - def test_execute_no_location_raises(self, tmp_path): - stage = self._make_stage() - stage.get_guards = lambda: [] - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - - ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) - registry = AgentRegistry() - - with pytest.raises(CLIError, match="region is required"): - stage.execute( - ctx, - registry, - name="test-proj", - location=None, - output_dir=str(tmp_path / "test-proj"), - ) - - def test_execute_azure_openai_skips_auth(self, tmp_path): - """azure-openai provider should skip GitHub auth entirely.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - - ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) - registry = AgentRegistry() - - result = stage.execute( - ctx, - registry, - name="aoai-test", - location="eastus", - ai_provider="azure-openai", - output_dir=str(tmp_path / "aoai-test"), - ) - assert result["status"] == "success" - assert result["github_user"] is None - assert "copilot_license" not in result - - def test_execute_environment_stored(self, tmp_path): - """--environment should be persisted in config.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.config import ProjectConfig - - ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) - registry = AgentRegistry() - - out = tmp_path / "env-test" - stage.execute( - ctx, - registry, - name="env-test", - location="westus2", - ai_provider="azure-openai", - environment="prod", - output_dir=str(out), - ) - config = ProjectConfig(str(out)) - config.load() - assert config.get("project.environment") == "prod" - assert config.get("naming.env") == "prd" - assert config.get("naming.zone_id") == "zp" - - def test_execute_model_override(self, tmp_path): - """Explicit --model should override provider default.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.config import ProjectConfig - - ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) - registry = AgentRegistry() - - out = tmp_path / "model-test" - stage.execute( - ctx, - registry, - name="model-test", - location="eastus", - ai_provider="azure-openai", - model="gpt-4o-mini", - output_dir=str(out), - ) - config = ProjectConfig(str(out)) - config.load() - assert config.get("ai.model") == "gpt-4o-mini" - - def test_execute_idempotency_cancel(self, tmp_path): - """Existing project + user declining should cancel.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - - # Pre-create project directory with config - proj = tmp_path / "idem-test" - proj.mkdir() - (proj / "prototype.yaml").write_text("project:\n name: old\n") - - ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) - registry = AgentRegistry() - - with patch("builtins.input", return_value="n"): - result = stage.execute( - ctx, - registry, - name="idem-test", - location="eastus", - ai_provider="azure-openai", - output_dir=str(proj), - ) - assert result["status"] == "cancelled" - - def test_execute_marks_init_complete(self, tmp_path): - """Init stage should set stages.init.completed and timestamp.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - - from azext_prototype.agents.base import AgentContext - from azext_prototype.agents.registry import AgentRegistry - from azext_prototype.config import ProjectConfig - - ctx = AgentContext(project_config={}, project_dir=str(tmp_path), ai_provider=None) - registry = AgentRegistry() - - out = tmp_path / "complete-test" - stage.execute( - ctx, - registry, - name="complete-test", - location="eastus", - ai_provider="azure-openai", - output_dir=str(out), - ) - config = ProjectConfig(str(out)) - config.load() - assert config.get("stages.init.completed") is True - assert config.get("stages.init.timestamp") is not None diff --git a/tests/test_console.py b/tests/ui/test_console.py similarity index 100% rename from tests/test_console.py rename to tests/ui/test_console.py diff --git a/tests/test_prompt_input.py b/tests/ui/test_prompt_input.py similarity index 100% rename from tests/test_prompt_input.py rename to tests/ui/test_prompt_input.py diff --git a/tests/test_stage_orchestrator.py b/tests/ui/test_stage_orchestrator.py similarity index 100% rename from tests/test_stage_orchestrator.py rename to tests/ui/test_stage_orchestrator.py diff --git a/tests/test_tui_adapter.py b/tests/ui/test_tui_adapter.py similarity index 100% rename from tests/test_tui_adapter.py rename to tests/ui/test_tui_adapter.py diff --git a/tests/test_tui_widgets.py b/tests/ui/test_tui_widgets.py similarity index 100% rename from tests/test_tui_widgets.py rename to tests/ui/test_tui_widgets.py From 597f9edc453d0cb9d0e6f2b700e3478a1b374658 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Wed, 8 Apr 2026 15:09:27 -0400 Subject: [PATCH 07/12] Consolidate test suite: migrate to mirrored directories, remove duplicates Migrated flat test files to 1:1 test-to-source directory structure, merged split test files, and removed ~114 duplicate tests across 10 files. --- azext_prototype/azext_metadata.json | 2 +- scripts/generate_pdf.py | 2 +- setup.py | 2 +- tests/agents/test_phase4_agents.py | 27 - tests/agents/test_providers_auth_agents.py | 69 - tests/ai/test_ai.py | 5 - tests/governance/test_governance.py | 38 - tests/stages/test_coverage_gaps.py | 108 - tests/stages/test_deploy_helpers.py | 77 - tests/stages/test_deploy_session.py | 304 --- tests/stages/test_stages.py | 430 ---- tests/telemetry/__init__.py | 0 .../test___init__.py} | 2 +- tests/test_custom.py | 2282 ++++++++++++++++- tests/test_custom_extended.py | 2204 ---------------- tests/tracking/__init__.py | 0 .../test___init__.py} | 0 tests/ui/test_tui_widgets.py | 58 - 18 files changed, 2240 insertions(+), 3370 deletions(-) create mode 100644 tests/telemetry/__init__.py rename tests/{test_telemetry.py => telemetry/test___init__.py} (99%) delete mode 100644 tests/test_custom_extended.py create mode 100644 tests/tracking/__init__.py rename tests/{test_tracking.py => tracking/test___init__.py} (100%) diff --git a/azext_prototype/azext_metadata.json b/azext_prototype/azext_metadata.json index 33cd7d7..5cb05a4 100644 --- a/azext_prototype/azext_metadata.json +++ b/azext_prototype/azext_metadata.json @@ -2,7 +2,7 @@ "azext.isPreview": true, "azext.minCliCoreVersion": "2.50.0", "name": "prototype", - "version": "0.2.1b6", + "version": "0.2.1b7", "azext.summary": "Azure CLI extension for building rapid prototypes with GitHub Copilot.", "license": "MIT", "classifiers": [ diff --git a/scripts/generate_pdf.py b/scripts/generate_pdf.py index a168bc5..9c8eb55 100644 --- a/scripts/generate_pdf.py +++ b/scripts/generate_pdf.py @@ -18,7 +18,7 @@ # DATA # ============================================================ -VERSION = "v0.2.1b6" +VERSION = "v0.2.1b7" DATE = datetime.now().strftime("%B %d, %Y") DATE_SHORT = datetime.now().strftime("%Y-%m-%d") MODEL = "Sonnet 4.6" diff --git a/setup.py b/setup.py index 3c3648d..dc68c8f 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup -VERSION = "0.2.1b6" +VERSION = "0.2.1b7" CLASSIFIERS = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", diff --git a/tests/agents/test_phase4_agents.py b/tests/agents/test_phase4_agents.py index 3dad275..f291889 100644 --- a/tests/agents/test_phase4_agents.py +++ b/tests/agents/test_phase4_agents.py @@ -261,33 +261,6 @@ def test_contract(self): class TestNewAgentsInRegistry: """Test that new agents register and resolve correctly.""" - def test_security_architect_registered(self, populated_registry): - assert "security-architect" in populated_registry - agent = populated_registry.get("security-architect") - assert agent.name == "security-architect" - - def test_monitoring_agent_registered(self, populated_registry): - assert "monitoring-agent" in populated_registry - agent = populated_registry.get("monitoring-agent") - assert agent.name == "monitoring-agent" - - def test_all_builtin_agents_registered(self, populated_registry): - expected = [ - "cloud-architect", - "terraform-agent", - "bicep-agent", - "app-developer", - "doc-agent", - "qa-engineer", - "biz-analyst", - "cost-analyst", - "project-manager", - "security-architect", - "monitoring-agent", - ] - for name in expected: - assert name in populated_registry, f"Built-in agent '{name}' not registered" - def test_builtin_count(self, populated_registry): assert len(populated_registry) == 19 diff --git a/tests/agents/test_providers_auth_agents.py b/tests/agents/test_providers_auth_agents.py index fbc1900..66dca8a 100644 --- a/tests/agents/test_providers_auth_agents.py +++ b/tests/agents/test_providers_auth_agents.py @@ -921,28 +921,6 @@ def test_yaml_agent_can_handle_name_match(self): score = agent.can_handle("process the data") assert score > 0.3 - def test_load_agents_from_directory(self, tmp_path): - from azext_prototype.agents.loader import load_agents_from_directory - - (tmp_path / "agent1.yaml").write_text( - "name: agent1\ndescription: A\ncapabilities: []\nsystem_prompt: test\n", - encoding="utf-8", - ) - (tmp_path / "agent2.yaml").write_text( - "name: agent2\ndescription: B\ncapabilities: []\nsystem_prompt: test\n", - encoding="utf-8", - ) - (tmp_path / "_skip.py").write_text("# skipped", encoding="utf-8") - - agents = load_agents_from_directory(str(tmp_path)) - assert len(agents) == 2 - - def test_load_agents_from_nonexistent_dir(self, tmp_path): - from azext_prototype.agents.loader import load_agents_from_directory - - agents = load_agents_from_directory(str(tmp_path / "nonexistent")) - assert agents == [] - def test_load_agents_handles_invalid_files(self, tmp_path): from azext_prototype.agents.loader import load_agents_from_directory @@ -956,19 +934,6 @@ def test_yaml_agent_missing_name_raises(self): with pytest.raises(CLIError, match="must include 'name'"): YAMLAgent({"description": "no name"}) - def test_load_yaml_agent_not_found(self): - from azext_prototype.agents.loader import load_yaml_agent - - with pytest.raises(CLIError, match="not found"): - load_yaml_agent("/nonexistent/path.yaml") - - def test_load_yaml_agent_wrong_ext(self, tmp_path): - from azext_prototype.agents.loader import load_yaml_agent - - (tmp_path / "test.txt").write_text("test") - with pytest.raises(CLIError, match=".yaml"): - load_yaml_agent(str(tmp_path / "test.txt")) - def test_load_yaml_agent_not_mapping(self, tmp_path): from azext_prototype.agents.loader import load_yaml_agent @@ -976,40 +941,6 @@ def test_load_yaml_agent_not_mapping(self, tmp_path): with pytest.raises(CLIError, match="mapping"): load_yaml_agent(str(tmp_path / "bad.yaml")) - def test_load_python_agent_not_found(self): - from azext_prototype.agents.loader import load_python_agent - - with pytest.raises(CLIError, match="not found"): - load_python_agent("/nonexistent/agent.py") - - def test_load_python_agent_wrong_ext(self, tmp_path): - from azext_prototype.agents.loader import load_python_agent - - (tmp_path / "test.yaml").write_text("test") - with pytest.raises(CLIError, match=".py"): - load_python_agent(str(tmp_path / "test.yaml")) - - def test_load_python_agent_with_agent_class(self, tmp_path): - from azext_prototype.agents.loader import load_python_agent - - code = """ -from azext_prototype.agents.base import BaseAgent, AgentCapability - -class MyAgent(BaseAgent): - def __init__(self): - super().__init__( - name="py-agent", - description="Python agent", - capabilities=[AgentCapability.DEVELOP], - system_prompt="test", - ) - -AGENT_CLASS = MyAgent -""" - (tmp_path / "my_agent.py").write_text(code, encoding="utf-8") - agent = load_python_agent(str(tmp_path / "my_agent.py")) - assert agent.name == "py-agent" - def test_load_python_agent_auto_discover(self, tmp_path): from azext_prototype.agents.loader import load_python_agent diff --git a/tests/ai/test_ai.py b/tests/ai/test_ai.py index a9b3e2e..4bad4e9 100644 --- a/tests/ai/test_ai.py +++ b/tests/ai/test_ai.py @@ -268,10 +268,5 @@ def test_config_default(self): assert DEFAULT_CONFIG["ai"]["model"] == "claude-sonnet-4.5" - def test_copilot_default(self): - from azext_prototype.ai.copilot_provider import CopilotProvider - - assert CopilotProvider.DEFAULT_MODEL == "claude-sonnet-4" - def test_github_models_default(self): assert GitHubModelsProvider.DEFAULT_MODEL == "gpt-4o" diff --git a/tests/governance/test_governance.py b/tests/governance/test_governance.py index 2ded917..82da25c 100644 --- a/tests/governance/test_governance.py +++ b/tests/governance/test_governance.py @@ -500,44 +500,6 @@ def test_cloud_architect_validates_response(self, mock_agent_context): assert "Governance warnings" not in result.content -# ------------------------------------------------------------------ # -# Credential detection patterns — exhaustive -# ------------------------------------------------------------------ # - - -class TestCredentialDetection: - """Test all credential patterns are detected.""" - - @pytest.fixture(autouse=True) - def _setup_governance(self, policy_engine, template_registry): - import azext_prototype.agents.governance as gov_mod - - gov_mod._policy_engine = policy_engine - gov_mod._template_registry = template_registry - - @pytest.mark.parametrize( - "pattern", - [ - "connection_string", - "connectionstring", - "access_key", - "accesskey", - "account_key", - "accountkey", - "shared_access_key", - "client_secret", - 'password="bad"', - "password='bad'", - "password = foo", - ], - ) - def test_credential_pattern_detected(self, pattern, governance_ctx): - warnings = governance_ctx.check_response_for_violations("terraform-agent", f"Use {pattern} for auth") - assert any( - "credential" in w.lower() or "secret" in w.lower() or "managed identity" in w.lower() for w in warnings - ), f"Pattern '{pattern}' should be detected as credential" - - # ------------------------------------------------------------------ # # GovernanceContext — edge cases # ------------------------------------------------------------------ # diff --git a/tests/stages/test_coverage_gaps.py b/tests/stages/test_coverage_gaps.py index b3668d7..2ed8a0b 100644 --- a/tests/stages/test_coverage_gaps.py +++ b/tests/stages/test_coverage_gaps.py @@ -18,119 +18,11 @@ class TestDeployHelpersDeep: """Deep tests for deploy_helpers module-level functions.""" - # --- Bicep helpers --- - - def test_find_bicep_params_json(self, tmp_path): - from azext_prototype.stages.deploy_helpers import find_bicep_params - - (tmp_path / "main.parameters.json").write_text("{}", encoding="utf-8") - result = find_bicep_params(tmp_path, tmp_path / "main.bicep") - assert result is not None - assert result.name == "main.parameters.json" - - def test_find_bicep_params_bicepparam(self, tmp_path): - from azext_prototype.stages.deploy_helpers import find_bicep_params - - (tmp_path / "main.bicepparam").write_text("", encoding="utf-8") - result = find_bicep_params(tmp_path, tmp_path / "main.bicep") - assert result is not None - assert result.name == "main.bicepparam" - - def test_find_bicep_params_generic(self, tmp_path): - from azext_prototype.stages.deploy_helpers import find_bicep_params - - (tmp_path / "parameters.json").write_text("{}", encoding="utf-8") - result = find_bicep_params(tmp_path, tmp_path / "main.bicep") - assert result is not None - assert result.name == "parameters.json" - - def test_find_bicep_params_none(self, tmp_path): - from azext_prototype.stages.deploy_helpers import find_bicep_params - - result = find_bicep_params(tmp_path, tmp_path / "main.bicep") - assert result is None - - def test_is_subscription_scoped_true(self, tmp_path): - from azext_prototype.stages.deploy_helpers import is_subscription_scoped - - bicep = tmp_path / "main.bicep" - bicep.write_text("targetScope = 'subscription'\n", encoding="utf-8") - assert is_subscription_scoped(bicep) is True - - def test_is_subscription_scoped_false(self, tmp_path): - from azext_prototype.stages.deploy_helpers import is_subscription_scoped - - bicep = tmp_path / "main.bicep" - bicep.write_text("resource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = {}\n", encoding="utf-8") - assert is_subscription_scoped(bicep) is False - def test_is_subscription_scoped_missing_file(self, tmp_path): from azext_prototype.stages.deploy_helpers import is_subscription_scoped assert is_subscription_scoped(tmp_path / "nope.bicep") is False - def test_get_deploy_location_from_params(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - params = {"parameters": {"location": {"value": "westus2"}}} - (tmp_path / "parameters.json").write_text(json.dumps(params), encoding="utf-8") - assert get_deploy_location(tmp_path) == "westus2" - - def test_get_deploy_location_from_string(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - params = {"location": "centralus"} - (tmp_path / "parameters.json").write_text(json.dumps(params), encoding="utf-8") - assert get_deploy_location(tmp_path) == "centralus" - - def test_get_deploy_location_none(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - assert get_deploy_location(tmp_path) is None - - def test_get_deploy_location_invalid_json(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - (tmp_path / "parameters.json").write_text("not json", encoding="utf-8") - assert get_deploy_location(tmp_path) is None - - # --- check_az_login --- - - @patch("azext_prototype.stages.deploy_helpers.subprocess.run") - def test_check_az_login_true(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - mock_run.return_value = MagicMock(returncode=0) - assert check_az_login() is True - - @patch("azext_prototype.stages.deploy_helpers.subprocess.run") - def test_check_az_login_false(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - mock_run.return_value = MagicMock(returncode=1) - assert check_az_login() is False - - @patch("azext_prototype.stages.deploy_helpers.subprocess.run", side_effect=FileNotFoundError) - def test_check_az_login_no_az(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - assert check_az_login() is False - - # --- get_current_subscription --- - - @patch("azext_prototype.stages.deploy_helpers.subprocess.run") - def test_get_current_subscription(self, mock_run): - from azext_prototype.stages.deploy_helpers import get_current_subscription - - mock_run.return_value = MagicMock(returncode=0, stdout="sub-abc-123\n") - assert get_current_subscription() == "sub-abc-123" - - @patch("azext_prototype.stages.deploy_helpers.subprocess.run", side_effect=FileNotFoundError) - def test_get_current_subscription_error(self, mock_run): - from azext_prototype.stages.deploy_helpers import get_current_subscription - - assert get_current_subscription() == "" - # --- deploy_terraform --- @patch("azext_prototype.stages.deploy_helpers.subprocess.run") diff --git a/tests/stages/test_deploy_helpers.py b/tests/stages/test_deploy_helpers.py index 933b62d..1975574 100644 --- a/tests/stages/test_deploy_helpers.py +++ b/tests/stages/test_deploy_helpers.py @@ -105,40 +105,6 @@ def test_az_caches_result(self): # ====================================================================== -class TestBuildDeployEnv: - """Test build_deploy_env merges OS environ with Azure auth vars.""" - - def test_all_params_set(self): - from azext_prototype.stages.deploy_helpers import build_deploy_env - - env = build_deploy_env( - subscription="sub-123", - tenant="tenant-abc", - client_id="cid", - client_secret="csec", - ) - assert env["ARM_SUBSCRIPTION_ID"] == "sub-123" - assert env["TF_VAR_subscription_id"] == "sub-123" - assert env["SUBSCRIPTION_ID"] == "sub-123" - assert env["ARM_TENANT_ID"] == "tenant-abc" - assert env["ARM_CLIENT_ID"] == "cid" - assert env["ARM_CLIENT_SECRET"] == "csec" - - def test_none_params_skipped(self): - from azext_prototype.stages.deploy_helpers import build_deploy_env - - env = build_deploy_env(subscription="sub-only") - assert env["ARM_SUBSCRIPTION_ID"] == "sub-only" - assert "ARM_TENANT_ID" not in env or env.get("ARM_TENANT_ID") == os.environ.get("ARM_TENANT_ID") - - def test_includes_os_environ(self): - from azext_prototype.stages.deploy_helpers import build_deploy_env - - env = build_deploy_env() - # Should contain at least PATH from os.environ - assert "PATH" in env - - # ====================================================================== # Terraform Secret Variable Scanning # ====================================================================== @@ -1051,46 +1017,3 @@ def test_deploy_terraform_no_env_still_works(self, mock_run): assert c.kwargs.get("env") is None -class TestSecretVariableScanning: - """Tests for scan_tf_secret_variables().""" - - def test_scan_finds_secret_suffix(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "graph_client_secret" {}\n') - result = scan_tf_secret_variables(tmp_path) - assert "graph_client_secret" in result - - def test_scan_finds_password_suffix(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "admin_password" {\n type = string\n}\n') - result = scan_tf_secret_variables(tmp_path) - assert "admin_password" in result - - def test_scan_ignores_known_vars(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "client_secret" {}\n') - result = scan_tf_secret_variables(tmp_path) - assert "client_secret" not in result - - def test_scan_ignores_non_secret_vars(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "location" {}\nvariable "resource_group_name" {}\n') - result = scan_tf_secret_variables(tmp_path) - assert result == [] - - def test_scan_ignores_vars_with_default(self, tmp_path): - tf = tmp_path / "main.tf" - tf.write_text('variable "api_secret" {\n default = "preset-value"\n}\n') - result = scan_tf_secret_variables(tmp_path) - assert result == [] - - def test_scan_multiple_files(self, tmp_path): - (tmp_path / "main.tf").write_text('variable "graph_client_secret" {}\n') - (tmp_path / "variables.tf").write_text('variable "db_password" {}\n') - result = scan_tf_secret_variables(tmp_path) - assert "graph_client_secret" in result - assert "db_password" in result - - def test_scan_empty_dir(self, tmp_path): - result = scan_tf_secret_variables(tmp_path) - assert result == [] diff --git a/tests/stages/test_deploy_session.py b/tests/stages/test_deploy_session.py index d6f58a6..e8c46ac 100644 --- a/tests/stages/test_deploy_session.py +++ b/tests/stages/test_deploy_session.py @@ -550,42 +550,6 @@ def test_bicep_no_output_skips(self, mock_sub, mock_env, mock_oid, deploy_contex session._output_capture.capture_bicep.assert_not_called() -# ------------------------------------------------------------------ -# _extract_providers_from_files -# ------------------------------------------------------------------ - - -class TestExtractProviders: - @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) - @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") - def test_extracts_terraform_providers(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): - session = _make_session(deploy_context, deploy_registry) - - stage_dir = Path(deploy_context.project_dir) / "concept" / "infra" / "terraform" / "stage-1-foundation" - stage_dir.mkdir(parents=True, exist_ok=True) - (stage_dir / "main.tf").write_text( - 'resource "azapi_resource" "kv" {\n' ' type = "Microsoft.KeyVault/vaults@2023-07-01"\n' "}\n", - encoding="utf-8", - ) - - session._deploy_state._state["deployment_stages"] = [ - {"stage": 1, "dir": "concept/infra/terraform/stage-1-foundation", "services": []} - ] - - namespaces = session._extract_providers_from_files() - assert "Microsoft.KeyVault" in namespaces - - @patch("azext_prototype.stages.deploy_session._lookup_deployer_object_id", return_value=None) - @patch("azext_prototype.stages.deploy_session.build_deploy_env", return_value={}) - @patch("azext_prototype.stages.deploy_session.get_current_subscription", return_value="sub") - def test_no_files_returns_empty(self, mock_sub, mock_env, mock_oid, deploy_context, deploy_registry): - session = _make_session(deploy_context, deploy_registry) - session._deploy_state._state["deployment_stages"] = [] - namespaces = session._extract_providers_from_files() - assert namespaces == set() - - # --- Additional imports from merged flat test --- from azext_prototype.agents.base import AgentContext from azext_prototype.agents.builtin import register_all_builtin @@ -697,274 +661,6 @@ def _write_build_yaml(project_dir, stages=None, iac_tool="terraform"): yaml.dump(build_data, f, default_flow_style=False) return state_dir / "build.yaml" -# ====================================================================== - - -class TestDeployState: - - def test_default_state_structure(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - state = ds.state - assert state["iac_tool"] == "terraform" - assert state["subscription"] == "" - assert state["resource_group"] == "" - assert state["deployment_stages"] == [] - assert state["preflight_results"] == [] - assert state["deploy_log"] == [] - assert state["rollback_log"] == [] - assert state["captured_outputs"] == {} - assert state["_metadata"]["iteration"] == 0 - - def test_load_save_roundtrip(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - ds._state["subscription"] = "test-sub-123" - ds._state["iac_tool"] = "bicep" - ds.save() - - ds2 = DeployState(str(tmp_project)) - loaded = ds2.load() - assert loaded["subscription"] == "test-sub-123" - assert loaded["iac_tool"] == "bicep" - assert loaded["_metadata"]["created"] is not None - assert loaded["_metadata"]["last_updated"] is not None - - def test_exists_property(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - assert not ds.exists - ds.save() - assert ds.exists - - def test_load_from_build_state(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - result = ds.load_from_build_state(build_path) - - assert result is True - assert len(ds.state["deployment_stages"]) == 3 - # Verify deploy-specific fields were added - stage = ds.state["deployment_stages"][0] - assert stage["deploy_status"] == "pending" - assert stage["deploy_timestamp"] is None - assert stage["deploy_output"] == "" - assert stage["deploy_error"] == "" - assert stage["rollback_timestamp"] is None - - def test_load_from_build_state_missing_file(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - result = ds.load_from_build_state("/nonexistent/build.yaml") - assert result is False - - def test_load_from_build_state_no_stages(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project, stages=[]) - ds = DeployState(str(tmp_project)) - result = ds.load_from_build_state(build_path) - assert result is False - - def test_stage_transitions(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - # pending → deploying - ds.mark_stage_deploying(1) - assert ds.get_stage(1)["deploy_status"] == "deploying" - - # deploying → deployed - ds.mark_stage_deployed(1, output="resource_id=abc123") - stage = ds.get_stage(1) - assert stage["deploy_status"] == "deployed" - assert stage["deploy_timestamp"] is not None - assert stage["deploy_output"] == "resource_id=abc123" - assert stage["deploy_error"] == "" - - # deployed → rolled_back - ds.mark_stage_rolled_back(1) - stage = ds.get_stage(1) - assert stage["deploy_status"] == "rolled_back" - assert stage["rollback_timestamp"] is not None - - def test_stage_failure(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deploying(1) - ds.mark_stage_failed(1, error="timeout connecting to Azure") - stage = ds.get_stage(1) - assert stage["deploy_status"] == "failed" - assert stage["deploy_error"] == "timeout connecting to Azure" - - def test_get_pending_deployed_failed(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - assert len(ds.get_pending_stages()) == 3 - assert len(ds.get_deployed_stages()) == 0 - assert len(ds.get_failed_stages()) == 0 - - ds.mark_stage_deployed(1) - ds.mark_stage_failed(2, "error") - - assert len(ds.get_pending_stages()) == 1 - assert len(ds.get_deployed_stages()) == 1 - assert len(ds.get_failed_stages()) == 1 - - def test_can_rollback_ordering(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deployed(1) - ds.mark_stage_deployed(2) - ds.mark_stage_deployed(3) - - # Can only rollback stage 3 (highest) - assert ds.can_rollback(3) is True - assert ds.can_rollback(2) is False # stage 3 still deployed - assert ds.can_rollback(1) is False # stages 2,3 still deployed - - # Roll back stage 3 - ds.mark_stage_rolled_back(3) - assert ds.can_rollback(2) is True - assert ds.can_rollback(1) is False # stage 2 still deployed - - def test_rollback_candidates_reverse_order(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deployed(1) - ds.mark_stage_deployed(2) - ds.mark_stage_deployed(3) - - candidates = ds.get_rollback_candidates() - assert [c["stage"] for c in candidates] == [3, 2, 1] - - def test_preflight_results(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - results = [ - {"name": "Azure Login", "status": "pass", "message": "Logged in."}, - {"name": "Terraform", "status": "fail", "message": "Not found.", "fix_command": "brew install terraform"}, - ] - ds.set_preflight_results(results) - - failures = ds.get_preflight_failures() - assert len(failures) == 1 - assert failures[0]["name"] == "Terraform" - - def test_deploy_log(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - ds.mark_stage_deploying(1) - ds.mark_stage_deployed(1) - - assert len(ds.state["deploy_log"]) == 2 - assert ds.state["deploy_log"][0]["action"] == "deploying" - assert ds.state["deploy_log"][1]["action"] == "deployed" - - def test_reset(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - assert len(ds.state["deployment_stages"]) == 3 - - ds.reset() - assert ds.state["deployment_stages"] == [] - assert ds.exists # File still exists after reset - - def test_format_deploy_report(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - ds._state["subscription"] = "sub-123" - - ds.mark_stage_deployed(1) - ds.mark_stage_failed(2, "timeout") - - report = ds.format_deploy_report() - assert "Deploy Report" in report - assert "sub-123" in report - assert "1 deployed" in report - assert "1 failed" in report - - def test_format_stage_status(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - build_path = _write_build_yaml(tmp_project) - ds = DeployState(str(tmp_project)) - ds.load_from_build_state(build_path) - - status = ds.format_stage_status() - assert "Foundation" in status - assert "Application" in status - assert "0/3 stages deployed" in status - - def test_format_preflight_report(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - ds.set_preflight_results( - [ - {"name": "Azure Login", "status": "pass", "message": "OK"}, - { - "name": "Terraform", - "status": "warn", - "message": "Old version", - "fix_command": "brew upgrade terraform", - }, - ] - ) - - report = ds.format_preflight_report() - assert "Preflight Checks" in report - assert "2 passed" in report or "1 passed" in report - assert "1 warning" in report - - def test_conversation_tracking(self, tmp_project): - from azext_prototype.stages.deploy_state import DeployState - - ds = DeployState(str(tmp_project)) - ds.update_from_exchange("deploy all", "Deploying stage 1...", 1) - - assert len(ds.state["conversation_history"]) == 1 - assert ds.state["conversation_history"][0]["user"] == "deploy all" - -# ====================================================================== - - class TestExtractResourceProvidersFromFiles: """Verify _extract_providers_from_files() parses IaC files for namespaces.""" diff --git a/tests/stages/test_stages.py b/tests/stages/test_stages.py index db42e97..7d13a21 100644 --- a/tests/stages/test_stages.py +++ b/tests/stages/test_stages.py @@ -179,212 +179,10 @@ def test_init_stage_has_guards(self): assert len(guards) == 0 -class TestDeployStage: - """Test the deploy stage.""" - - def test_deploy_stage_instantiates(self): - from azext_prototype.stages.deploy_stage import DeployStage - - stage = DeployStage() - assert stage is not None - assert stage.name == "deploy" - - def test_deploy_stage_has_execute(self): - from azext_prototype.stages.deploy_stage import DeployStage - - stage = DeployStage() - assert hasattr(stage, "execute") - assert callable(stage.execute) - - -class TestDeployBicepStaging: - """Test Bicep staged deployment capabilities (via deploy_helpers).""" - - def test_find_bicep_params_main_parameters_json(self, tmp_path): - from azext_prototype.stages.deploy_helpers import find_bicep_params - - template = tmp_path / "main.bicep" - template.write_text("resource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = {}") - params = tmp_path / "main.parameters.json" - params.write_text('{"parameters": {"location": {"value": "eastus"}}}') - - result = find_bicep_params(tmp_path, template) - assert result == params - - def test_find_bicep_params_bicepparam(self, tmp_path): - from azext_prototype.stages.deploy_helpers import find_bicep_params - - template = tmp_path / "main.bicep" - template.write_text("") - bp = tmp_path / "main.bicepparam" - bp.write_text("using './main.bicep'\nparam location = 'eastus'") - - result = find_bicep_params(tmp_path, template) - assert result == bp - - def test_find_bicep_params_fallback_parameters_json(self, tmp_path): - from azext_prototype.stages.deploy_helpers import find_bicep_params - - template = tmp_path / "network.bicep" - template.write_text("") - params = tmp_path / "parameters.json" - params.write_text('{"parameters": {}}') - - result = find_bicep_params(tmp_path, template) - assert result == params - - def test_find_bicep_params_none_when_missing(self, tmp_path): - from azext_prototype.stages.deploy_helpers import find_bicep_params - - template = tmp_path / "main.bicep" - template.write_text("") - - result = find_bicep_params(tmp_path, template) - assert result is None - - def test_is_subscription_scoped_true(self, tmp_path): - from azext_prototype.stages.deploy_helpers import is_subscription_scoped - - bicep_file = tmp_path / "main.bicep" - bicep_file.write_text( - "targetScope = 'subscription'\n\nresource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = {}" - ) - - assert is_subscription_scoped(bicep_file) is True - - def test_is_subscription_scoped_false(self, tmp_path): - from azext_prototype.stages.deploy_helpers import is_subscription_scoped - - bicep_file = tmp_path / "main.bicep" - bicep_file.write_text("resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {}") - - assert is_subscription_scoped(bicep_file) is False - - def test_get_deploy_location_from_params(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - params = tmp_path / "parameters.json" - params.write_text('{"parameters": {"location": {"value": "westus2"}}}') - - result = get_deploy_location(tmp_path) - assert result == "westus2" - - def test_get_deploy_location_returns_none(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - result = get_deploy_location(tmp_path) - assert result is None - - @patch("subprocess.run") - def test_deploy_bicep_resource_group_scope(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_bicep - - bicep_dir = tmp_path / "stage1" - bicep_dir.mkdir() - (bicep_dir / "main.bicep").write_text("resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {}") - - mock_run.return_value = MagicMock(returncode=0, stdout='{"properties":{}}', stderr="") - - result = deploy_bicep(bicep_dir, "sub-123", "my-rg") - assert result["status"] == "deployed" - assert result["scope"] == "resourceGroup" - assert result["template"] == "main.bicep" - - # Verify az deployment group create was called (not sub create) - cmd_parts = mock_run.call_args[0][0] - assert "group" in cmd_parts - assert "create" in cmd_parts - - @patch("subprocess.run") - def test_deploy_bicep_subscription_scope(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_bicep - - bicep_dir = tmp_path / "stage1" - bicep_dir.mkdir() - (bicep_dir / "main.bicep").write_text( - "targetScope = 'subscription'\n\nresource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = {}" - ) - - mock_run.return_value = MagicMock(returncode=0, stdout='{"properties":{}}', stderr="") - - result = deploy_bicep(bicep_dir, "sub-123", "") - assert result["status"] == "deployed" - assert result["scope"] == "subscription" - - # Verify az deployment sub create was called - cmd_parts = mock_run.call_args[0][0] - assert "sub" in cmd_parts - - @patch("subprocess.run") - def test_deploy_bicep_with_params_file(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_bicep - - (tmp_path / "main.bicep").write_text("param location string\n") - (tmp_path / "main.parameters.json").write_text('{"parameters":{"location":{"value":"eastus"}}}') - - mock_run.return_value = MagicMock(returncode=0, stdout="{}", stderr="") - - deploy_bicep(tmp_path, "sub-123", "my-rg") - - cmd_parts = mock_run.call_args[0][0] - assert "--parameters" in cmd_parts - - def test_deploy_bicep_no_bicep_files_skips(self, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_bicep - - empty_dir = tmp_path / "empty" - empty_dir.mkdir() - - result = deploy_bicep(empty_dir, "sub-123", "my-rg") - assert result["status"] == "skipped" - - def test_deploy_bicep_fallback_to_first_file(self, tmp_path): - """When no main.bicep exists, uses the first .bicep file.""" - from azext_prototype.stages.deploy_helpers import deploy_bicep - - (tmp_path / "network.bicep").write_text("resource vnet 'Microsoft.Network/virtualNetworks@2023-05-01' = {}") - - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="{}", stderr="") - result = deploy_bicep(tmp_path, "sub-123", "my-rg") - - assert result["status"] == "deployed" - assert result["template"] == "network.bicep" - - def test_deploy_bicep_rg_required_for_rg_scope(self, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_bicep - - (tmp_path / "main.bicep").write_text("resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {}") - - result = deploy_bicep(tmp_path, "sub-123", "") - assert result["status"] == "failed" - assert "Resource group required" in result["error"] - - @patch("subprocess.run") - def test_whatif_bicep_runs(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import whatif_bicep - - (tmp_path / "main.bicep").write_text("resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {}") - mock_run.return_value = MagicMock(returncode=0, stdout="Resource changes: 1 to create", stderr="") - - result = whatif_bicep(tmp_path, "sub-123", "my-rg") - assert result["status"] == "previewed" - assert "Resource changes" in result["output"] - - cmd_parts = mock_run.call_args[0][0] - assert "what-if" in cmd_parts - class TestDesignStage: """Test the design stage.""" - def test_design_stage_instantiates(self): - from azext_prototype.stages.design_stage import DesignStage - - stage = DesignStage() - assert stage.name == "design" - assert stage.reentrant is True - def test_design_stage_has_execute(self): from azext_prototype.stages.design_stage import DesignStage @@ -1021,25 +819,6 @@ def test_design_skip_discovery_fails_without_state( ) -class TestBuildStage: - """Test the build stage.""" - - def test_build_stage_instantiates(self): - from azext_prototype.stages.build_stage import BuildStage - - stage = BuildStage() - assert stage is not None - assert stage.name == "build" - - def test_match_templates_empty_architecture(self): - from azext_prototype.stages.build_stage import BuildStage - - stage = BuildStage() - config = MagicMock() - result = stage._match_templates({"architecture": ""}, config) - assert result == [] - - # --- Additional imports from merged flat test --- from knack.util import CLIError @@ -1047,190 +826,7 @@ def test_match_templates_empty_architecture(self): from azext_prototype.agents.registry import AgentRegistry from azext_prototype.ai.provider import AIResponse from azext_prototype.stages.build_session import BuildResult -from azext_prototype.stages.deploy_helpers import check_az_login -from azext_prototype.stages.deploy_helpers import deploy_app_stage -from azext_prototype.stages.deploy_helpers import deploy_terraform -from azext_prototype.stages.deploy_helpers import get_current_subscription - - -# ====================================================================== - -class TestDeployStageExecution: - """Test DeployStage orchestration and deploy_helpers functions.""" - - def _make_stage(self): - from azext_prototype.stages.deploy_stage import DeployStage - - return DeployStage() - - def test_deploy_guards(self): - stage = self._make_stage() - guards = stage.get_guards() - names = [g.name for g in guards] - assert "project_initialized" in names - assert "build_complete" in names - assert "az_logged_in" in names - - @patch("subprocess.run") - def test_check_az_login_true(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - mock_run.return_value = MagicMock(returncode=0) - assert check_az_login() is True - - @patch("subprocess.run") - def test_check_az_login_false(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - mock_run.return_value = MagicMock(returncode=1) - assert check_az_login() is False - - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_check_az_login_not_installed(self, mock_run): - from azext_prototype.stages.deploy_helpers import check_az_login - - assert check_az_login() is False - - @patch("subprocess.run") - def test_get_current_subscription(self, mock_run): - from azext_prototype.stages.deploy_helpers import get_current_subscription - - mock_run.return_value = MagicMock(returncode=0, stdout="abc-123\n") - result = get_current_subscription() - assert result == "abc-123" - - @patch("subprocess.run", side_effect=FileNotFoundError) - def test_get_current_subscription_not_installed(self, mock_run): - from azext_prototype.stages.deploy_helpers import get_current_subscription - - assert get_current_subscription() == "" - - @patch("subprocess.run") - def test_deploy_terraform_success(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_terraform - - infra_dir = tmp_path / "tf" - infra_dir.mkdir() - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - - result = deploy_terraform(infra_dir, "sub-123") - assert result["status"] == "deployed" - - @patch("subprocess.run") - def test_deploy_terraform_failure(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_terraform - - infra_dir = tmp_path / "tf" - infra_dir.mkdir() - mock_run.return_value = MagicMock(returncode=1, stderr="init failed", stdout="") - - result = deploy_terraform(infra_dir, "sub-123") - assert result["status"] == "failed" - - @patch("subprocess.run") - def test_deploy_bicep_failure(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_bicep - - (tmp_path / "main.bicep").write_text("resource x 'y' = {}", encoding="utf-8") - mock_run.return_value = MagicMock(returncode=1, stderr="Deployment failed", stdout="") - - result = deploy_bicep(tmp_path, "sub-123", "my-rg") - assert result["status"] == "failed" - - def test_deploy_app_stage_with_deploy_script(self, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_app_stage - - app_dir = tmp_path / "app" - app_dir.mkdir() - (app_dir / "deploy.sh").write_text("echo deployed", encoding="utf-8") - - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - result = deploy_app_stage(app_dir, "sub-123", "my-rg") - assert result["status"] == "deployed" - - def test_deploy_app_stage_sub_apps(self, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_app_stage - - stage_dir = tmp_path / "stage" - stage_dir.mkdir() - backend = stage_dir / "backend" - backend.mkdir() - (backend / "deploy.sh").write_text("echo ok", encoding="utf-8") - - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - result = deploy_app_stage(stage_dir, "sub-123", "my-rg") - assert result["status"] == "deployed" - assert "backend" in result["apps"] - - def test_deploy_app_stage_no_scripts(self, tmp_path): - from azext_prototype.stages.deploy_helpers import deploy_app_stage - - empty_dir = tmp_path / "empty" - empty_dir.mkdir() - result = deploy_app_stage(empty_dir, "sub-123", "my-rg") - assert result["status"] == "skipped" - - @patch("subprocess.run") - def test_whatif_bicep_no_files(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import whatif_bicep - - empty_dir = tmp_path / "empty" - empty_dir.mkdir() - result = whatif_bicep(empty_dir, "sub-123", "my-rg") - assert result["status"] == "skipped" - - @patch("subprocess.run") - def test_whatif_bicep_no_rg_skips(self, mock_run, tmp_path): - from azext_prototype.stages.deploy_helpers import whatif_bicep - - (tmp_path / "main.bicep").write_text("resource x 'y' = {}", encoding="utf-8") - result = whatif_bicep(tmp_path, "sub-123", "") - assert result["status"] == "skipped" - - def test_get_deploy_location_main_params(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - (tmp_path / "main.parameters.json").write_text( - '{"parameters": {"location": {"value": "northeurope"}}}', encoding="utf-8" - ) - result = get_deploy_location(tmp_path) - assert result == "northeurope" - - def test_get_deploy_location_string_value(self, tmp_path): - from azext_prototype.stages.deploy_helpers import get_deploy_location - - (tmp_path / "parameters.json").write_text('{"location": "uksouth"}', encoding="utf-8") - result = get_deploy_location(tmp_path) - assert result == "uksouth" - - def test_execute_status(self, project_with_build, mock_agent_context, populated_registry): - """Deploy with --status shows state and returns.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - mock_agent_context.project_dir = str(project_with_build) - - result = stage.execute( - mock_agent_context, - populated_registry, - status=True, - ) - assert result["status"] == "status_displayed" - - def test_execute_reset(self, project_with_build, mock_agent_context, populated_registry): - """Deploy with --reset clears state and returns.""" - stage = self._make_stage() - stage.get_guards = lambda: [] - mock_agent_context.project_dir = str(project_with_build) - - result = stage.execute( - mock_agent_context, - populated_registry, - reset=True, - ) - assert result["status"] == "reset" # ====================================================================== @@ -1243,32 +839,6 @@ def _make_stage(self): return BuildStage() - def test_build_guards(self): - stage = self._make_stage() - guards = stage.get_guards() - names = [g.name for g in guards] - assert "project_initialized" in names - assert "discovery_complete" in names - assert "design_complete" in names - - def test_load_design(self, project_with_design): - stage = self._make_stage() - design = stage._load_design(str(project_with_design)) - assert "architecture" in design - - def test_load_design_missing(self, tmp_project): - stage = self._make_stage() - result = stage._load_design(str(tmp_project)) - assert result == {} - - def test_execute_no_design_raises(self, project_with_config, mock_agent_context, populated_registry): - stage = self._make_stage() - stage.get_guards = lambda: [] - mock_agent_context.project_dir = str(project_with_config) - - with pytest.raises(CLIError, match="No architecture design"): - stage.execute(mock_agent_context, populated_registry) - def test_execute_dry_run(self, project_with_design, mock_agent_context, populated_registry): stage = self._make_stage() stage.get_guards = lambda: [] diff --git a/tests/telemetry/__init__.py b/tests/telemetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_telemetry.py b/tests/telemetry/test___init__.py similarity index 99% rename from tests/test_telemetry.py rename to tests/telemetry/test___init__.py index 8240f10..47578b6 100644 --- a/tests/test_telemetry.py +++ b/tests/telemetry/test___init__.py @@ -355,7 +355,7 @@ def test_reads_from_metadata(self): from azext_prototype.telemetry import _get_extension_version version = _get_extension_version() - assert version == "0.2.1b6" + assert version == "0.2.1b7" def test_returns_unknown_on_error(self): from azext_prototype.telemetry import _get_extension_version diff --git a/tests/test_custom.py b/tests/test_custom.py index 1126d32..0c00910 100644 --- a/tests/test_custom.py +++ b/tests/test_custom.py @@ -10,7 +10,7 @@ # All command functions call _get_project_dir() internally (uses Path.cwd()), # so we mock it to point at our tmp fixture directories. -_CUSTOM_MODULE = "azext_prototype.custom" +_MOD = "azext_prototype.custom" class TestGetProjectDir: @@ -42,7 +42,7 @@ def test_missing_config_raises(self, tmp_project): class TestPrototypeStatus: """Test az prototype status command.""" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_status_with_config(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_status @@ -60,7 +60,7 @@ def test_status_with_config(self, mock_dir, project_with_config): assert "build" in result["stages"] assert "deploy" in result["stages"] - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_status_without_config(self, mock_dir, tmp_project): from azext_prototype.custom import prototype_status @@ -75,7 +75,7 @@ def test_status_without_config(self, mock_dir, tmp_project): class TestPrototypeConfigShow: """Test az prototype config show command.""" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_config_show(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_config_show @@ -89,7 +89,7 @@ def test_config_show(self, mock_dir, project_with_config): class TestPrototypeConfigSet: """Test az prototype config set command.""" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_config_set(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_config_set @@ -111,7 +111,7 @@ def test_config_set_missing_key_raises(self): class TestPrototypeAgentList: """Test az prototype agent list command.""" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_agent_list(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_agent_list @@ -122,7 +122,7 @@ def test_agent_list(self, mock_dir, project_with_config): assert isinstance(result, list) assert len(result) >= 8 # 8 built-in agents - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_agent_list_no_builtin(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_agent_list @@ -137,7 +137,7 @@ def test_agent_list_no_builtin(self, mock_dir, project_with_config): class TestPrototypeAgentShow: """Test az prototype agent show command.""" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_agent_show_builtin(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_agent_show @@ -159,7 +159,7 @@ def test_agent_show_missing_name_raises(self): class TestPrototypeAgentAdd: """Test az prototype agent add command — all three modes.""" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_add_default_template(self, mock_dir, project_with_config): """Mode 1: --name only with interactive input → creates agent from prompts.""" from azext_prototype.custom import prototype_agent_add @@ -184,7 +184,7 @@ def test_add_default_template(self, mock_dir, project_with_config): content = _yaml.safe_load(agent_file.read_text(encoding="utf-8")) assert content["name"] == "my-data-agent" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_add_from_builtin_definition(self, mock_dir, project_with_config): """Mode 2: --name + --definition → copies named builtin definition.""" from azext_prototype.custom import prototype_agent_add @@ -205,7 +205,7 @@ def test_add_from_builtin_definition(self, mock_dir, project_with_config): content = _yaml.safe_load(agent_file.read_text(encoding="utf-8")) assert content["name"] == "my-architect" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_add_from_user_file(self, mock_dir, project_with_config): """Mode 3: --name + --file → copies user-supplied file.""" from azext_prototype.custom import prototype_agent_add @@ -235,7 +235,7 @@ def test_add_missing_name_raises(self): with pytest.raises(CLIError, match="--name"): prototype_agent_add(cmd, name=None) - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_add_file_and_definition_mutually_exclusive(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_agent_add @@ -245,7 +245,7 @@ def test_add_file_and_definition_mutually_exclusive(self, mock_dir, project_with with pytest.raises(CLIError, match="mutually exclusive"): prototype_agent_add(cmd, name="x", file="./a.yaml", definition="cloud_architect") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_add_unknown_definition_raises(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_agent_add @@ -255,7 +255,7 @@ def test_add_unknown_definition_raises(self, mock_dir, project_with_config): with pytest.raises(CLIError, match="Unknown definition"): prototype_agent_add(cmd, name="x", definition="nonexistent_agent") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_add_duplicate_name_raises(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_agent_add @@ -266,7 +266,7 @@ def test_add_duplicate_name_raises(self, mock_dir, project_with_config): with pytest.raises(CLIError, match="already exists"): prototype_agent_add(cmd, name="dup-agent", definition="cloud_architect") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_add_records_config_manifest(self, mock_dir, project_with_config): """Verify the agent is recorded in prototype.yaml.""" from azext_prototype.custom import _load_config, prototype_agent_add @@ -283,7 +283,7 @@ def test_add_records_config_manifest(self, mock_dir, project_with_config): assert "file" in custom["manifest-test"] assert "capabilities" in custom["manifest-test"] - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_add_file_not_found_raises(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_agent_add @@ -347,7 +347,7 @@ def test_rewrites_name_field(self, tmp_path): class TestPrototypeGenerateDocs: """Test az prototype generate docs command.""" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_generate_docs_creates_files(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_generate_docs @@ -364,7 +364,7 @@ def test_generate_docs_creates_files(self, mock_dir, project_with_config): md_files = list(docs_path.glob("*.md")) assert len(md_files) >= 1 - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_generate_docs_default_output(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_generate_docs @@ -379,7 +379,7 @@ def test_generate_docs_default_output(self, mock_dir, project_with_config): class TestPrototypeGenerateSpeckit: """Test az prototype generate speckit command.""" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_generate_speckit_creates_files(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_generate_speckit @@ -391,7 +391,7 @@ def test_generate_speckit_creates_files(self, mock_dir, project_with_config): assert result is not None assert result["status"] == "generated" - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._get_project_dir") def test_generate_speckit_manifest(self, mock_dir, project_with_config): from azext_prototype.custom import prototype_generate_speckit @@ -415,8 +415,8 @@ def test_generate_speckit_manifest(self, mock_dir, project_with_config): class TestPrototypeGenerateBacklog: """Test az prototype generate backlog command.""" - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") def test_generate_backlog_github(self, mock_dir, mock_check_req, project_with_design, mock_ai_provider): """Backlog session runs and returns result for github provider.""" from azext_prototype.custom import prototype_generate_backlog @@ -427,7 +427,7 @@ def test_generate_backlog_github(self, mock_dir, mock_check_req, project_with_de mock_result = BacklogResult(items_generated=3, items_pushed=0) - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx, patch( + with patch(f"{_MOD}._build_context") as mock_ctx, patch( "azext_prototype.stages.backlog_session.BacklogSession" ) as MockSession: from azext_prototype.agents.base import AgentContext @@ -446,8 +446,8 @@ def test_generate_backlog_github(self, mock_dir, mock_check_req, project_with_de assert result["provider"] == "github" assert result["items_generated"] == 3 - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") def test_generate_backlog_devops(self, mock_dir, mock_check_req, project_with_design, mock_ai_provider): """Backlog session runs for devops provider.""" from azext_prototype.custom import prototype_generate_backlog @@ -458,7 +458,7 @@ def test_generate_backlog_devops(self, mock_dir, mock_check_req, project_with_de mock_result = BacklogResult(items_generated=2, items_pushed=0) - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx, patch( + with patch(f"{_MOD}._build_context") as mock_ctx, patch( "azext_prototype.stages.backlog_session.BacklogSession" ) as MockSession: from azext_prototype.agents.base import AgentContext @@ -476,8 +476,8 @@ def test_generate_backlog_devops(self, mock_dir, mock_check_req, project_with_de assert result["status"] == "generated" assert result["provider"] == "devops" - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") def test_generate_backlog_invalid_provider_raises( self, mock_dir, mock_check_req, project_with_design, mock_ai_provider ): @@ -486,7 +486,7 @@ def test_generate_backlog_invalid_provider_raises( mock_dir.return_value = str(project_with_design) cmd = MagicMock() - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: + with patch(f"{_MOD}._build_context") as mock_ctx: from azext_prototype.agents.base import AgentContext ctx = AgentContext( @@ -499,15 +499,15 @@ def test_generate_backlog_invalid_provider_raises( with pytest.raises(CLIError, match="Unsupported backlog provider"): prototype_generate_backlog(cmd, provider="jira", org="x", project="y") - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") def test_generate_backlog_no_design_raises(self, mock_dir, mock_check_req, project_with_config, mock_ai_provider): from azext_prototype.custom import prototype_generate_backlog mock_dir.return_value = str(project_with_config) cmd = MagicMock() - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx: + with patch(f"{_MOD}._build_context") as mock_ctx: from azext_prototype.agents.base import AgentContext ctx = AgentContext( @@ -520,8 +520,8 @@ def test_generate_backlog_no_design_raises(self, mock_dir, mock_check_req, proje with pytest.raises(CLIError, match="No architecture design found"): prototype_generate_backlog(cmd, provider="github", org="x", project="y") - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") def test_generate_backlog_defaults_from_config( self, mock_dir, mock_check_req, project_with_design, mock_ai_provider ): @@ -544,7 +544,7 @@ def test_generate_backlog_defaults_from_config( mock_result = BacklogResult(items_generated=1, items_pushed=0) - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx, patch( + with patch(f"{_MOD}._build_context") as mock_ctx, patch( "azext_prototype.stages.backlog_session.BacklogSession" ) as MockSession: from azext_prototype.agents.base import AgentContext @@ -561,8 +561,8 @@ def test_generate_backlog_defaults_from_config( assert result["provider"] == "devops" - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") def test_generate_backlog_result_fields(self, mock_dir, mock_check_req, project_with_design, mock_ai_provider): """Result dict includes expected fields.""" from azext_prototype.custom import prototype_generate_backlog @@ -573,7 +573,7 @@ def test_generate_backlog_result_fields(self, mock_dir, mock_check_req, project_ mock_result = BacklogResult(items_generated=1, items_pushed=0) - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx, patch( + with patch(f"{_MOD}._build_context") as mock_ctx, patch( "azext_prototype.stages.backlog_session.BacklogSession" ) as MockSession: from azext_prototype.agents.base import AgentContext @@ -591,8 +591,8 @@ def test_generate_backlog_result_fields(self, mock_dir, mock_check_req, project_ assert result["status"] == "generated" assert result["items_generated"] == 1 - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") def test_generate_backlog_prompts_when_unconfigured( self, mock_dir, mock_check_req, project_with_design, mock_ai_provider ): @@ -605,8 +605,8 @@ def test_generate_backlog_prompts_when_unconfigured( mock_result = BacklogResult(items_generated=1, items_pushed=0) - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx, patch( - f"{_CUSTOM_MODULE}._prompt_backlog_config" + with patch(f"{_MOD}._build_context") as mock_ctx, patch( + f"{_MOD}._prompt_backlog_config" ) as mock_prompt, patch("azext_prototype.stages.backlog_session.BacklogSession") as MockSession: from azext_prototype.agents.base import AgentContext @@ -639,8 +639,8 @@ def test_generate_backlog_prompts_when_unconfigured( assert saved["backlog"]["org"] == "prompted-org" assert saved["backlog"]["project"] == "prompted-repo" - @patch(f"{_CUSTOM_MODULE}._check_requirements") - @patch(f"{_CUSTOM_MODULE}._get_project_dir") + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") def test_generate_backlog_no_prompt_when_fully_configured( self, mock_dir, mock_check_req, project_with_design, mock_ai_provider ): @@ -653,8 +653,8 @@ def test_generate_backlog_no_prompt_when_fully_configured( mock_result = BacklogResult(items_generated=1, items_pushed=0) - with patch(f"{_CUSTOM_MODULE}._build_context") as mock_ctx, patch( - f"{_CUSTOM_MODULE}._prompt_backlog_config" + with patch(f"{_MOD}._build_context") as mock_ctx, patch( + f"{_MOD}._prompt_backlog_config" ) as mock_prompt, patch("azext_prototype.stages.backlog_session.BacklogSession") as MockSession: from azext_prototype.agents.base import AgentContext @@ -729,3 +729,2193 @@ def test_invalid_choice_retries(self): result = _prompt_backlog_config() assert result["provider"] == "github" + +# ====================================================================== +# Helper functions +# ====================================================================== + + +class TestBuildRegistry: + """Test _build_registry helper.""" + + def test_build_registry_builtin_only(self): + from azext_prototype.custom import _build_registry + + registry = _build_registry(config=None, project_dir=None) + agents = registry.list_all() + assert len(agents) >= 8 + + def test_build_registry_with_custom_agents(self, project_with_config): + from azext_prototype.custom import _build_registry, _load_config + + # Create a custom YAML agent + agent_dir = project_with_config / ".prototype" / "agents" + agent_dir.mkdir(parents=True, exist_ok=True) + (agent_dir / "test-agent.yaml").write_text( + "name: test-agent\ndescription: A test\ncapabilities:\n - develop\n" "system_prompt: You are a test.\n", + encoding="utf-8", + ) + + config = _load_config(str(project_with_config)) + registry = _build_registry(config, str(project_with_config)) + names = [a.name for a in registry.list_all()] + assert "test-agent" in names + + def test_build_registry_with_overrides(self, project_with_config): + from azext_prototype.custom import _build_registry, _load_config + + # Write a YAML agent to use as override + override_file = project_with_config / "override.yaml" + override_file.write_text( + "name: cloud-architect\ndescription: Override\ncapabilities:\n - architect\n" + "system_prompt: Override prompt.\n", + encoding="utf-8", + ) + + config = _load_config(str(project_with_config)) + config.set("agents.overrides", {"cloud-architect": "override.yaml"}) + + registry = _build_registry(config, str(project_with_config)) + agent = registry.get("cloud-architect") + assert "Override" in agent.description + + +class TestBuildContext: + """Test _build_context helper.""" + + @patch("azext_prototype.ai.factory.create_ai_provider") + def test_build_context_creates_agent_context(self, mock_factory, project_with_config): + from azext_prototype.custom import _build_context, _load_config + + mock_provider = MagicMock() + mock_factory.return_value = mock_provider + config = _load_config(str(project_with_config)) + + ctx = _build_context(config, str(project_with_config)) + assert ctx.project_dir == str(project_with_config) + assert ctx.ai_provider is mock_provider + + +class TestPrepareCommand: + """Test _prepare_command helper.""" + + @patch(f"{_MOD}._check_requirements") + @patch("azext_prototype.ai.factory.create_ai_provider") + def test_prepare_command(self, mock_factory, mock_check_req, project_with_config): + from azext_prototype.custom import _prepare_command + + mock_factory.return_value = MagicMock() + pd, config, registry, ctx = _prepare_command(str(project_with_config)) + assert pd == str(project_with_config) + assert config is not None + assert registry is not None + assert ctx is not None + + +class TestCheckRequirements: + """Test _check_requirements wiring in command entry points.""" + + def test_check_requirements_passes_when_all_ok(self): + from azext_prototype.custom import _check_requirements + from azext_prototype.requirements import CheckResult + + with patch("azext_prototype.requirements.check_all") as mock_check: + mock_check.return_value = [ + CheckResult(name="Python", status="pass", installed_version="3.12.0", required=">=3.9.0", message="ok"), + ] + # Should not raise + _check_requirements("terraform") + + def test_check_requirements_raises_on_missing(self): + from azext_prototype.custom import _check_requirements + from azext_prototype.requirements import CheckResult + + with patch("azext_prototype.requirements.check_all") as mock_check: + mock_check.return_value = [ + CheckResult( + name="Terraform", + status="missing", + installed_version=None, + required=">=1.14.0", + message="Terraform is not installed", + install_hint="https://developer.hashicorp.com/terraform/install", + ), + ] + with pytest.raises(CLIError, match="Tool requirements not met"): + _check_requirements("terraform") + + def test_check_requirements_raises_on_version_fail(self): + from azext_prototype.custom import _check_requirements + from azext_prototype.requirements import CheckResult + + with patch("azext_prototype.requirements.check_all") as mock_check: + mock_check.return_value = [ + CheckResult( + name="Azure CLI", + status="fail", + installed_version="2.40.0", + required=">=2.50.0", + message="Azure CLI 2.40.0 does not satisfy >=2.50.0", + install_hint="https://learn.microsoft.com/cli/azure/install-azure-cli", + ), + ] + with pytest.raises(CLIError, match="Azure CLI"): + _check_requirements(None) + + def test_check_requirements_includes_install_hint(self): + from azext_prototype.custom import _check_requirements + from azext_prototype.requirements import CheckResult + + with patch("azext_prototype.requirements.check_all") as mock_check: + mock_check.return_value = [ + CheckResult( + name="Terraform", + status="missing", + installed_version=None, + required=">=1.14.0", + message="Terraform is not installed", + install_hint="https://developer.hashicorp.com/terraform/install", + ), + ] + with pytest.raises(CLIError, match="Install:.*hashicorp"): + _check_requirements("terraform") + + @patch("azext_prototype.ai.factory.create_ai_provider") + def test_prepare_command_calls_check_requirements(self, mock_factory, project_with_config): + from azext_prototype.custom import _prepare_command + + mock_factory.return_value = MagicMock() + with patch(f"{_MOD}._check_requirements") as mock_check: + _prepare_command(str(project_with_config)) + mock_check.assert_called_once() + + def test_init_calls_check_requirements(self, tmp_path): + with patch(f"{_MOD}._check_requirements") as mock_check, patch( + "azext_prototype.stages.init_stage.InitStage" + ) as MockStage: + from azext_prototype.custom import prototype_init + + mock_stage = MockStage.return_value + mock_stage.can_run.return_value = (True, []) + mock_stage.execute.return_value = {"status": "success"} + + cmd = MagicMock() + prototype_init(cmd, name="test", location="eastus", output_dir=str(tmp_path)) + mock_check.assert_called_once_with("terraform") # default iac_tool + + +class TestCheckGuards: + """Test _check_guards helper.""" + + def test_check_guards_pass(self): + from azext_prototype.custom import _check_guards + + stage = MagicMock() + stage.can_run.return_value = (True, []) + _check_guards(stage) # Should not raise + + def test_check_guards_fail(self): + from azext_prototype.custom import _check_guards + + stage = MagicMock() + stage.can_run.return_value = (False, ["Missing gh CLI"]) + with pytest.raises(CLIError, match="Prerequisites not met"): + _check_guards(stage) + + +class TestGetRegistryWithFallback: + """Test _get_registry_with_fallback helper.""" + + def test_with_valid_config(self, project_with_config): + from azext_prototype.custom import _get_registry_with_fallback + + registry = _get_registry_with_fallback(str(project_with_config)) + assert len(registry.list_all()) >= 8 + + def test_without_config_falls_back(self, tmp_project): + from azext_prototype.custom import _get_registry_with_fallback + + registry = _get_registry_with_fallback(str(tmp_project)) + assert len(registry.list_all()) >= 8 + + +# ====================================================================== +# Stage commands +# ====================================================================== + + +class TestPrototypeInit: + """Test the init command.""" + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._check_guards") + @patch("azext_prototype.auth.copilot_license.CopilotLicenseValidator") + @patch("azext_prototype.auth.github_auth.GitHubAuthManager") + @patch("azext_prototype.stages.init_stage.InitStage._check_gh", return_value=True) + def test_init_success(self, mock_gh, mock_auth_cls, mock_lic_cls, mock_guards, mock_check_req, tmp_path): + from azext_prototype.custom import prototype_init + + mock_auth = MagicMock() + mock_auth.ensure_authenticated.return_value = {"login": "testuser"} + mock_auth_cls.return_value = mock_auth + + mock_lic = MagicMock() + mock_lic.validate_license.return_value = {"plan": "business", "status": "active"} + mock_lic_cls.return_value = mock_lic + + cmd = MagicMock() + out = tmp_path / "test-proj" + result = prototype_init( + cmd, + name="test-proj", + location="eastus", + output_dir=str(out), + ai_provider="github-models", + json_output=True, + ) + + assert result["status"] == "success" + assert result["github_user"] == "testuser" + assert out.is_dir() + assert (out / "prototype.yaml").exists() + assert (out / ".gitignore").exists() + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._check_guards") + def test_init_azure_openai_skips_license(self, mock_guards, mock_check_req, tmp_path): + from azext_prototype.custom import prototype_init + + cmd = MagicMock() + result = prototype_init( + cmd, + name="aoai-proj", + location="eastus", + output_dir=str(tmp_path / "aoai-proj"), + ai_provider="azure-openai", + json_output=True, + ) + + assert result["status"] == "success" + assert "copilot_license" not in result + assert result["github_user"] is None + + @patch(f"{_MOD}._check_requirements") + def test_init_missing_name_raises(self, mock_check_req, tmp_path): + from azext_prototype.custom import prototype_init + from azext_prototype.stages.init_stage import InitStage + + cmd = MagicMock() + # Need to bypass guards + with patch.object(InitStage, "get_guards", return_value=[]): + with pytest.raises(CLIError, match="Project name"): + prototype_init(cmd, name=None, location="eastus", output_dir=str(tmp_path / "no-name")) + + @patch(f"{_MOD}._check_requirements") + def test_init_missing_location_raises(self, mock_check_req, tmp_path): + from azext_prototype.custom import prototype_init + from azext_prototype.stages.init_stage import InitStage + + cmd = MagicMock() + with patch.object(InitStage, "get_guards", return_value=[]): + with pytest.raises(CLIError, match="region is required"): + prototype_init(cmd, name="test-proj", location=None, output_dir=str(tmp_path / "test-proj")) + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._check_guards") + def test_init_idempotency_cancel(self, mock_guards, mock_check_req, tmp_path): + """If project exists and user declines, init should cancel.""" + from azext_prototype.custom import prototype_init + + # Create existing project + proj_dir = tmp_path / "existing-proj" + proj_dir.mkdir() + (proj_dir / "prototype.yaml").write_text("project:\n name: old\n") + + cmd = MagicMock() + with patch("builtins.input", return_value="n"): + result = prototype_init( + cmd, + name="existing-proj", + location="eastus", + output_dir=str(proj_dir), + ai_provider="azure-openai", + json_output=True, + ) + assert result["status"] == "cancelled" + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._check_guards") + def test_init_idempotency_reinitialize(self, mock_guards, mock_check_req, tmp_path): + """If project exists and user confirms, init should proceed.""" + from azext_prototype.custom import prototype_init + + proj_dir = tmp_path / "reinit-proj" + proj_dir.mkdir() + (proj_dir / "prototype.yaml").write_text("project:\n name: old\n") + + cmd = MagicMock() + with patch("builtins.input", return_value="y"): + result = prototype_init( + cmd, + name="reinit-proj", + location="eastus", + output_dir=str(proj_dir), + ai_provider="azure-openai", + json_output=True, + ) + assert result["status"] == "success" + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._check_guards") + def test_init_environment_parameter(self, mock_guards, mock_check_req, tmp_path): + """--environment should be stored in config.""" + from azext_prototype.config import ProjectConfig + from azext_prototype.custom import prototype_init + + cmd = MagicMock() + out = tmp_path / "env-proj" + result = prototype_init( + cmd, + name="env-proj", + location="westus2", + output_dir=str(out), + ai_provider="azure-openai", + environment="staging", + json_output=True, + ) + assert result["status"] == "success" + config = ProjectConfig(str(out)) + config.load() + assert config.get("project.environment") == "staging" + assert config.get("naming.env") == "stg" + assert config.get("naming.zone_id") == "zs" + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._check_guards") + def test_init_model_parameter(self, mock_guards, mock_check_req, tmp_path): + """--model should override the provider default.""" + from azext_prototype.config import ProjectConfig + from azext_prototype.custom import prototype_init + + cmd = MagicMock() + out = tmp_path / "model-proj" + result = prototype_init( + cmd, + name="model-proj", + location="eastus", + output_dir=str(out), + ai_provider="azure-openai", + model="gpt-4o-mini", + json_output=True, + ) + assert result["status"] == "success" + config = ProjectConfig(str(out)) + config.load() + assert config.get("ai.model") == "gpt-4o-mini" + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._check_guards") + def test_init_default_model_per_provider(self, mock_guards, mock_check_req, tmp_path): + """Without --model, the default should be provider-specific.""" + from azext_prototype.config import ProjectConfig + from azext_prototype.custom import prototype_init + + cmd = MagicMock() + out = tmp_path / "defmodel-proj" + result = prototype_init( + cmd, + name="defmodel-proj", + location="eastus", + output_dir=str(out), + ai_provider="azure-openai", + json_output=True, + ) + assert result["status"] == "success" + config = ProjectConfig(str(out)) + config.load() + assert config.get("ai.model") == "gpt-4o" + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._check_guards") + def test_init_sends_telemetry_overrides(self, mock_guards, mock_check_req, tmp_path): + """Init should set _telemetry_overrides with resolved values.""" + from azext_prototype.custom import prototype_init + + cmd = MagicMock() + prototype_init( + cmd, + name="telem-proj", + location="westeurope", + output_dir=str(tmp_path / "telem-proj"), + ai_provider="azure-openai", + environment="staging", + iac_tool="bicep", + ) + + assert isinstance(cmd._telemetry_overrides, dict) + overrides = cmd._telemetry_overrides + assert overrides["location"] == "westeurope" + assert overrides["ai_provider"] == "azure-openai" + assert overrides["model"] == "gpt-4o" # resolved default + assert overrides["iac_tool"] == "bicep" + assert overrides["environment"] == "staging" + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._check_guards") + def test_init_telemetry_overrides_explicit_model(self, mock_guards, mock_check_req, tmp_path): + """When --model is explicit, overrides should use that value.""" + from azext_prototype.custom import prototype_init + + cmd = MagicMock() + prototype_init( + cmd, + name="telem-model-proj", + location="eastus", + output_dir=str(tmp_path / "telem-model-proj"), + ai_provider="azure-openai", + model="gpt-4o-mini", + ) + + overrides = cmd._telemetry_overrides + assert overrides["model"] == "gpt-4o-mini" + assert overrides["ai_provider"] == "azure-openai" + + +class TestPrototypeConfigGet: + """Test the config get command.""" + + def test_config_get_basic(self, project_with_config): + from azext_prototype.custom import prototype_config_get + + cmd = MagicMock() + with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): + result = prototype_config_get(cmd, key="ai.provider", json_output=True) + assert result == {"key": "ai.provider", "value": "github-models"} + + def test_config_get_missing_key(self, project_with_config): + from azext_prototype.custom import prototype_config_get + + cmd = MagicMock() + with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): + with pytest.raises(CLIError, match="not found"): + prototype_config_get(cmd, key="nonexistent.key") + + def test_config_get_masks_secret(self, project_with_config): + from azext_prototype.config import ProjectConfig + from azext_prototype.custom import prototype_config_get + + # Set a secret value first + config = ProjectConfig(str(project_with_config)) + config.load() + config._secrets = {"deploy": {"subscription": "secret-sub-id"}} + config._config["deploy"]["subscription"] = "secret-sub-id" + config.save() + config.save_secrets() + + cmd = MagicMock() + with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): + result = prototype_config_get(cmd, key="deploy.subscription", json_output=True) + assert result == {"key": "deploy.subscription", "value": "***"} + + +class TestPrototypeConfigShowMasking: + """Test that config show masks secrets.""" + + def test_config_show_masks_secret_values(self, project_with_config): + from azext_prototype.config import ProjectConfig + from azext_prototype.custom import prototype_config_show + + # Set a secret value + config = ProjectConfig(str(project_with_config)) + config.load() + config._secrets = {"deploy": {"subscription": "my-secret-sub"}} + config._config["deploy"]["subscription"] = "my-secret-sub" + config.save() + config.save_secrets() + + cmd = MagicMock() + with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): + result = prototype_config_show(cmd, json_output=True) + assert result["deploy"]["subscription"] == "***" + + def test_config_show_preserves_non_secrets(self, project_with_config): + from azext_prototype.custom import prototype_config_show + + cmd = MagicMock() + with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): + result = prototype_config_show(cmd, json_output=True) + # Non-secret value should not be masked + assert result["ai"]["provider"] == "github-models" + + +class TestPrototypeConfigInit: + """Test config init marks init complete.""" + + @patch( + "builtins.input", + side_effect=[ + "y", # overwrite existing prototype.yaml + "my-project", # project name + "eastus", # location + "dev", # environment + "terraform", # iac tool + "1", # naming strategy choice (microsoft-alz) + "myorg", # org + "zd", # zone_id (ALZ-specific) + "copilot", # ai provider + "", # model (accept default) + "", # subscription + "", # resource group + ], + ) + def test_config_init_marks_init_complete(self, mock_input, project_with_config): + from azext_prototype.config import ProjectConfig + from azext_prototype.custom import prototype_config_init + + cmd = MagicMock() + with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): + prototype_config_init(cmd) + + config = ProjectConfig(str(project_with_config)) + config.load() + assert config.get("stages.init.completed") is True + assert config.get("stages.init.timestamp") is not None + + @patch( + "builtins.input", + side_effect=[ + "y", # overwrite existing prototype.yaml + "telemetry-proj", # project name + "westus2", # location + "staging", # environment + "bicep", # iac tool + "2", # naming strategy choice (microsoft-caf) + "myorg", # org + "azure-openai", # ai provider + "gpt-4o", # model + "https://myres.openai.azure.com/", # Azure OpenAI endpoint + "gpt-4o", # deployment name + "", # subscription + "", # resource group + ], + ) + def test_config_init_sends_telemetry_overrides(self, mock_input, project_with_config): + """After prompting, config init should set _telemetry_overrides on cmd.""" + from azext_prototype.custom import prototype_config_init + + cmd = MagicMock() + with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): + prototype_config_init(cmd) + + assert hasattr(cmd, "_telemetry_overrides") + overrides = cmd._telemetry_overrides + assert overrides["location"] == "westus2" + assert overrides["ai_provider"] == "azure-openai" + assert overrides["model"] == "gpt-4o" + assert overrides["iac_tool"] == "bicep" + assert overrides["environment"] == "staging" + assert overrides["naming_strategy"] == "microsoft-caf" + + def test_config_init_cancelled_no_overrides(self, project_with_config): + """If config init is cancelled, no telemetry overrides should be set.""" + from azext_prototype.custom import prototype_config_init + + cmd = MagicMock(spec=[]) # strict spec — no auto-attributes + with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): + with patch("builtins.input", return_value="n"): + result = prototype_config_init(cmd, json_output=True) + assert result["status"] == "cancelled" + assert not hasattr(cmd, "_telemetry_overrides") + + +class TestPrototypeBuild: + """Test the build command.""" + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") + @patch("azext_prototype.ai.factory.create_ai_provider") + @patch(f"{_MOD}._check_guards") + def test_build_calls_stage( + self, mock_guards, mock_factory, mock_dir, mock_check_req, project_with_design, mock_ai_provider + ): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.custom import prototype_build + + mock_dir.return_value = str(project_with_design) + mock_factory.return_value = mock_ai_provider + mock_ai_provider.chat.return_value = AIResponse( + content="```main.tf\nresource null {}\n```", + model="gpt-4o", + ) + + cmd = MagicMock() + result = prototype_build(cmd, scope="docs", dry_run=True, json_output=True) + assert result["status"] == "dry-run" + + +class TestPrototypeDeploy: + """Test the deploy command.""" + + @patch(f"{_MOD}._check_requirements") + @patch(f"{_MOD}._get_project_dir") + @patch("azext_prototype.ai.factory.create_ai_provider") + def test_deploy_status(self, mock_factory, mock_dir, mock_check_req, project_with_build, mock_ai_provider): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_build) + mock_factory.return_value = mock_ai_provider + + cmd = MagicMock() + result = prototype_deploy(cmd, status=True, json_output=True) + assert result["status"] == "displayed" + + +class TestPrototypeDeployOutputs: + """Test deploy --outputs flag.""" + + @patch(f"{_MOD}._get_project_dir") + def test_no_outputs(self, mock_dir, project_with_build): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_build) + cmd = MagicMock() + result = prototype_deploy(cmd, outputs=True, json_output=True) + assert result["status"] == "empty" + + @patch(f"{_MOD}._get_project_dir") + def test_with_outputs(self, mock_dir, project_with_build): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_build) + # Write outputs file + outputs_dir = project_with_build / ".prototype" / "state" + outputs_dir.mkdir(parents=True, exist_ok=True) + (outputs_dir / "deploy_outputs.json").write_text(json.dumps({"rg_name": "test-rg"}), encoding="utf-8") + cmd = MagicMock() + result = prototype_deploy(cmd, outputs=True, json_output=True) + # May return empty or dict depending on DeploymentOutputCapture impl + assert isinstance(result, dict) + + +class TestPrototypeDeployRollbackInfo: + """Test deploy --rollback-info flag.""" + + @patch(f"{_MOD}._get_project_dir") + def test_rollback_info(self, mock_dir, project_with_build): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_build) + cmd = MagicMock() + result = prototype_deploy(cmd, rollback_info=True, json_output=True) + assert "last_deployment" in result + assert "rollback_instructions" in result + + +class TestPrototypeDeployGenerateScripts: + """Test deploy --generate-scripts flag.""" + + @patch(f"{_MOD}._get_project_dir") + def test_generate_scripts_no_apps(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + # concept/apps exists but empty (not created by init; build creates it) + (project_with_config / "concept" / "apps").mkdir(parents=True, exist_ok=True) + result = prototype_deploy(cmd, generate_scripts=True, json_output=True) + assert result["status"] == "generated" + assert len(result["scripts"]) == 0 + + @patch(f"{_MOD}._get_project_dir") + def test_generate_scripts_with_apps(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_config) + # Create app directories + apps_dir = project_with_config / "concept" / "apps" + (apps_dir / "backend").mkdir(parents=True, exist_ok=True) + (apps_dir / "frontend").mkdir(parents=True, exist_ok=True) + + cmd = MagicMock() + result = prototype_deploy(cmd, generate_scripts=True, script_deploy_type="webapp", json_output=True) + assert result["status"] == "generated" + assert len(result["scripts"]) == 2 + + @patch(f"{_MOD}._get_project_dir") + def test_generate_scripts_no_apps_dir_raises(self, mock_dir, project_with_config): + # Remove apps dir if present + import shutil + + from azext_prototype.custom import prototype_deploy + + apps_dir = project_with_config / "concept" / "apps" + if apps_dir.exists(): + shutil.rmtree(apps_dir) + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + with pytest.raises(CLIError, match="No apps directory"): + prototype_deploy(cmd, generate_scripts=True) + + +class TestPrototypeAgentOverride: + """Test agent override command.""" + + @patch(f"{_MOD}._get_project_dir") + def test_override_registers(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_override + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + # Create a real YAML file for the override + override_file = project_with_config / "my_arch.yaml" + override_file.write_text( + "name: cloud-architect\ndescription: Custom Override\n" + "capabilities:\n - architect\nsystem_prompt: Custom prompt.\n", + encoding="utf-8", + ) + + result = prototype_agent_override(cmd, name="cloud-architect", file="my_arch.yaml", json_output=True) + assert result["status"] == "override_registered" + assert result["name"] == "cloud-architect" + + def test_override_missing_name_raises(self): + from azext_prototype.custom import prototype_agent_override + + cmd = MagicMock() + with pytest.raises(CLIError, match="--name"): + prototype_agent_override(cmd, name=None, file="x.yaml") + + def test_override_missing_file_raises(self): + from azext_prototype.custom import prototype_agent_override + + cmd = MagicMock() + with pytest.raises(CLIError, match="--file"): + prototype_agent_override(cmd, name="x", file=None) + + +class TestPrototypeAgentRemove: + """Test agent remove command.""" + + @patch(f"{_MOD}._get_project_dir") + def test_remove_custom_agent(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_add, prototype_agent_remove + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + # Add then remove + prototype_agent_add(cmd, name="to-remove", definition="cloud_architect") + result = prototype_agent_remove(cmd, name="to-remove", json_output=True) + assert result["status"] == "removed" + + @patch(f"{_MOD}._get_project_dir") + def test_remove_override_agent(self, mock_dir, project_with_config): + from azext_prototype.custom import ( + prototype_agent_override, + prototype_agent_remove, + ) + + mock_dir.return_value = str(project_with_config) + + # Create a real YAML file for the override + override_file = project_with_config / "my_arch.yaml" + override_file.write_text( + "name: cloud-architect\ndescription: Override\n" "capabilities:\n - architect\nsystem_prompt: Override.\n", + encoding="utf-8", + ) + + cmd = MagicMock() + prototype_agent_override(cmd, name="cloud-architect", file="my_arch.yaml") + result = prototype_agent_remove(cmd, name="cloud-architect", json_output=True) + assert result["status"] == "override_removed" + + @patch(f"{_MOD}._get_project_dir") + def test_remove_builtin_raises(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_remove + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + # bicep-agent is builtin and not custom/override → should raise + with pytest.raises(CLIError, match="Built-in agents cannot be removed"): + prototype_agent_remove(cmd, name="app-developer") + + def test_remove_missing_name_raises(self): + from azext_prototype.custom import prototype_agent_remove + + cmd = MagicMock() + with pytest.raises(CLIError, match="--name"): + prototype_agent_remove(cmd, name=None) + + +class TestPrototypeAnalyzeError: + """Test the error analysis command.""" + + def test_missing_input_raises(self): + from azext_prototype.custom import prototype_analyze_error + + cmd = MagicMock() + with pytest.raises(CLIError, match="Error input is required"): + prototype_analyze_error(cmd, input=None) + + @patch(f"{_MOD}._prepare_command") + def test_analyze_inline_error(self, mock_prep, project_with_design, mock_ai_provider): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.custom import prototype_analyze_error + + mock_qa = MagicMock() + mock_qa.name = "qa-engineer" + mock_qa.execute.return_value = AIResponse(content="Root cause: missing RBAC", model="gpt-4o") + + mock_registry = MagicMock() + mock_registry.find_by_capability.return_value = [mock_qa] + + mock_ctx = MagicMock() + mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, mock_ctx) + + cmd = MagicMock() + result = prototype_analyze_error(cmd, input="ResourceNotFound error", json_output=True) + assert result["status"] == "analyzed" + + @patch(f"{_MOD}._prepare_command") + def test_analyze_log_file(self, mock_prep, project_with_design, mock_ai_provider): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.custom import prototype_analyze_error + + mock_qa = MagicMock() + mock_qa.name = "qa-engineer" + mock_qa.execute.return_value = AIResponse(content="Root cause: config error", model="gpt-4o") + + mock_registry = MagicMock() + mock_registry.find_by_capability.return_value = [mock_qa] + + mock_ctx = MagicMock() + mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, mock_ctx) + + log_file = project_with_design / "error.log" + log_file.write_text("ERROR: Connection refused", encoding="utf-8") + + cmd = MagicMock() + result = prototype_analyze_error(cmd, input=str(log_file), json_output=True) + assert result["status"] == "analyzed" + + @patch(f"{_MOD}._prepare_command") + def test_analyze_screenshot(self, mock_prep, project_with_design, mock_ai_provider): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.custom import prototype_analyze_error + + mock_qa = MagicMock() + mock_qa.name = "qa-engineer" + mock_qa.execute_with_image.return_value = AIResponse(content="Screenshot analysis", model="gpt-4o") + + mock_registry = MagicMock() + mock_registry.find_by_capability.return_value = [mock_qa] + + mock_ctx = MagicMock() + mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, mock_ctx) + + img = project_with_design / "error.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + + cmd = MagicMock() + result = prototype_analyze_error(cmd, input=str(img), json_output=True) + assert result["status"] == "analyzed" + + +class TestPrototypeAnalyzeCosts: + """Test the cost analysis command.""" + + @patch(f"{_MOD}._prepare_command") + def test_analyze_costs(self, mock_prep, project_with_design, mock_ai_provider): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.custom import prototype_analyze_costs + + mock_cost = MagicMock() + mock_cost.name = "cost-analyst" + mock_cost.execute.return_value = AIResponse(content="Cost report content", model="gpt-4o") + + mock_registry = MagicMock() + mock_registry.find_by_capability.return_value = [mock_cost] + + mock_ctx = MagicMock() + mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, mock_ctx) + + cmd = MagicMock() + result = prototype_analyze_costs(cmd, json_output=True) + assert result["status"] == "analyzed" + + @patch(f"{_MOD}._prepare_command") + def test_analyze_costs_no_agent_raises(self, mock_prep, project_with_design): + from azext_prototype.custom import prototype_analyze_costs + + mock_registry = MagicMock() + mock_registry.find_by_capability.return_value = [] + mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, MagicMock()) + + cmd = MagicMock() + with pytest.raises(CLIError, match="No cost analyst"): + prototype_analyze_costs(cmd) + + +class TestExtractCostTable: + """Test _extract_cost_table helper.""" + + def test_extracts_summary_table(self): + from azext_prototype.custom import _extract_cost_table + + content = ( + "# Executive Summary\n\nSome intro text.\n\n---\n\n" + "## Cost Summary Table\n\n" + " Service Small Medium Large\n" + " ──────────────────────────────────────────\n" + " App Service $0.00 $13.14 $74.00\n" + " TOTAL $0.00 $13.14 $74.00\n" + "\n\n---\n\n" + "## T-Shirt Size Definitions\n\nMore details...\n" + ) + result = _extract_cost_table(content) + assert "Cost Summary Table" in result + assert "$13.14" in result + assert "T-Shirt Size" not in result + + def test_fallback_on_no_heading(self): + from azext_prototype.custom import _extract_cost_table + + content = "No table here, just text about the architecture." + result = _extract_cost_table(content) + assert result == content + + +class TestPrototypeConfigSetExtended: + """Additional config set tests.""" + + def test_config_set_missing_value_raises(self): + from azext_prototype.custom import prototype_config_set + + cmd = MagicMock() + with pytest.raises(CLIError, match="--value"): + prototype_config_set(cmd, key="some.key", value=None) + + @patch(f"{_MOD}._get_project_dir") + def test_config_set_json_value(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_config_set + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + result = prototype_config_set(cmd, key="deploy.tags", value='{"env":"dev"}', json_output=True) + assert result["status"] == "updated" + + +class TestPrototypeStatusExtended: + """Extended status tests.""" + + @patch(f"{_MOD}._get_project_dir") + def test_status_with_build_shows_changes(self, mock_dir, project_with_build): + from azext_prototype.custom import prototype_status + + mock_dir.return_value = str(project_with_build) + cmd = MagicMock() + result = prototype_status(cmd, json_output=True) + # If build stage is marked completed, pending_changes should exist + if result.get("stages", {}).get("build", {}).get("completed"): + assert "pending_changes" in result + else: + # Build state exists → pending_changes may still be present + assert "stages" in result + + @patch(f"{_MOD}._get_project_dir") + def test_status_default_uses_console(self, mock_dir, project_with_config): + """Default mode (no flags) uses console output and returns None (suppressed).""" + from azext_prototype.custom import prototype_status + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + with patch("azext_prototype.custom.console", create=True): + result = prototype_status(cmd) + + assert result is None + + @patch(f"{_MOD}._get_project_dir") + def test_status_json_returns_enriched_dict(self, mock_dir, project_with_config): + """--json returns enriched dict with all new fields.""" + from azext_prototype.custom import prototype_status + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + result = prototype_status(cmd, json_output=True) + + assert isinstance(result, dict) + assert result["project"] == "test-project" + assert "environment" in result + assert "naming_strategy" in result + assert "project_id" in result + assert "deployment_history" in result + # All three stages present + for stage in ("design", "build", "deploy"): + assert stage in result["stages"] + assert "completed" in result["stages"][stage] + + @patch(f"{_MOD}._get_project_dir") + def test_status_detailed_prints_detail(self, mock_dir, project_with_config): + """--detailed prints expanded output and returns None (suppressed).""" + from azext_prototype.custom import prototype_status + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + with patch("azext_prototype.custom.console", create=True): + result = prototype_status(cmd, detailed=True) + + assert result is None + + @patch(f"{_MOD}._get_project_dir") + def test_status_with_discovery_state(self, mock_dir, project_with_config): + """Discovery state populates exchanges/confirmed/open.""" + import yaml + + from azext_prototype.custom import prototype_status + + state_dir = project_with_config / ".prototype" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + state_file = state_dir / "discovery.yaml" + state_file.write_text( + yaml.dump( + { + "open_items": ["item1"], + "confirmed_items": ["item2", "item3"], + "conversation_history": [], + "_metadata": { + "exchange_count": 5, + "created": "2026-01-01T00:00:00", + "last_updated": "2026-01-01T01:00:00", + }, + } + ), + encoding="utf-8", + ) + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + result = prototype_status(cmd, json_output=True) + + d = result["stages"]["design"] + assert d["exchanges"] == 5 + assert d["confirmed"] == 2 + assert d["open"] == 1 + + @patch(f"{_MOD}._get_project_dir") + def test_status_with_build_state(self, mock_dir, project_with_build): + """Build state populates templates/stages/files/overrides.""" + from azext_prototype.custom import prototype_status + + mock_dir.return_value = str(project_with_build) + cmd = MagicMock() + result = prototype_status(cmd, json_output=True) + + b = result["stages"]["build"] + assert "templates_used" in b + assert "total_stages" in b + assert "accepted_stages" in b + assert "files_generated" in b + assert "policy_overrides" in b + assert b["total_stages"] >= 0 + + @patch(f"{_MOD}._get_project_dir") + def test_status_with_deploy_state(self, mock_dir, project_with_config): + """Deploy state populates deployed/failed/rolled_back/outputs.""" + import yaml + + from azext_prototype.custom import prototype_status + + state_dir = project_with_config / ".prototype" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + state_file = state_dir / "deploy.yaml" + state_file.write_text( + yaml.dump( + { + "deployment_stages": [ + {"stage": 1, "name": "Foundation", "deploy_status": "deployed", "services": []}, + { + "stage": 2, + "name": "App", + "deploy_status": "failed", + "deploy_error": "timeout", + "services": [], + }, + ], + "captured_outputs": {"terraform": {"endpoint": "https://example.com"}}, + "_metadata": {"created": "2026-01-01T00:00:00", "last_updated": "2026-01-01T01:00:00"}, + } + ), + encoding="utf-8", + ) + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + result = prototype_status(cmd, json_output=True) + + dp = result["stages"]["deploy"] + assert dp["total_stages"] == 2 + assert dp["deployed"] == 1 + assert dp["failed"] == 1 + assert dp["rolled_back"] == 0 + assert dp["outputs_captured"] == 1 + + @patch(f"{_MOD}._get_project_dir") + def test_status_no_state_files(self, mock_dir, project_with_config): + """Config exists but no state files — stages show zero counts.""" + from azext_prototype.custom import prototype_status + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + result = prototype_status(cmd, json_output=True) + + d = result["stages"]["design"] + assert d["exchanges"] == 0 + assert d["confirmed"] == 0 + assert d["open"] == 0 + + b = result["stages"]["build"] + assert b["total_stages"] == 0 + assert b["files_generated"] == 0 + + dp = result["stages"]["deploy"] + assert dp["total_stages"] == 0 + assert dp["deployed"] == 0 + + @patch(f"{_MOD}._get_project_dir") + def test_status_deployment_history(self, mock_dir, project_with_config): + """Deployment history from ChangeTracker is included.""" + import json as json_mod + + from azext_prototype.custom import prototype_status + + # Create a manifest with deployment history + manifest_dir = project_with_config / ".prototype" / "state" + manifest_dir.mkdir(parents=True, exist_ok=True) + manifest_path = manifest_dir / "change_manifest.json" + manifest_path.write_text( + json_mod.dumps( + { + "files": {}, + "deployments": [ + {"scope": "all", "timestamp": "2026-01-15T10:00:00", "files_count": 12}, + ], + } + ), + encoding="utf-8", + ) + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + result = prototype_status(cmd, json_output=True) + + assert len(result["deployment_history"]) == 1 + assert result["deployment_history"][0]["scope"] == "all" + + @patch(f"{_MOD}._get_project_dir") + def test_status_detailed_json_returns_dict(self, mock_dir, project_with_config): + """When both detailed and json_output are True, json wins — returns dict.""" + from azext_prototype.custom import prototype_status + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + result = prototype_status(cmd, detailed=True, json_output=True) + + # json_output takes precedence — returns the enriched dict, not displayed + assert isinstance(result, dict) + assert "project" in result + assert result.get("status") != "displayed" + + +class TestLoadDesignContext: + """Test _load_design_context.""" + + def test_loads_from_design_json(self, project_with_design): + from azext_prototype.custom import _load_design_context + + result = _load_design_context(str(project_with_design)) + assert "Sample architecture" in result + + def test_loads_from_architecture_md(self, project_with_config): + from azext_prototype.custom import _load_design_context + + arch_md = project_with_config / "concept" / "docs" / "ARCHITECTURE.md" + arch_md.parent.mkdir(parents=True, exist_ok=True) + arch_md.write_text("# My Architecture\nDetails here.", encoding="utf-8") + + result = _load_design_context(str(project_with_config)) + assert "My Architecture" in result + + def test_returns_empty_when_no_design(self, tmp_project): + from azext_prototype.custom import _load_design_context + + result = _load_design_context(str(tmp_project)) + assert result == "" + + +class TestRenderTemplate: + """Test _render_template.""" + + def test_replaces_placeholders(self): + from azext_prototype.custom import _render_template + + template = "Project: [PROJECT_NAME], Region: [LOCATION], Date: [DATE]" + config = {"project": {"name": "my-proj", "location": "westus2"}} + result = _render_template(template, config) + assert "my-proj" in result + assert "westus2" in result + assert "[PROJECT_NAME]" not in result + + def test_keeps_unknown_placeholders(self): + from azext_prototype.custom import _render_template + + template = "[UNKNOWN_PLACEHOLDER] stays" + result = _render_template(template, {}) + assert "[UNKNOWN_PLACEHOLDER]" in result + + +class TestGenerateTemplates: + """Test _generate_templates shared helper.""" + + def test_generates_all_templates(self, project_with_config): + from azext_prototype.custom import _generate_templates, _load_config + + config = _load_config(str(project_with_config)) + output_dir = project_with_config / "test_output" + + generated = _generate_templates(output_dir, str(project_with_config), config.to_dict(), "test") + assert len(generated) >= 1 + assert output_dir.is_dir() + + def test_generates_with_manifest(self, project_with_config): + from azext_prototype.custom import _generate_templates, _load_config + + config = _load_config(str(project_with_config)) + output_dir = project_with_config / "speckit_output" + + _generate_templates( + output_dir, + str(project_with_config), + config.to_dict(), + "speckit", + include_manifest=True, + ) + assert (output_dir / "manifest.json").exists() + manifest = json.loads((output_dir / "manifest.json").read_text()) + assert "speckit_version" in manifest + + +# ====================================================================== +# _load_design_context — 3-source cascade +# ====================================================================== + + +class TestLoadDesignContextCascade: + """Test the 3-source cascade in _load_design_context.""" + + def test_loads_from_design_json(self, project_with_design): + """Source 1: design.json is used when present.""" + from azext_prototype.custom import _load_design_context + + result = _load_design_context(str(project_with_design)) + assert "Sample architecture" in result + + def test_falls_back_to_discovery_yaml(self, project_with_discovery): + """Source 2: discovery.yaml used when no design.json.""" + from azext_prototype.custom import _load_design_context + + result = _load_design_context(str(project_with_discovery)) + assert result # Should get non-empty context from discovery state + + def test_design_json_takes_priority(self, project_with_design): + """design.json takes priority over discovery.yaml when both exist.""" + import yaml as _yaml + + from azext_prototype.custom import _load_design_context + + # Add a discovery.yaml alongside the existing design.json + state_dir = project_with_design / ".prototype" / "state" + discovery = { + "project": {"summary": "Different content from discovery"}, + "confirmed_items": ["Different item"], + "_metadata": {"exchange_count": 1, "created": "2026-01-01T00:00:00", "last_updated": "2026-01-01T00:00:00"}, + } + (state_dir / "discovery.yaml").write_text(_yaml.dump(discovery), encoding="utf-8") + + result = _load_design_context(str(project_with_design)) + assert "Sample architecture" in result # design.json content, not discovery + + def test_falls_back_to_architecture_md(self, project_with_config): + """Source 3: ARCHITECTURE.md used when no state files exist.""" + from azext_prototype.custom import _load_design_context + + arch_md = project_with_config / "concept" / "docs" / "ARCHITECTURE.md" + arch_md.parent.mkdir(parents=True, exist_ok=True) + arch_md.write_text("# Architecture from markdown", encoding="utf-8") + + result = _load_design_context(str(project_with_config)) + assert "Architecture from markdown" in result + + def test_returns_empty_when_nothing(self, project_with_config): + """Returns empty string when no sources exist.""" + from azext_prototype.custom import _load_design_context + + result = _load_design_context(str(project_with_config)) + assert result == "" + + +# ====================================================================== +# Analyze costs — cache behavior +# ====================================================================== + + +class TestAnalyzeCostsCache: + """Test cost analysis caching (deterministic results).""" + + def _make_mock_prep(self, project_dir, mock_registry, mock_context): + """Build a _prepare_command return tuple.""" + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(str(project_dir)) + config.load() + return (str(project_dir), config, mock_registry, mock_context) + + def _make_registry_with_cost_agent(self): + from tests.conftest import make_ai_response + + agent = MagicMock() + agent.name = "cost-analyst" + agent.execute.return_value = make_ai_response("## Cost Report\n| Service | Small | Medium | Large |") + + registry = MagicMock() + registry.find_by_capability.return_value = [agent] + return registry, agent + + @patch(f"{_MOD}._prepare_command") + def test_first_run_calls_agent_and_caches(self, mock_prep, project_with_design): + from azext_prototype.custom import prototype_analyze_costs + + registry, agent = self._make_registry_with_cost_agent() + mock_ctx = MagicMock() + mock_ctx.project_config = {"project": {"location": "eastus"}} + mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) + + cmd = MagicMock() + result = prototype_analyze_costs(cmd, refresh=False, json_output=True) + + assert result["status"] == "analyzed" + agent.execute.assert_called_once() + + # Cache file should exist + cache = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" + assert cache.exists() + + @patch(f"{_MOD}._prepare_command") + def test_second_run_returns_cached(self, mock_prep, project_with_design): + """Cached result returned without calling agent.""" + import yaml as _yaml + + from azext_prototype.custom import prototype_analyze_costs + + registry, agent = self._make_registry_with_cost_agent() + mock_ctx = MagicMock() + mock_ctx.project_config = {"project": {"location": "eastus"}} + mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) + + # Pre-populate cache with matching hash + import hashlib + + from azext_prototype.custom import _load_design_context + + design_context = _load_design_context(str(project_with_design)) + context_hash = hashlib.sha256(design_context.encode("utf-8")).hexdigest()[:16] + + cache_data = { + "context_hash": context_hash, + "content": "Cached cost report content", + "result": {"status": "analyzed", "agent": "cost-analyst"}, + "timestamp": "2026-01-01T00:00:00+00:00", + } + cache_path = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" + cache_path.write_text(_yaml.dump(cache_data, default_flow_style=False), encoding="utf-8") + + cmd = MagicMock() + result = prototype_analyze_costs(cmd, refresh=False, json_output=True) + + assert result["status"] == "analyzed" + agent.execute.assert_not_called() # Should NOT have called the agent + + @patch(f"{_MOD}._prepare_command") + def test_refresh_bypasses_cache(self, mock_prep, project_with_design): + """--refresh forces fresh analysis even when cache matches.""" + import yaml as _yaml + + from azext_prototype.custom import prototype_analyze_costs + + registry, agent = self._make_registry_with_cost_agent() + mock_ctx = MagicMock() + mock_ctx.project_config = {"project": {"location": "eastus"}} + mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) + + # Pre-populate cache with matching hash + import hashlib + + from azext_prototype.custom import _load_design_context + + design_context = _load_design_context(str(project_with_design)) + context_hash = hashlib.sha256(design_context.encode("utf-8")).hexdigest()[:16] + + cache_data = { + "context_hash": context_hash, + "content": "Old cached content", + "result": {"status": "analyzed", "agent": "cost-analyst"}, + } + cache_path = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" + cache_path.write_text(_yaml.dump(cache_data, default_flow_style=False), encoding="utf-8") + + cmd = MagicMock() + result = prototype_analyze_costs(cmd, refresh=True, json_output=True) + + assert result["status"] == "analyzed" + agent.execute.assert_called_once() # Should HAVE called the agent + + @patch(f"{_MOD}._prepare_command") + def test_cache_invalidated_on_design_change(self, mock_prep, project_with_design): + """Different design context hash invalidates the cache.""" + import yaml as _yaml + + from azext_prototype.custom import prototype_analyze_costs + + registry, agent = self._make_registry_with_cost_agent() + mock_ctx = MagicMock() + mock_ctx.project_config = {"project": {"location": "eastus"}} + mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) + + # Pre-populate cache with a DIFFERENT hash + cache_data = { + "context_hash": "stale_hash_0000", + "content": "Stale cached content", + "result": {"status": "analyzed", "agent": "cost-analyst"}, + } + cache_path = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" + cache_path.write_text(_yaml.dump(cache_data, default_flow_style=False), encoding="utf-8") + + cmd = MagicMock() + result = prototype_analyze_costs(cmd, refresh=False, json_output=True) + + assert result["status"] == "analyzed" + agent.execute.assert_called_once() # Stale cache — must re-run + + @patch(f"{_MOD}._prepare_command") + def test_cache_file_written_to_state_dir(self, mock_prep, project_with_design): + """Cache is written to .prototype/state/cost_analysis.yaml.""" + import yaml as _yaml + + from azext_prototype.custom import prototype_analyze_costs + + registry, agent = self._make_registry_with_cost_agent() + mock_ctx = MagicMock() + mock_ctx.project_config = {"project": {"location": "eastus"}} + mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) + + cmd = MagicMock() + prototype_analyze_costs(cmd, refresh=False) + + cache_path = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" + assert cache_path.exists() + cached = _yaml.safe_load(cache_path.read_text(encoding="utf-8")) + assert "context_hash" in cached + assert "content" in cached + assert "timestamp" in cached + + +# ====================================================================== +# Console output — analyze commands +# ====================================================================== + + +class TestAnalyzeConsoleOutput: + """Verify analyze commands use console.* methods (not raw print).""" + + @patch(f"{_MOD}._prepare_command") + @patch(f"{_MOD}.console", create=True) + def test_analyze_error_uses_console(self, mock_console, mock_prep, project_with_design): + from azext_prototype.custom import prototype_analyze_error + from tests.conftest import make_ai_response + + agent = MagicMock() + agent.name = "qa-engineer" + agent.execute.return_value = make_ai_response("## Fix\nDo something") + + registry = MagicMock() + registry.find_by_capability.return_value = [agent] + + config = MagicMock() + mock_prep.return_value = (str(project_with_design), config, registry, MagicMock()) + + cmd = MagicMock() + result = prototype_analyze_error(cmd, input="some error text", json_output=True) + + assert result["status"] == "analyzed" + + @patch(f"{_MOD}._prepare_command") + def test_analyze_error_warns_no_context(self, mock_prep, project_with_config): + """When no design context exists, a warning should be shown.""" + from azext_prototype.custom import prototype_analyze_error + from tests.conftest import make_ai_response + + agent = MagicMock() + agent.name = "qa-engineer" + agent.execute.return_value = make_ai_response("## Fix\nDo something") + + registry = MagicMock() + registry.find_by_capability.return_value = [agent] + + config = MagicMock() + mock_prep.return_value = (str(project_with_config), config, registry, MagicMock()) + + cmd = MagicMock() + + # Patch the module-level console singleton. We must use importlib + # because `import azext_prototype.ui.console` can resolve to the + # `console` variable re-exported in azext_prototype.ui.__init__ + # instead of the submodule (name collision on Python 3.10). + import importlib + + _console_mod = importlib.import_module("azext_prototype.ui.console") + + with patch.object(_console_mod, "console") as mock_console: # noqa: F841 + result = prototype_analyze_error(cmd, input="some error", json_output=True) + + assert result["status"] == "analyzed" + + @patch(f"{_MOD}._prepare_command") + def test_analyze_costs_uses_console(self, mock_prep, project_with_design): + from azext_prototype.custom import prototype_analyze_costs + from tests.conftest import make_ai_response + + agent = MagicMock() + agent.name = "cost-analyst" + agent.execute.return_value = make_ai_response("## Costs\n$100/mo") + + registry = MagicMock() + registry.find_by_capability.return_value = [agent] + + from azext_prototype.config import ProjectConfig + + config = ProjectConfig(str(project_with_design)) + config.load() + + mock_ctx = MagicMock() + mock_ctx.project_config = {"project": {"location": "eastus"}} + mock_prep.return_value = (str(project_with_design), config, registry, mock_ctx) + + cmd = MagicMock() + result = prototype_analyze_costs(cmd, refresh=True, json_output=True) + + assert result["status"] == "analyzed" + + +# ====================================================================== +# Console output — deploy subcommands +# ====================================================================== + + +class TestDeploySubcommandConsole: + """Verify deploy flag sub-actions use console.* methods.""" + + @patch(f"{_MOD}._get_project_dir") + def test_deploy_outputs_empty_warns(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + with patch("azext_prototype.stages.deploy_helpers.DeploymentOutputCapture") as MockCapture: + MockCapture.return_value.get_all.return_value = {} + result = prototype_deploy(cmd, outputs=True, json_output=True) + + assert result["status"] == "empty" + + @patch(f"{_MOD}._get_project_dir") + def test_deploy_rollback_info_empty_warns(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + with patch("azext_prototype.stages.deploy_helpers.RollbackManager") as MockMgr: + MockMgr.return_value.get_last_snapshot.return_value = None + MockMgr.return_value.get_rollback_instructions.return_value = None + result = prototype_deploy(cmd, rollback_info=True, json_output=True) + + assert result["last_deployment"] is None + assert result["rollback_instructions"] is None + + @patch(f"{_MOD}._get_project_dir") + @patch(f"{_MOD}._load_config") + def test_generate_scripts_uses_console(self, mock_config, mock_dir, project_with_config): + from azext_prototype.custom import prototype_deploy + + mock_dir.return_value = str(project_with_config) + mock_config.return_value = MagicMock() + mock_config.return_value.get.return_value = "" + + # Create an apps directory with a subdirectory + apps_dir = project_with_config / "concept" / "apps" + apps_dir.mkdir(parents=True, exist_ok=True) + (apps_dir / "my-app").mkdir() + + cmd = MagicMock() + + with patch("azext_prototype.stages.deploy_helpers.DeployScriptGenerator") as MockGen: # noqa: F841 + result = prototype_deploy(cmd, generate_scripts=True, json_output=True) + + assert result["status"] == "generated" + assert "my-app/deploy.sh" in result["scripts"] + + +# ====================================================================== +# Agent commands — Rich UI, new commands, validation +# ====================================================================== + + +class TestPrototypeAgentListRichUI: + """Test agent list Rich UI, json, and detailed modes.""" + + @patch(f"{_MOD}._get_project_dir") + def test_list_json_returns_list(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_list + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + result = prototype_agent_list(cmd, json_output=True) + assert isinstance(result, list) + assert len(result) >= 8 + + @patch(f"{_MOD}._get_project_dir") + def test_list_console_mode(self, mock_dir, project_with_config): + """Default (non-json) returns list and uses console.""" + from azext_prototype.custom import prototype_agent_list + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + result = prototype_agent_list(cmd, json_output=True) + assert isinstance(result, list) + + @patch(f"{_MOD}._get_project_dir") + def test_list_detailed_mode(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_list + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + result = prototype_agent_list(cmd, detailed=True, json_output=True) + assert isinstance(result, list) + + @patch(f"{_MOD}._get_project_dir") + def test_list_agents_have_source(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_list + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + result = prototype_agent_list(cmd, json_output=True) + for agent in result: + assert "source" in agent + + +class TestPrototypeAgentShowRichUI: + """Test agent show Rich UI, json, and detailed modes.""" + + @patch(f"{_MOD}._get_project_dir") + def test_show_json_returns_dict(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_show + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + result = prototype_agent_show(cmd, name="cloud-architect", json_output=True) + assert isinstance(result, dict) + assert result["name"] == "cloud-architect" + assert "system_prompt_preview" in result + + @patch(f"{_MOD}._get_project_dir") + def test_show_detailed_includes_full_prompt(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_show + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + result = prototype_agent_show(cmd, name="cloud-architect", detailed=True, json_output=True) + assert "system_prompt" in result + # detailed should not have preview + assert "system_prompt_preview" not in result + + @patch(f"{_MOD}._get_project_dir") + def test_show_console_mode(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_show + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + result = prototype_agent_show(cmd, name="cloud-architect", json_output=True) + assert isinstance(result, dict) + + +class TestPrototypeAgentUpdate: + """Test agent update command.""" + + @patch(f"{_MOD}._get_project_dir") + def test_update_description(self, mock_dir, project_with_config): + """Targeted field update — description only.""" + from azext_prototype.custom import prototype_agent_add, prototype_agent_update + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + prototype_agent_add(cmd, name="updatable", definition="cloud_architect") + result = prototype_agent_update(cmd, name="updatable", description="New desc", json_output=True) + assert result["status"] == "updated" + assert result["description"] == "New desc" + + @patch(f"{_MOD}._get_project_dir") + def test_update_capabilities(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_add, prototype_agent_update + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + prototype_agent_add(cmd, name="cap-update", definition="cloud_architect") + result = prototype_agent_update(cmd, name="cap-update", capabilities="architect,deploy", json_output=True) + assert result["status"] == "updated" + assert "architect" in result["capabilities"] + assert "deploy" in result["capabilities"] + + @patch(f"{_MOD}._get_project_dir") + def test_update_system_prompt_from_file(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_add, prototype_agent_update + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + prototype_agent_add(cmd, name="prompt-update", definition="cloud_architect") + + prompt_file = project_with_config / "new_prompt.txt" + prompt_file.write_text("You are an updated agent.", encoding="utf-8") + + result = prototype_agent_update( + cmd, name="prompt-update", system_prompt_file=str(prompt_file), json_output=True + ) + assert result["status"] == "updated" + + import yaml as _yaml + + agent_file = project_with_config / ".prototype" / "agents" / "prompt-update.yaml" + content = _yaml.safe_load(agent_file.read_text(encoding="utf-8")) + assert content["system_prompt"] == "You are an updated agent." + + @patch(f"{_MOD}._get_project_dir") + def test_update_interactive_mode(self, mock_dir, project_with_config): + """Interactive mode with mocked input.""" + from azext_prototype.custom import prototype_agent_add, prototype_agent_update + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + prototype_agent_add(cmd, name="interactive-up", definition="cloud_architect") + + # Mock interactive prompts: description, role, capabilities, constraints (empty), system prompt (empty=keep) + inputs = [ + "Updated description", # description + "architect", # role + "architect", # capabilities + "", # end constraints + "", # system prompt (keep existing - first empty line) + "", # examples (skip) + ] + with patch("builtins.input", side_effect=inputs): + result = prototype_agent_update(cmd, name="interactive-up", json_output=True) + + assert result["status"] == "updated" + assert result["description"] == "Updated description" + + @patch(f"{_MOD}._get_project_dir") + def test_update_manifest_sync(self, mock_dir, project_with_config): + """Manifest entry is updated after field update.""" + from azext_prototype.custom import ( + _load_config, + prototype_agent_add, + prototype_agent_update, + ) + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + prototype_agent_add(cmd, name="manifest-sync", definition="cloud_architect") + prototype_agent_update(cmd, name="manifest-sync", description="Synced desc") + + config = _load_config(str(project_with_config)) + custom = config.get("agents.custom", {}) + assert custom["manifest-sync"]["description"] == "Synced desc" + + def test_update_missing_name_raises(self): + from azext_prototype.custom import prototype_agent_update + + cmd = MagicMock() + with pytest.raises(CLIError, match="--name"): + prototype_agent_update(cmd, name=None) + + @patch(f"{_MOD}._get_project_dir") + def test_update_nonexistent_raises(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_update + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + with pytest.raises(CLIError, match="not found"): + prototype_agent_update(cmd, name="nonexistent-agent") + + @patch(f"{_MOD}._get_project_dir") + def test_update_invalid_capability_raises(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_add, prototype_agent_update + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + prototype_agent_add(cmd, name="bad-cap", definition="cloud_architect") + with pytest.raises(CLIError, match="Unknown capability"): + prototype_agent_update(cmd, name="bad-cap", capabilities="invalid_cap") + + @patch(f"{_MOD}._get_project_dir") + def test_update_prompt_file_not_found_raises(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_add, prototype_agent_update + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + prototype_agent_add(cmd, name="no-prompt", definition="cloud_architect") + with pytest.raises(CLIError, match="not found"): + prototype_agent_update(cmd, name="no-prompt", system_prompt_file="./does_not_exist.txt") + + +class TestPrototypeAgentTest: + """Test agent test command.""" + + @patch(f"{_MOD}._prepare_command") + def test_default_prompt(self, mock_prep, project_with_config, mock_ai_provider): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.custom import prototype_agent_test + + mock_agent = MagicMock() + mock_agent.name = "cloud-architect" + mock_agent.execute.return_value = AIResponse( + content="I am the cloud architect.", + model="gpt-4o", + usage={"prompt_tokens": 50, "completion_tokens": 20, "total_tokens": 70}, + ) + + mock_registry = MagicMock() + mock_registry.get.return_value = mock_agent + mock_prep.return_value = (str(project_with_config), MagicMock(), mock_registry, MagicMock()) + + cmd = MagicMock() + result = prototype_agent_test(cmd, name="cloud-architect", json_output=True) + + assert result["status"] == "tested" + assert result["name"] == "cloud-architect" + assert result["model"] == "gpt-4o" + assert result["tokens"] == 70 + mock_agent.execute.assert_called_once() + + @patch(f"{_MOD}._prepare_command") + def test_custom_prompt(self, mock_prep, project_with_config, mock_ai_provider): + from azext_prototype.ai.provider import AIResponse + from azext_prototype.custom import prototype_agent_test + + mock_agent = MagicMock() + mock_agent.name = "cloud-architect" + mock_agent.execute.return_value = AIResponse( + content="Here is a web app design.", + model="gpt-4o", + usage={"total_tokens": 100}, + ) + + mock_registry = MagicMock() + mock_registry.get.return_value = mock_agent + mock_prep.return_value = (str(project_with_config), MagicMock(), mock_registry, MagicMock()) + + cmd = MagicMock() + result = prototype_agent_test(cmd, name="cloud-architect", prompt="Design a web app", json_output=True) + + assert result["status"] == "tested" + # Verify custom prompt was passed + call_args = mock_agent.execute.call_args + assert "Design a web app" in call_args[0][1] + + def test_test_missing_name_raises(self): + from azext_prototype.custom import prototype_agent_test + + cmd = MagicMock() + with pytest.raises(CLIError, match="--name"): + prototype_agent_test(cmd, name=None) + + +class TestPrototypeAgentExport: + """Test agent export command.""" + + @patch(f"{_MOD}._get_project_dir") + def test_export_builtin(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_export + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + output_path = str(project_with_config / "exported.yaml") + result = prototype_agent_export(cmd, name="cloud-architect", output_file=output_path, json_output=True) + + assert result["status"] == "exported" + assert result["name"] == "cloud-architect" + + import yaml as _yaml + + exported = _yaml.safe_load((project_with_config / "exported.yaml").read_text(encoding="utf-8")) + assert exported["name"] == "cloud-architect" + assert "capabilities" in exported + assert "system_prompt" in exported + + @patch(f"{_MOD}._get_project_dir") + def test_export_custom(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_add, prototype_agent_export + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + prototype_agent_add(cmd, name="export-test", definition="bicep_agent") + output_path = str(project_with_config / "custom_export.yaml") + result = prototype_agent_export(cmd, name="export-test", output_file=output_path, json_output=True) + + assert result["status"] == "exported" + assert (project_with_config / "custom_export.yaml").exists() + + @patch(f"{_MOD}._get_project_dir") + def test_export_default_path(self, mock_dir, project_with_config): + """Default output path is ./{name}.yaml.""" + import os + + from azext_prototype.custom import prototype_agent_export + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + # Change cwd to project dir for default path + original_cwd = os.getcwd() + try: + os.chdir(str(project_with_config)) + result = prototype_agent_export(cmd, name="cloud-architect", json_output=True) + assert result["status"] == "exported" + assert (project_with_config / "cloud-architect.yaml").exists() + finally: + os.chdir(original_cwd) + + @patch(f"{_MOD}._get_project_dir") + def test_export_loadable_by_loader(self, mock_dir, project_with_config): + """Exported YAML is loadable by load_yaml_agent.""" + from azext_prototype.agents.loader import load_yaml_agent + from azext_prototype.custom import prototype_agent_export + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + output_path = str(project_with_config / "loadable.yaml") + prototype_agent_export(cmd, name="cloud-architect", output_file=output_path) + + agent = load_yaml_agent(output_path) + assert agent.name == "cloud-architect" + + def test_export_missing_name_raises(self): + from azext_prototype.custom import prototype_agent_export + + cmd = MagicMock() + with pytest.raises(CLIError, match="--name"): + prototype_agent_export(cmd, name=None) + + +class TestPrototypeAgentOverrideValidation: + """Test override validation enhancements.""" + + @patch(f"{_MOD}._get_project_dir") + def test_override_file_not_found_raises(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_override + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + with pytest.raises(CLIError, match="not found"): + prototype_agent_override(cmd, name="cloud-architect", file="./does_not_exist.yaml") + + @patch(f"{_MOD}._get_project_dir") + def test_override_invalid_yaml_raises(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_override + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + bad_yaml = project_with_config / "bad.yaml" + bad_yaml.write_text("{{invalid yaml::", encoding="utf-8") + + with pytest.raises(CLIError, match="Invalid YAML"): + prototype_agent_override(cmd, name="cloud-architect", file="bad.yaml") + + @patch(f"{_MOD}._get_project_dir") + def test_override_missing_name_field_raises(self, mock_dir, project_with_config): + from azext_prototype.custom import prototype_agent_override + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + no_name = project_with_config / "no_name.yaml" + no_name.write_text("description: test\n", encoding="utf-8") + + with pytest.raises(CLIError, match="name"): + prototype_agent_override(cmd, name="cloud-architect", file="no_name.yaml") + + @patch(f"{_MOD}._get_project_dir") + def test_override_non_builtin_warns(self, mock_dir, project_with_config): + """Overriding a non-builtin name should warn but succeed.""" + from azext_prototype.custom import prototype_agent_override + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + valid_yaml = project_with_config / "valid.yaml" + valid_yaml.write_text( + "name: nonexistent-agent\ndescription: test\ncapabilities:\n - develop\n" "system_prompt: test\n", + encoding="utf-8", + ) + + result = prototype_agent_override(cmd, name="nonexistent-agent", file="valid.yaml", json_output=True) + assert result["status"] == "override_registered" + + @patch(f"{_MOD}._get_project_dir") + def test_override_valid_builtin(self, mock_dir, project_with_config): + """Overriding a known builtin should succeed without warnings.""" + from azext_prototype.custom import prototype_agent_override + + mock_dir.return_value = str(project_with_config) + cmd = MagicMock() + + valid_yaml = project_with_config / "arch_override.yaml" + valid_yaml.write_text( + "name: cloud-architect\ndescription: Custom arch\ncapabilities:\n - architect\n" + "system_prompt: Custom prompt.\n", + encoding="utf-8", + ) + + result = prototype_agent_override(cmd, name="cloud-architect", file="arch_override.yaml", json_output=True) + assert result["status"] == "override_registered" + + +class TestPromptAgentDefinition: + """Test the _prompt_agent_definition interactive helper.""" + + def test_full_walkthrough(self): + from azext_prototype.custom import _prompt_agent_definition + from azext_prototype.ui.console import Console + + console = Console() + inputs = [ + "My agent description", # description + "architect", # role + "architect,deploy", # capabilities + "Must use PaaS only", # constraint 1 + "", # end constraints + "You are a custom agent.", # system prompt line 1 + "END", # end system prompt + "", # no examples + ] + with patch("builtins.input", side_effect=inputs): + result = _prompt_agent_definition(console, "test-agent") + + assert result["name"] == "test-agent" + assert result["description"] == "My agent description" + assert result["role"] == "architect" + assert "architect" in result["capabilities"] + assert "deploy" in result["capabilities"] + assert "Must use PaaS only" in result["constraints"] + assert "You are a custom agent." in result["system_prompt"] + + def test_existing_defaults(self): + from azext_prototype.custom import _prompt_agent_definition + from azext_prototype.ui.console import Console + + console = Console() + existing = { + "description": "Old desc", + "role": "developer", + "capabilities": ["develop"], + "constraints": ["Old constraint"], + "system_prompt": "Old prompt.", + "examples": [{"user": "hello", "assistant": "hi"}], + } + # All empty inputs → keep existing values + inputs = [ + "", # description (keep) + "", # role (keep) + "", # capabilities (keep) + "", # constraints (keep existing) + "", # system prompt (keep existing) + "", # examples (keep existing) + ] + with patch("builtins.input", side_effect=inputs): + result = _prompt_agent_definition(console, "test-agent", existing=existing) + + assert result["description"] == "Old desc" + assert result["role"] == "developer" + assert result["capabilities"] == ["develop"] + assert result["constraints"] == ["Old constraint"] + assert result["system_prompt"] == "Old prompt." + assert result["examples"] == [{"user": "hello", "assistant": "hi"}] + + def test_invalid_capability_skipped(self): + from azext_prototype.custom import _prompt_agent_definition + from azext_prototype.ui.console import Console + + console = Console() + inputs = [ + "desc", # description + "role", # role + "invalid_cap,architect", # capabilities — one invalid + "", # end constraints + "prompt", # system prompt + "END", # end system prompt + "", # no examples + ] + with patch("builtins.input", side_effect=inputs): + result = _prompt_agent_definition(console, "test-agent") + + assert "architect" in result["capabilities"] + assert "invalid_cap" not in result["capabilities"] + + +class TestReadMultilineInput: + """Test _read_multiline_input helper.""" + + def test_reads_until_end(self): + from azext_prototype.custom import _read_multiline_input + + with patch("builtins.input", side_effect=["line 1", "line 2", "END"]): + result = _read_multiline_input() + assert result == "line 1\nline 2" + + def test_empty_first_line_returns_empty(self): + from azext_prototype.custom import _read_multiline_input + + with patch("builtins.input", side_effect=[""]): + result = _read_multiline_input() + assert result == "" diff --git a/tests/test_custom_extended.py b/tests/test_custom_extended.py deleted file mode 100644 index 85f3548..0000000 --- a/tests/test_custom_extended.py +++ /dev/null @@ -1,2204 +0,0 @@ -"""Tests for custom.py — additional coverage for stage commands and helpers.""" - -import json -from unittest.mock import MagicMock, patch - -import pytest -from knack.util import CLIError - -_MOD = "azext_prototype.custom" - - -# ====================================================================== -# Helper functions -# ====================================================================== - - -class TestBuildRegistry: - """Test _build_registry helper.""" - - def test_build_registry_builtin_only(self): - from azext_prototype.custom import _build_registry - - registry = _build_registry(config=None, project_dir=None) - agents = registry.list_all() - assert len(agents) >= 8 - - def test_build_registry_with_custom_agents(self, project_with_config): - from azext_prototype.custom import _build_registry, _load_config - - # Create a custom YAML agent - agent_dir = project_with_config / ".prototype" / "agents" - agent_dir.mkdir(parents=True, exist_ok=True) - (agent_dir / "test-agent.yaml").write_text( - "name: test-agent\ndescription: A test\ncapabilities:\n - develop\n" "system_prompt: You are a test.\n", - encoding="utf-8", - ) - - config = _load_config(str(project_with_config)) - registry = _build_registry(config, str(project_with_config)) - names = [a.name for a in registry.list_all()] - assert "test-agent" in names - - def test_build_registry_with_overrides(self, project_with_config): - from azext_prototype.custom import _build_registry, _load_config - - # Write a YAML agent to use as override - override_file = project_with_config / "override.yaml" - override_file.write_text( - "name: cloud-architect\ndescription: Override\ncapabilities:\n - architect\n" - "system_prompt: Override prompt.\n", - encoding="utf-8", - ) - - config = _load_config(str(project_with_config)) - config.set("agents.overrides", {"cloud-architect": "override.yaml"}) - - registry = _build_registry(config, str(project_with_config)) - agent = registry.get("cloud-architect") - assert "Override" in agent.description - - -class TestBuildContext: - """Test _build_context helper.""" - - @patch("azext_prototype.ai.factory.create_ai_provider") - def test_build_context_creates_agent_context(self, mock_factory, project_with_config): - from azext_prototype.custom import _build_context, _load_config - - mock_provider = MagicMock() - mock_factory.return_value = mock_provider - config = _load_config(str(project_with_config)) - - ctx = _build_context(config, str(project_with_config)) - assert ctx.project_dir == str(project_with_config) - assert ctx.ai_provider is mock_provider - - -class TestPrepareCommand: - """Test _prepare_command helper.""" - - @patch(f"{_MOD}._check_requirements") - @patch("azext_prototype.ai.factory.create_ai_provider") - def test_prepare_command(self, mock_factory, mock_check_req, project_with_config): - from azext_prototype.custom import _prepare_command - - mock_factory.return_value = MagicMock() - pd, config, registry, ctx = _prepare_command(str(project_with_config)) - assert pd == str(project_with_config) - assert config is not None - assert registry is not None - assert ctx is not None - - -class TestCheckRequirements: - """Test _check_requirements wiring in command entry points.""" - - def test_check_requirements_passes_when_all_ok(self): - from azext_prototype.custom import _check_requirements - from azext_prototype.requirements import CheckResult - - with patch("azext_prototype.requirements.check_all") as mock_check: - mock_check.return_value = [ - CheckResult(name="Python", status="pass", installed_version="3.12.0", required=">=3.9.0", message="ok"), - ] - # Should not raise - _check_requirements("terraform") - - def test_check_requirements_raises_on_missing(self): - from azext_prototype.custom import _check_requirements - from azext_prototype.requirements import CheckResult - - with patch("azext_prototype.requirements.check_all") as mock_check: - mock_check.return_value = [ - CheckResult( - name="Terraform", - status="missing", - installed_version=None, - required=">=1.14.0", - message="Terraform is not installed", - install_hint="https://developer.hashicorp.com/terraform/install", - ), - ] - with pytest.raises(CLIError, match="Tool requirements not met"): - _check_requirements("terraform") - - def test_check_requirements_raises_on_version_fail(self): - from azext_prototype.custom import _check_requirements - from azext_prototype.requirements import CheckResult - - with patch("azext_prototype.requirements.check_all") as mock_check: - mock_check.return_value = [ - CheckResult( - name="Azure CLI", - status="fail", - installed_version="2.40.0", - required=">=2.50.0", - message="Azure CLI 2.40.0 does not satisfy >=2.50.0", - install_hint="https://learn.microsoft.com/cli/azure/install-azure-cli", - ), - ] - with pytest.raises(CLIError, match="Azure CLI"): - _check_requirements(None) - - def test_check_requirements_includes_install_hint(self): - from azext_prototype.custom import _check_requirements - from azext_prototype.requirements import CheckResult - - with patch("azext_prototype.requirements.check_all") as mock_check: - mock_check.return_value = [ - CheckResult( - name="Terraform", - status="missing", - installed_version=None, - required=">=1.14.0", - message="Terraform is not installed", - install_hint="https://developer.hashicorp.com/terraform/install", - ), - ] - with pytest.raises(CLIError, match="Install:.*hashicorp"): - _check_requirements("terraform") - - @patch("azext_prototype.ai.factory.create_ai_provider") - def test_prepare_command_calls_check_requirements(self, mock_factory, project_with_config): - from azext_prototype.custom import _prepare_command - - mock_factory.return_value = MagicMock() - with patch(f"{_MOD}._check_requirements") as mock_check: - _prepare_command(str(project_with_config)) - mock_check.assert_called_once() - - def test_init_calls_check_requirements(self, tmp_path): - with patch(f"{_MOD}._check_requirements") as mock_check, patch( - "azext_prototype.stages.init_stage.InitStage" - ) as MockStage: - from azext_prototype.custom import prototype_init - - mock_stage = MockStage.return_value - mock_stage.can_run.return_value = (True, []) - mock_stage.execute.return_value = {"status": "success"} - - cmd = MagicMock() - prototype_init(cmd, name="test", location="eastus", output_dir=str(tmp_path)) - mock_check.assert_called_once_with("terraform") # default iac_tool - - -class TestCheckGuards: - """Test _check_guards helper.""" - - def test_check_guards_pass(self): - from azext_prototype.custom import _check_guards - - stage = MagicMock() - stage.can_run.return_value = (True, []) - _check_guards(stage) # Should not raise - - def test_check_guards_fail(self): - from azext_prototype.custom import _check_guards - - stage = MagicMock() - stage.can_run.return_value = (False, ["Missing gh CLI"]) - with pytest.raises(CLIError, match="Prerequisites not met"): - _check_guards(stage) - - -class TestGetRegistryWithFallback: - """Test _get_registry_with_fallback helper.""" - - def test_with_valid_config(self, project_with_config): - from azext_prototype.custom import _get_registry_with_fallback - - registry = _get_registry_with_fallback(str(project_with_config)) - assert len(registry.list_all()) >= 8 - - def test_without_config_falls_back(self, tmp_project): - from azext_prototype.custom import _get_registry_with_fallback - - registry = _get_registry_with_fallback(str(tmp_project)) - assert len(registry.list_all()) >= 8 - - -# ====================================================================== -# Stage commands -# ====================================================================== - - -class TestPrototypeInit: - """Test the init command.""" - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._check_guards") - @patch("azext_prototype.auth.copilot_license.CopilotLicenseValidator") - @patch("azext_prototype.auth.github_auth.GitHubAuthManager") - @patch("azext_prototype.stages.init_stage.InitStage._check_gh", return_value=True) - def test_init_success(self, mock_gh, mock_auth_cls, mock_lic_cls, mock_guards, mock_check_req, tmp_path): - from azext_prototype.custom import prototype_init - - mock_auth = MagicMock() - mock_auth.ensure_authenticated.return_value = {"login": "testuser"} - mock_auth_cls.return_value = mock_auth - - mock_lic = MagicMock() - mock_lic.validate_license.return_value = {"plan": "business", "status": "active"} - mock_lic_cls.return_value = mock_lic - - cmd = MagicMock() - out = tmp_path / "test-proj" - result = prototype_init( - cmd, - name="test-proj", - location="eastus", - output_dir=str(out), - ai_provider="github-models", - json_output=True, - ) - - assert result["status"] == "success" - assert result["github_user"] == "testuser" - assert out.is_dir() - assert (out / "prototype.yaml").exists() - assert (out / ".gitignore").exists() - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._check_guards") - def test_init_azure_openai_skips_license(self, mock_guards, mock_check_req, tmp_path): - from azext_prototype.custom import prototype_init - - cmd = MagicMock() - result = prototype_init( - cmd, - name="aoai-proj", - location="eastus", - output_dir=str(tmp_path / "aoai-proj"), - ai_provider="azure-openai", - json_output=True, - ) - - assert result["status"] == "success" - assert "copilot_license" not in result - assert result["github_user"] is None - - @patch(f"{_MOD}._check_requirements") - def test_init_missing_name_raises(self, mock_check_req, tmp_path): - from azext_prototype.custom import prototype_init - from azext_prototype.stages.init_stage import InitStage - - cmd = MagicMock() - # Need to bypass guards - with patch.object(InitStage, "get_guards", return_value=[]): - with pytest.raises(CLIError, match="Project name"): - prototype_init(cmd, name=None, location="eastus", output_dir=str(tmp_path / "no-name")) - - @patch(f"{_MOD}._check_requirements") - def test_init_missing_location_raises(self, mock_check_req, tmp_path): - from azext_prototype.custom import prototype_init - from azext_prototype.stages.init_stage import InitStage - - cmd = MagicMock() - with patch.object(InitStage, "get_guards", return_value=[]): - with pytest.raises(CLIError, match="region is required"): - prototype_init(cmd, name="test-proj", location=None, output_dir=str(tmp_path / "test-proj")) - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._check_guards") - def test_init_idempotency_cancel(self, mock_guards, mock_check_req, tmp_path): - """If project exists and user declines, init should cancel.""" - from azext_prototype.custom import prototype_init - - # Create existing project - proj_dir = tmp_path / "existing-proj" - proj_dir.mkdir() - (proj_dir / "prototype.yaml").write_text("project:\n name: old\n") - - cmd = MagicMock() - with patch("builtins.input", return_value="n"): - result = prototype_init( - cmd, - name="existing-proj", - location="eastus", - output_dir=str(proj_dir), - ai_provider="azure-openai", - json_output=True, - ) - assert result["status"] == "cancelled" - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._check_guards") - def test_init_idempotency_reinitialize(self, mock_guards, mock_check_req, tmp_path): - """If project exists and user confirms, init should proceed.""" - from azext_prototype.custom import prototype_init - - proj_dir = tmp_path / "reinit-proj" - proj_dir.mkdir() - (proj_dir / "prototype.yaml").write_text("project:\n name: old\n") - - cmd = MagicMock() - with patch("builtins.input", return_value="y"): - result = prototype_init( - cmd, - name="reinit-proj", - location="eastus", - output_dir=str(proj_dir), - ai_provider="azure-openai", - json_output=True, - ) - assert result["status"] == "success" - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._check_guards") - def test_init_environment_parameter(self, mock_guards, mock_check_req, tmp_path): - """--environment should be stored in config.""" - from azext_prototype.config import ProjectConfig - from azext_prototype.custom import prototype_init - - cmd = MagicMock() - out = tmp_path / "env-proj" - result = prototype_init( - cmd, - name="env-proj", - location="westus2", - output_dir=str(out), - ai_provider="azure-openai", - environment="staging", - json_output=True, - ) - assert result["status"] == "success" - config = ProjectConfig(str(out)) - config.load() - assert config.get("project.environment") == "staging" - assert config.get("naming.env") == "stg" - assert config.get("naming.zone_id") == "zs" - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._check_guards") - def test_init_model_parameter(self, mock_guards, mock_check_req, tmp_path): - """--model should override the provider default.""" - from azext_prototype.config import ProjectConfig - from azext_prototype.custom import prototype_init - - cmd = MagicMock() - out = tmp_path / "model-proj" - result = prototype_init( - cmd, - name="model-proj", - location="eastus", - output_dir=str(out), - ai_provider="azure-openai", - model="gpt-4o-mini", - json_output=True, - ) - assert result["status"] == "success" - config = ProjectConfig(str(out)) - config.load() - assert config.get("ai.model") == "gpt-4o-mini" - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._check_guards") - def test_init_default_model_per_provider(self, mock_guards, mock_check_req, tmp_path): - """Without --model, the default should be provider-specific.""" - from azext_prototype.config import ProjectConfig - from azext_prototype.custom import prototype_init - - cmd = MagicMock() - out = tmp_path / "defmodel-proj" - result = prototype_init( - cmd, - name="defmodel-proj", - location="eastus", - output_dir=str(out), - ai_provider="azure-openai", - json_output=True, - ) - assert result["status"] == "success" - config = ProjectConfig(str(out)) - config.load() - assert config.get("ai.model") == "gpt-4o" - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._check_guards") - def test_init_sends_telemetry_overrides(self, mock_guards, mock_check_req, tmp_path): - """Init should set _telemetry_overrides with resolved values.""" - from azext_prototype.custom import prototype_init - - cmd = MagicMock() - prototype_init( - cmd, - name="telem-proj", - location="westeurope", - output_dir=str(tmp_path / "telem-proj"), - ai_provider="azure-openai", - environment="staging", - iac_tool="bicep", - ) - - assert isinstance(cmd._telemetry_overrides, dict) - overrides = cmd._telemetry_overrides - assert overrides["location"] == "westeurope" - assert overrides["ai_provider"] == "azure-openai" - assert overrides["model"] == "gpt-4o" # resolved default - assert overrides["iac_tool"] == "bicep" - assert overrides["environment"] == "staging" - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._check_guards") - def test_init_telemetry_overrides_explicit_model(self, mock_guards, mock_check_req, tmp_path): - """When --model is explicit, overrides should use that value.""" - from azext_prototype.custom import prototype_init - - cmd = MagicMock() - prototype_init( - cmd, - name="telem-model-proj", - location="eastus", - output_dir=str(tmp_path / "telem-model-proj"), - ai_provider="azure-openai", - model="gpt-4o-mini", - ) - - overrides = cmd._telemetry_overrides - assert overrides["model"] == "gpt-4o-mini" - assert overrides["ai_provider"] == "azure-openai" - - -class TestPrototypeConfigGet: - """Test the config get command.""" - - def test_config_get_basic(self, project_with_config): - from azext_prototype.custom import prototype_config_get - - cmd = MagicMock() - with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): - result = prototype_config_get(cmd, key="ai.provider", json_output=True) - assert result == {"key": "ai.provider", "value": "github-models"} - - def test_config_get_missing_key(self, project_with_config): - from azext_prototype.custom import prototype_config_get - - cmd = MagicMock() - with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): - with pytest.raises(CLIError, match="not found"): - prototype_config_get(cmd, key="nonexistent.key") - - def test_config_get_masks_secret(self, project_with_config): - from azext_prototype.config import ProjectConfig - from azext_prototype.custom import prototype_config_get - - # Set a secret value first - config = ProjectConfig(str(project_with_config)) - config.load() - config._secrets = {"deploy": {"subscription": "secret-sub-id"}} - config._config["deploy"]["subscription"] = "secret-sub-id" - config.save() - config.save_secrets() - - cmd = MagicMock() - with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): - result = prototype_config_get(cmd, key="deploy.subscription", json_output=True) - assert result == {"key": "deploy.subscription", "value": "***"} - - -class TestPrototypeConfigShowMasking: - """Test that config show masks secrets.""" - - def test_config_show_masks_secret_values(self, project_with_config): - from azext_prototype.config import ProjectConfig - from azext_prototype.custom import prototype_config_show - - # Set a secret value - config = ProjectConfig(str(project_with_config)) - config.load() - config._secrets = {"deploy": {"subscription": "my-secret-sub"}} - config._config["deploy"]["subscription"] = "my-secret-sub" - config.save() - config.save_secrets() - - cmd = MagicMock() - with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): - result = prototype_config_show(cmd, json_output=True) - assert result["deploy"]["subscription"] == "***" - - def test_config_show_preserves_non_secrets(self, project_with_config): - from azext_prototype.custom import prototype_config_show - - cmd = MagicMock() - with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): - result = prototype_config_show(cmd, json_output=True) - # Non-secret value should not be masked - assert result["ai"]["provider"] == "github-models" - - -class TestPrototypeConfigInit: - """Test config init marks init complete.""" - - @patch( - "builtins.input", - side_effect=[ - "y", # overwrite existing prototype.yaml - "my-project", # project name - "eastus", # location - "dev", # environment - "terraform", # iac tool - "1", # naming strategy choice (microsoft-alz) - "myorg", # org - "zd", # zone_id (ALZ-specific) - "copilot", # ai provider - "", # model (accept default) - "", # subscription - "", # resource group - ], - ) - def test_config_init_marks_init_complete(self, mock_input, project_with_config): - from azext_prototype.config import ProjectConfig - from azext_prototype.custom import prototype_config_init - - cmd = MagicMock() - with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): - prototype_config_init(cmd) - - config = ProjectConfig(str(project_with_config)) - config.load() - assert config.get("stages.init.completed") is True - assert config.get("stages.init.timestamp") is not None - - @patch( - "builtins.input", - side_effect=[ - "y", # overwrite existing prototype.yaml - "telemetry-proj", # project name - "westus2", # location - "staging", # environment - "bicep", # iac tool - "2", # naming strategy choice (microsoft-caf) - "myorg", # org - "azure-openai", # ai provider - "gpt-4o", # model - "https://myres.openai.azure.com/", # Azure OpenAI endpoint - "gpt-4o", # deployment name - "", # subscription - "", # resource group - ], - ) - def test_config_init_sends_telemetry_overrides(self, mock_input, project_with_config): - """After prompting, config init should set _telemetry_overrides on cmd.""" - from azext_prototype.custom import prototype_config_init - - cmd = MagicMock() - with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): - prototype_config_init(cmd) - - assert hasattr(cmd, "_telemetry_overrides") - overrides = cmd._telemetry_overrides - assert overrides["location"] == "westus2" - assert overrides["ai_provider"] == "azure-openai" - assert overrides["model"] == "gpt-4o" - assert overrides["iac_tool"] == "bicep" - assert overrides["environment"] == "staging" - assert overrides["naming_strategy"] == "microsoft-caf" - - def test_config_init_cancelled_no_overrides(self, project_with_config): - """If config init is cancelled, no telemetry overrides should be set.""" - from azext_prototype.custom import prototype_config_init - - cmd = MagicMock(spec=[]) # strict spec — no auto-attributes - with patch(f"{_MOD}._get_project_dir", return_value=str(project_with_config)): - with patch("builtins.input", return_value="n"): - result = prototype_config_init(cmd, json_output=True) - assert result["status"] == "cancelled" - assert not hasattr(cmd, "_telemetry_overrides") - - -class TestPrototypeBuild: - """Test the build command.""" - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._get_project_dir") - @patch("azext_prototype.ai.factory.create_ai_provider") - @patch(f"{_MOD}._check_guards") - def test_build_calls_stage( - self, mock_guards, mock_factory, mock_dir, mock_check_req, project_with_design, mock_ai_provider - ): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.custom import prototype_build - - mock_dir.return_value = str(project_with_design) - mock_factory.return_value = mock_ai_provider - mock_ai_provider.chat.return_value = AIResponse( - content="```main.tf\nresource null {}\n```", - model="gpt-4o", - ) - - cmd = MagicMock() - result = prototype_build(cmd, scope="docs", dry_run=True, json_output=True) - assert result["status"] == "dry-run" - - -class TestPrototypeDeploy: - """Test the deploy command.""" - - @patch(f"{_MOD}._check_requirements") - @patch(f"{_MOD}._get_project_dir") - @patch("azext_prototype.ai.factory.create_ai_provider") - def test_deploy_status(self, mock_factory, mock_dir, mock_check_req, project_with_build, mock_ai_provider): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_build) - mock_factory.return_value = mock_ai_provider - - cmd = MagicMock() - result = prototype_deploy(cmd, status=True, json_output=True) - assert result["status"] == "displayed" - - -class TestPrototypeDeployOutputs: - """Test deploy --outputs flag.""" - - @patch(f"{_MOD}._get_project_dir") - def test_no_outputs(self, mock_dir, project_with_build): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_build) - cmd = MagicMock() - result = prototype_deploy(cmd, outputs=True, json_output=True) - assert result["status"] == "empty" - - @patch(f"{_MOD}._get_project_dir") - def test_with_outputs(self, mock_dir, project_with_build): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_build) - # Write outputs file - outputs_dir = project_with_build / ".prototype" / "state" - outputs_dir.mkdir(parents=True, exist_ok=True) - (outputs_dir / "deploy_outputs.json").write_text(json.dumps({"rg_name": "test-rg"}), encoding="utf-8") - cmd = MagicMock() - result = prototype_deploy(cmd, outputs=True, json_output=True) - # May return empty or dict depending on DeploymentOutputCapture impl - assert isinstance(result, dict) - - -class TestPrototypeDeployRollbackInfo: - """Test deploy --rollback-info flag.""" - - @patch(f"{_MOD}._get_project_dir") - def test_rollback_info(self, mock_dir, project_with_build): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_build) - cmd = MagicMock() - result = prototype_deploy(cmd, rollback_info=True, json_output=True) - assert "last_deployment" in result - assert "rollback_instructions" in result - - -class TestPrototypeDeployGenerateScripts: - """Test deploy --generate-scripts flag.""" - - @patch(f"{_MOD}._get_project_dir") - def test_generate_scripts_no_apps(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - # concept/apps exists but empty (not created by init; build creates it) - (project_with_config / "concept" / "apps").mkdir(parents=True, exist_ok=True) - result = prototype_deploy(cmd, generate_scripts=True, json_output=True) - assert result["status"] == "generated" - assert len(result["scripts"]) == 0 - - @patch(f"{_MOD}._get_project_dir") - def test_generate_scripts_with_apps(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_config) - # Create app directories - apps_dir = project_with_config / "concept" / "apps" - (apps_dir / "backend").mkdir(parents=True, exist_ok=True) - (apps_dir / "frontend").mkdir(parents=True, exist_ok=True) - - cmd = MagicMock() - result = prototype_deploy(cmd, generate_scripts=True, script_deploy_type="webapp", json_output=True) - assert result["status"] == "generated" - assert len(result["scripts"]) == 2 - - @patch(f"{_MOD}._get_project_dir") - def test_generate_scripts_no_apps_dir_raises(self, mock_dir, project_with_config): - # Remove apps dir if present - import shutil - - from azext_prototype.custom import prototype_deploy - - apps_dir = project_with_config / "concept" / "apps" - if apps_dir.exists(): - shutil.rmtree(apps_dir) - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - with pytest.raises(CLIError, match="No apps directory"): - prototype_deploy(cmd, generate_scripts=True) - - -class TestPrototypeAgentOverride: - """Test agent override command.""" - - @patch(f"{_MOD}._get_project_dir") - def test_override_registers(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_override - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - # Create a real YAML file for the override - override_file = project_with_config / "my_arch.yaml" - override_file.write_text( - "name: cloud-architect\ndescription: Custom Override\n" - "capabilities:\n - architect\nsystem_prompt: Custom prompt.\n", - encoding="utf-8", - ) - - result = prototype_agent_override(cmd, name="cloud-architect", file="my_arch.yaml", json_output=True) - assert result["status"] == "override_registered" - assert result["name"] == "cloud-architect" - - def test_override_missing_name_raises(self): - from azext_prototype.custom import prototype_agent_override - - cmd = MagicMock() - with pytest.raises(CLIError, match="--name"): - prototype_agent_override(cmd, name=None, file="x.yaml") - - def test_override_missing_file_raises(self): - from azext_prototype.custom import prototype_agent_override - - cmd = MagicMock() - with pytest.raises(CLIError, match="--file"): - prototype_agent_override(cmd, name="x", file=None) - - -class TestPrototypeAgentRemove: - """Test agent remove command.""" - - @patch(f"{_MOD}._get_project_dir") - def test_remove_custom_agent(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_add, prototype_agent_remove - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - # Add then remove - prototype_agent_add(cmd, name="to-remove", definition="cloud_architect") - result = prototype_agent_remove(cmd, name="to-remove", json_output=True) - assert result["status"] == "removed" - - @patch(f"{_MOD}._get_project_dir") - def test_remove_override_agent(self, mock_dir, project_with_config): - from azext_prototype.custom import ( - prototype_agent_override, - prototype_agent_remove, - ) - - mock_dir.return_value = str(project_with_config) - - # Create a real YAML file for the override - override_file = project_with_config / "my_arch.yaml" - override_file.write_text( - "name: cloud-architect\ndescription: Override\n" "capabilities:\n - architect\nsystem_prompt: Override.\n", - encoding="utf-8", - ) - - cmd = MagicMock() - prototype_agent_override(cmd, name="cloud-architect", file="my_arch.yaml") - result = prototype_agent_remove(cmd, name="cloud-architect", json_output=True) - assert result["status"] == "override_removed" - - @patch(f"{_MOD}._get_project_dir") - def test_remove_builtin_raises(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_remove - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - # bicep-agent is builtin and not custom/override → should raise - with pytest.raises(CLIError, match="Built-in agents cannot be removed"): - prototype_agent_remove(cmd, name="app-developer") - - def test_remove_missing_name_raises(self): - from azext_prototype.custom import prototype_agent_remove - - cmd = MagicMock() - with pytest.raises(CLIError, match="--name"): - prototype_agent_remove(cmd, name=None) - - -class TestPrototypeAnalyzeError: - """Test the error analysis command.""" - - def test_missing_input_raises(self): - from azext_prototype.custom import prototype_analyze_error - - cmd = MagicMock() - with pytest.raises(CLIError, match="Error input is required"): - prototype_analyze_error(cmd, input=None) - - @patch(f"{_MOD}._prepare_command") - def test_analyze_inline_error(self, mock_prep, project_with_design, mock_ai_provider): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.custom import prototype_analyze_error - - mock_qa = MagicMock() - mock_qa.name = "qa-engineer" - mock_qa.execute.return_value = AIResponse(content="Root cause: missing RBAC", model="gpt-4o") - - mock_registry = MagicMock() - mock_registry.find_by_capability.return_value = [mock_qa] - - mock_ctx = MagicMock() - mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, mock_ctx) - - cmd = MagicMock() - result = prototype_analyze_error(cmd, input="ResourceNotFound error", json_output=True) - assert result["status"] == "analyzed" - - @patch(f"{_MOD}._prepare_command") - def test_analyze_log_file(self, mock_prep, project_with_design, mock_ai_provider): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.custom import prototype_analyze_error - - mock_qa = MagicMock() - mock_qa.name = "qa-engineer" - mock_qa.execute.return_value = AIResponse(content="Root cause: config error", model="gpt-4o") - - mock_registry = MagicMock() - mock_registry.find_by_capability.return_value = [mock_qa] - - mock_ctx = MagicMock() - mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, mock_ctx) - - log_file = project_with_design / "error.log" - log_file.write_text("ERROR: Connection refused", encoding="utf-8") - - cmd = MagicMock() - result = prototype_analyze_error(cmd, input=str(log_file), json_output=True) - assert result["status"] == "analyzed" - - @patch(f"{_MOD}._prepare_command") - def test_analyze_screenshot(self, mock_prep, project_with_design, mock_ai_provider): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.custom import prototype_analyze_error - - mock_qa = MagicMock() - mock_qa.name = "qa-engineer" - mock_qa.execute_with_image.return_value = AIResponse(content="Screenshot analysis", model="gpt-4o") - - mock_registry = MagicMock() - mock_registry.find_by_capability.return_value = [mock_qa] - - mock_ctx = MagicMock() - mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, mock_ctx) - - img = project_with_design / "error.png" - img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) - - cmd = MagicMock() - result = prototype_analyze_error(cmd, input=str(img), json_output=True) - assert result["status"] == "analyzed" - - -class TestPrototypeAnalyzeCosts: - """Test the cost analysis command.""" - - @patch(f"{_MOD}._prepare_command") - def test_analyze_costs(self, mock_prep, project_with_design, mock_ai_provider): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.custom import prototype_analyze_costs - - mock_cost = MagicMock() - mock_cost.name = "cost-analyst" - mock_cost.execute.return_value = AIResponse(content="Cost report content", model="gpt-4o") - - mock_registry = MagicMock() - mock_registry.find_by_capability.return_value = [mock_cost] - - mock_ctx = MagicMock() - mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, mock_ctx) - - cmd = MagicMock() - result = prototype_analyze_costs(cmd, json_output=True) - assert result["status"] == "analyzed" - - @patch(f"{_MOD}._prepare_command") - def test_analyze_costs_no_agent_raises(self, mock_prep, project_with_design): - from azext_prototype.custom import prototype_analyze_costs - - mock_registry = MagicMock() - mock_registry.find_by_capability.return_value = [] - mock_prep.return_value = (str(project_with_design), MagicMock(), mock_registry, MagicMock()) - - cmd = MagicMock() - with pytest.raises(CLIError, match="No cost analyst"): - prototype_analyze_costs(cmd) - - -class TestExtractCostTable: - """Test _extract_cost_table helper.""" - - def test_extracts_summary_table(self): - from azext_prototype.custom import _extract_cost_table - - content = ( - "# Executive Summary\n\nSome intro text.\n\n---\n\n" - "## Cost Summary Table\n\n" - " Service Small Medium Large\n" - " ──────────────────────────────────────────\n" - " App Service $0.00 $13.14 $74.00\n" - " TOTAL $0.00 $13.14 $74.00\n" - "\n\n---\n\n" - "## T-Shirt Size Definitions\n\nMore details...\n" - ) - result = _extract_cost_table(content) - assert "Cost Summary Table" in result - assert "$13.14" in result - assert "T-Shirt Size" not in result - - def test_fallback_on_no_heading(self): - from azext_prototype.custom import _extract_cost_table - - content = "No table here, just text about the architecture." - result = _extract_cost_table(content) - assert result == content - - -class TestPrototypeConfigSet: - """Additional config set tests.""" - - def test_config_set_missing_value_raises(self): - from azext_prototype.custom import prototype_config_set - - cmd = MagicMock() - with pytest.raises(CLIError, match="--value"): - prototype_config_set(cmd, key="some.key", value=None) - - @patch(f"{_MOD}._get_project_dir") - def test_config_set_json_value(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_config_set - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - result = prototype_config_set(cmd, key="deploy.tags", value='{"env":"dev"}', json_output=True) - assert result["status"] == "updated" - - -class TestPrototypeStatusExtended: - """Extended status tests.""" - - @patch(f"{_MOD}._get_project_dir") - def test_status_with_build_shows_changes(self, mock_dir, project_with_build): - from azext_prototype.custom import prototype_status - - mock_dir.return_value = str(project_with_build) - cmd = MagicMock() - result = prototype_status(cmd, json_output=True) - # If build stage is marked completed, pending_changes should exist - if result.get("stages", {}).get("build", {}).get("completed"): - assert "pending_changes" in result - else: - # Build state exists → pending_changes may still be present - assert "stages" in result - - @patch(f"{_MOD}._get_project_dir") - def test_status_default_uses_console(self, mock_dir, project_with_config): - """Default mode (no flags) uses console output and returns None (suppressed).""" - from azext_prototype.custom import prototype_status - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - with patch("azext_prototype.custom.console", create=True): - result = prototype_status(cmd) - - assert result is None - - @patch(f"{_MOD}._get_project_dir") - def test_status_json_returns_enriched_dict(self, mock_dir, project_with_config): - """--json returns enriched dict with all new fields.""" - from azext_prototype.custom import prototype_status - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - result = prototype_status(cmd, json_output=True) - - assert isinstance(result, dict) - assert result["project"] == "test-project" - assert "environment" in result - assert "naming_strategy" in result - assert "project_id" in result - assert "deployment_history" in result - # All three stages present - for stage in ("design", "build", "deploy"): - assert stage in result["stages"] - assert "completed" in result["stages"][stage] - - @patch(f"{_MOD}._get_project_dir") - def test_status_detailed_prints_detail(self, mock_dir, project_with_config): - """--detailed prints expanded output and returns None (suppressed).""" - from azext_prototype.custom import prototype_status - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - with patch("azext_prototype.custom.console", create=True): - result = prototype_status(cmd, detailed=True) - - assert result is None - - @patch(f"{_MOD}._get_project_dir") - def test_status_with_discovery_state(self, mock_dir, project_with_config): - """Discovery state populates exchanges/confirmed/open.""" - import yaml - - from azext_prototype.custom import prototype_status - - state_dir = project_with_config / ".prototype" / "state" - state_dir.mkdir(parents=True, exist_ok=True) - state_file = state_dir / "discovery.yaml" - state_file.write_text( - yaml.dump( - { - "open_items": ["item1"], - "confirmed_items": ["item2", "item3"], - "conversation_history": [], - "_metadata": { - "exchange_count": 5, - "created": "2026-01-01T00:00:00", - "last_updated": "2026-01-01T01:00:00", - }, - } - ), - encoding="utf-8", - ) - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - result = prototype_status(cmd, json_output=True) - - d = result["stages"]["design"] - assert d["exchanges"] == 5 - assert d["confirmed"] == 2 - assert d["open"] == 1 - - @patch(f"{_MOD}._get_project_dir") - def test_status_with_build_state(self, mock_dir, project_with_build): - """Build state populates templates/stages/files/overrides.""" - from azext_prototype.custom import prototype_status - - mock_dir.return_value = str(project_with_build) - cmd = MagicMock() - result = prototype_status(cmd, json_output=True) - - b = result["stages"]["build"] - assert "templates_used" in b - assert "total_stages" in b - assert "accepted_stages" in b - assert "files_generated" in b - assert "policy_overrides" in b - assert b["total_stages"] >= 0 - - @patch(f"{_MOD}._get_project_dir") - def test_status_with_deploy_state(self, mock_dir, project_with_config): - """Deploy state populates deployed/failed/rolled_back/outputs.""" - import yaml - - from azext_prototype.custom import prototype_status - - state_dir = project_with_config / ".prototype" / "state" - state_dir.mkdir(parents=True, exist_ok=True) - state_file = state_dir / "deploy.yaml" - state_file.write_text( - yaml.dump( - { - "deployment_stages": [ - {"stage": 1, "name": "Foundation", "deploy_status": "deployed", "services": []}, - { - "stage": 2, - "name": "App", - "deploy_status": "failed", - "deploy_error": "timeout", - "services": [], - }, - ], - "captured_outputs": {"terraform": {"endpoint": "https://example.com"}}, - "_metadata": {"created": "2026-01-01T00:00:00", "last_updated": "2026-01-01T01:00:00"}, - } - ), - encoding="utf-8", - ) - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - result = prototype_status(cmd, json_output=True) - - dp = result["stages"]["deploy"] - assert dp["total_stages"] == 2 - assert dp["deployed"] == 1 - assert dp["failed"] == 1 - assert dp["rolled_back"] == 0 - assert dp["outputs_captured"] == 1 - - @patch(f"{_MOD}._get_project_dir") - def test_status_no_state_files(self, mock_dir, project_with_config): - """Config exists but no state files — stages show zero counts.""" - from azext_prototype.custom import prototype_status - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - result = prototype_status(cmd, json_output=True) - - d = result["stages"]["design"] - assert d["exchanges"] == 0 - assert d["confirmed"] == 0 - assert d["open"] == 0 - - b = result["stages"]["build"] - assert b["total_stages"] == 0 - assert b["files_generated"] == 0 - - dp = result["stages"]["deploy"] - assert dp["total_stages"] == 0 - assert dp["deployed"] == 0 - - @patch(f"{_MOD}._get_project_dir") - def test_status_deployment_history(self, mock_dir, project_with_config): - """Deployment history from ChangeTracker is included.""" - import json as json_mod - - from azext_prototype.custom import prototype_status - - # Create a manifest with deployment history - manifest_dir = project_with_config / ".prototype" / "state" - manifest_dir.mkdir(parents=True, exist_ok=True) - manifest_path = manifest_dir / "change_manifest.json" - manifest_path.write_text( - json_mod.dumps( - { - "files": {}, - "deployments": [ - {"scope": "all", "timestamp": "2026-01-15T10:00:00", "files_count": 12}, - ], - } - ), - encoding="utf-8", - ) - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - result = prototype_status(cmd, json_output=True) - - assert len(result["deployment_history"]) == 1 - assert result["deployment_history"][0]["scope"] == "all" - - @patch(f"{_MOD}._get_project_dir") - def test_status_detailed_json_returns_dict(self, mock_dir, project_with_config): - """When both detailed and json_output are True, json wins — returns dict.""" - from azext_prototype.custom import prototype_status - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - result = prototype_status(cmd, detailed=True, json_output=True) - - # json_output takes precedence — returns the enriched dict, not displayed - assert isinstance(result, dict) - assert "project" in result - assert result.get("status") != "displayed" - - -class TestLoadDesignContext: - """Test _load_design_context.""" - - def test_loads_from_design_json(self, project_with_design): - from azext_prototype.custom import _load_design_context - - result = _load_design_context(str(project_with_design)) - assert "Sample architecture" in result - - def test_loads_from_architecture_md(self, project_with_config): - from azext_prototype.custom import _load_design_context - - arch_md = project_with_config / "concept" / "docs" / "ARCHITECTURE.md" - arch_md.parent.mkdir(parents=True, exist_ok=True) - arch_md.write_text("# My Architecture\nDetails here.", encoding="utf-8") - - result = _load_design_context(str(project_with_config)) - assert "My Architecture" in result - - def test_returns_empty_when_no_design(self, tmp_project): - from azext_prototype.custom import _load_design_context - - result = _load_design_context(str(tmp_project)) - assert result == "" - - -class TestRenderTemplate: - """Test _render_template.""" - - def test_replaces_placeholders(self): - from azext_prototype.custom import _render_template - - template = "Project: [PROJECT_NAME], Region: [LOCATION], Date: [DATE]" - config = {"project": {"name": "my-proj", "location": "westus2"}} - result = _render_template(template, config) - assert "my-proj" in result - assert "westus2" in result - assert "[PROJECT_NAME]" not in result - - def test_keeps_unknown_placeholders(self): - from azext_prototype.custom import _render_template - - template = "[UNKNOWN_PLACEHOLDER] stays" - result = _render_template(template, {}) - assert "[UNKNOWN_PLACEHOLDER]" in result - - -class TestGenerateTemplates: - """Test _generate_templates shared helper.""" - - def test_generates_all_templates(self, project_with_config): - from azext_prototype.custom import _generate_templates, _load_config - - config = _load_config(str(project_with_config)) - output_dir = project_with_config / "test_output" - - generated = _generate_templates(output_dir, str(project_with_config), config.to_dict(), "test") - assert len(generated) >= 1 - assert output_dir.is_dir() - - def test_generates_with_manifest(self, project_with_config): - from azext_prototype.custom import _generate_templates, _load_config - - config = _load_config(str(project_with_config)) - output_dir = project_with_config / "speckit_output" - - _generate_templates( - output_dir, - str(project_with_config), - config.to_dict(), - "speckit", - include_manifest=True, - ) - assert (output_dir / "manifest.json").exists() - manifest = json.loads((output_dir / "manifest.json").read_text()) - assert "speckit_version" in manifest - - -# ====================================================================== -# _load_design_context — 3-source cascade -# ====================================================================== - -_MOD = "azext_prototype.custom" - - -class TestLoadDesignContextCascade: - """Test the 3-source cascade in _load_design_context.""" - - def test_loads_from_design_json(self, project_with_design): - """Source 1: design.json is used when present.""" - from azext_prototype.custom import _load_design_context - - result = _load_design_context(str(project_with_design)) - assert "Sample architecture" in result - - def test_falls_back_to_discovery_yaml(self, project_with_discovery): - """Source 2: discovery.yaml used when no design.json.""" - from azext_prototype.custom import _load_design_context - - result = _load_design_context(str(project_with_discovery)) - assert result # Should get non-empty context from discovery state - - def test_design_json_takes_priority(self, project_with_design): - """design.json takes priority over discovery.yaml when both exist.""" - import yaml as _yaml - - from azext_prototype.custom import _load_design_context - - # Add a discovery.yaml alongside the existing design.json - state_dir = project_with_design / ".prototype" / "state" - discovery = { - "project": {"summary": "Different content from discovery"}, - "confirmed_items": ["Different item"], - "_metadata": {"exchange_count": 1, "created": "2026-01-01T00:00:00", "last_updated": "2026-01-01T00:00:00"}, - } - (state_dir / "discovery.yaml").write_text(_yaml.dump(discovery), encoding="utf-8") - - result = _load_design_context(str(project_with_design)) - assert "Sample architecture" in result # design.json content, not discovery - - def test_falls_back_to_architecture_md(self, project_with_config): - """Source 3: ARCHITECTURE.md used when no state files exist.""" - from azext_prototype.custom import _load_design_context - - arch_md = project_with_config / "concept" / "docs" / "ARCHITECTURE.md" - arch_md.parent.mkdir(parents=True, exist_ok=True) - arch_md.write_text("# Architecture from markdown", encoding="utf-8") - - result = _load_design_context(str(project_with_config)) - assert "Architecture from markdown" in result - - def test_returns_empty_when_nothing(self, project_with_config): - """Returns empty string when no sources exist.""" - from azext_prototype.custom import _load_design_context - - result = _load_design_context(str(project_with_config)) - assert result == "" - - -# ====================================================================== -# Analyze costs — cache behavior -# ====================================================================== - - -class TestAnalyzeCostsCache: - """Test cost analysis caching (deterministic results).""" - - def _make_mock_prep(self, project_dir, mock_registry, mock_context): - """Build a _prepare_command return tuple.""" - from azext_prototype.config import ProjectConfig - - config = ProjectConfig(str(project_dir)) - config.load() - return (str(project_dir), config, mock_registry, mock_context) - - def _make_registry_with_cost_agent(self): - from tests.conftest import make_ai_response - - agent = MagicMock() - agent.name = "cost-analyst" - agent.execute.return_value = make_ai_response("## Cost Report\n| Service | Small | Medium | Large |") - - registry = MagicMock() - registry.find_by_capability.return_value = [agent] - return registry, agent - - @patch(f"{_MOD}._prepare_command") - def test_first_run_calls_agent_and_caches(self, mock_prep, project_with_design): - from azext_prototype.custom import prototype_analyze_costs - - registry, agent = self._make_registry_with_cost_agent() - mock_ctx = MagicMock() - mock_ctx.project_config = {"project": {"location": "eastus"}} - mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) - - cmd = MagicMock() - result = prototype_analyze_costs(cmd, refresh=False, json_output=True) - - assert result["status"] == "analyzed" - agent.execute.assert_called_once() - - # Cache file should exist - cache = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" - assert cache.exists() - - @patch(f"{_MOD}._prepare_command") - def test_second_run_returns_cached(self, mock_prep, project_with_design): - """Cached result returned without calling agent.""" - import yaml as _yaml - - from azext_prototype.custom import prototype_analyze_costs - - registry, agent = self._make_registry_with_cost_agent() - mock_ctx = MagicMock() - mock_ctx.project_config = {"project": {"location": "eastus"}} - mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) - - # Pre-populate cache with matching hash - import hashlib - - from azext_prototype.custom import _load_design_context - - design_context = _load_design_context(str(project_with_design)) - context_hash = hashlib.sha256(design_context.encode("utf-8")).hexdigest()[:16] - - cache_data = { - "context_hash": context_hash, - "content": "Cached cost report content", - "result": {"status": "analyzed", "agent": "cost-analyst"}, - "timestamp": "2026-01-01T00:00:00+00:00", - } - cache_path = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" - cache_path.write_text(_yaml.dump(cache_data, default_flow_style=False), encoding="utf-8") - - cmd = MagicMock() - result = prototype_analyze_costs(cmd, refresh=False, json_output=True) - - assert result["status"] == "analyzed" - agent.execute.assert_not_called() # Should NOT have called the agent - - @patch(f"{_MOD}._prepare_command") - def test_refresh_bypasses_cache(self, mock_prep, project_with_design): - """--refresh forces fresh analysis even when cache matches.""" - import yaml as _yaml - - from azext_prototype.custom import prototype_analyze_costs - - registry, agent = self._make_registry_with_cost_agent() - mock_ctx = MagicMock() - mock_ctx.project_config = {"project": {"location": "eastus"}} - mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) - - # Pre-populate cache with matching hash - import hashlib - - from azext_prototype.custom import _load_design_context - - design_context = _load_design_context(str(project_with_design)) - context_hash = hashlib.sha256(design_context.encode("utf-8")).hexdigest()[:16] - - cache_data = { - "context_hash": context_hash, - "content": "Old cached content", - "result": {"status": "analyzed", "agent": "cost-analyst"}, - } - cache_path = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" - cache_path.write_text(_yaml.dump(cache_data, default_flow_style=False), encoding="utf-8") - - cmd = MagicMock() - result = prototype_analyze_costs(cmd, refresh=True, json_output=True) - - assert result["status"] == "analyzed" - agent.execute.assert_called_once() # Should HAVE called the agent - - @patch(f"{_MOD}._prepare_command") - def test_cache_invalidated_on_design_change(self, mock_prep, project_with_design): - """Different design context hash invalidates the cache.""" - import yaml as _yaml - - from azext_prototype.custom import prototype_analyze_costs - - registry, agent = self._make_registry_with_cost_agent() - mock_ctx = MagicMock() - mock_ctx.project_config = {"project": {"location": "eastus"}} - mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) - - # Pre-populate cache with a DIFFERENT hash - cache_data = { - "context_hash": "stale_hash_0000", - "content": "Stale cached content", - "result": {"status": "analyzed", "agent": "cost-analyst"}, - } - cache_path = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" - cache_path.write_text(_yaml.dump(cache_data, default_flow_style=False), encoding="utf-8") - - cmd = MagicMock() - result = prototype_analyze_costs(cmd, refresh=False, json_output=True) - - assert result["status"] == "analyzed" - agent.execute.assert_called_once() # Stale cache — must re-run - - @patch(f"{_MOD}._prepare_command") - def test_cache_file_written_to_state_dir(self, mock_prep, project_with_design): - """Cache is written to .prototype/state/cost_analysis.yaml.""" - import yaml as _yaml - - from azext_prototype.custom import prototype_analyze_costs - - registry, agent = self._make_registry_with_cost_agent() - mock_ctx = MagicMock() - mock_ctx.project_config = {"project": {"location": "eastus"}} - mock_prep.return_value = self._make_mock_prep(project_with_design, registry, mock_ctx) - - cmd = MagicMock() - prototype_analyze_costs(cmd, refresh=False) - - cache_path = project_with_design / ".prototype" / "state" / "cost_analysis.yaml" - assert cache_path.exists() - cached = _yaml.safe_load(cache_path.read_text(encoding="utf-8")) - assert "context_hash" in cached - assert "content" in cached - assert "timestamp" in cached - - -# ====================================================================== -# Console output — analyze commands -# ====================================================================== - - -class TestAnalyzeConsoleOutput: - """Verify analyze commands use console.* methods (not raw print).""" - - @patch(f"{_MOD}._prepare_command") - @patch(f"{_MOD}.console", create=True) - def test_analyze_error_uses_console(self, mock_console, mock_prep, project_with_design): - from azext_prototype.custom import prototype_analyze_error - from tests.conftest import make_ai_response - - agent = MagicMock() - agent.name = "qa-engineer" - agent.execute.return_value = make_ai_response("## Fix\nDo something") - - registry = MagicMock() - registry.find_by_capability.return_value = [agent] - - config = MagicMock() - mock_prep.return_value = (str(project_with_design), config, registry, MagicMock()) - - cmd = MagicMock() - result = prototype_analyze_error(cmd, input="some error text", json_output=True) - - assert result["status"] == "analyzed" - - @patch(f"{_MOD}._prepare_command") - def test_analyze_error_warns_no_context(self, mock_prep, project_with_config): - """When no design context exists, a warning should be shown.""" - from azext_prototype.custom import prototype_analyze_error - from tests.conftest import make_ai_response - - agent = MagicMock() - agent.name = "qa-engineer" - agent.execute.return_value = make_ai_response("## Fix\nDo something") - - registry = MagicMock() - registry.find_by_capability.return_value = [agent] - - config = MagicMock() - mock_prep.return_value = (str(project_with_config), config, registry, MagicMock()) - - cmd = MagicMock() - - # Patch the module-level console singleton. We must use importlib - # because `import azext_prototype.ui.console` can resolve to the - # `console` variable re-exported in azext_prototype.ui.__init__ - # instead of the submodule (name collision on Python 3.10). - import importlib - - _console_mod = importlib.import_module("azext_prototype.ui.console") - - with patch.object(_console_mod, "console") as mock_console: # noqa: F841 - result = prototype_analyze_error(cmd, input="some error", json_output=True) - - assert result["status"] == "analyzed" - - @patch(f"{_MOD}._prepare_command") - def test_analyze_costs_uses_console(self, mock_prep, project_with_design): - from azext_prototype.custom import prototype_analyze_costs - from tests.conftest import make_ai_response - - agent = MagicMock() - agent.name = "cost-analyst" - agent.execute.return_value = make_ai_response("## Costs\n$100/mo") - - registry = MagicMock() - registry.find_by_capability.return_value = [agent] - - from azext_prototype.config import ProjectConfig - - config = ProjectConfig(str(project_with_design)) - config.load() - - mock_ctx = MagicMock() - mock_ctx.project_config = {"project": {"location": "eastus"}} - mock_prep.return_value = (str(project_with_design), config, registry, mock_ctx) - - cmd = MagicMock() - result = prototype_analyze_costs(cmd, refresh=True, json_output=True) - - assert result["status"] == "analyzed" - - -# ====================================================================== -# Console output — deploy subcommands -# ====================================================================== - - -class TestDeploySubcommandConsole: - """Verify deploy flag sub-actions use console.* methods.""" - - @patch(f"{_MOD}._get_project_dir") - def test_deploy_outputs_empty_warns(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - with patch("azext_prototype.stages.deploy_helpers.DeploymentOutputCapture") as MockCapture: - MockCapture.return_value.get_all.return_value = {} - result = prototype_deploy(cmd, outputs=True, json_output=True) - - assert result["status"] == "empty" - - @patch(f"{_MOD}._get_project_dir") - def test_deploy_rollback_info_empty_warns(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - with patch("azext_prototype.stages.deploy_helpers.RollbackManager") as MockMgr: - MockMgr.return_value.get_last_snapshot.return_value = None - MockMgr.return_value.get_rollback_instructions.return_value = None - result = prototype_deploy(cmd, rollback_info=True, json_output=True) - - assert result["last_deployment"] is None - assert result["rollback_instructions"] is None - - @patch(f"{_MOD}._get_project_dir") - @patch(f"{_MOD}._load_config") - def test_generate_scripts_uses_console(self, mock_config, mock_dir, project_with_config): - from azext_prototype.custom import prototype_deploy - - mock_dir.return_value = str(project_with_config) - mock_config.return_value = MagicMock() - mock_config.return_value.get.return_value = "" - - # Create an apps directory with a subdirectory - apps_dir = project_with_config / "concept" / "apps" - apps_dir.mkdir(parents=True, exist_ok=True) - (apps_dir / "my-app").mkdir() - - cmd = MagicMock() - - with patch("azext_prototype.stages.deploy_helpers.DeployScriptGenerator") as MockGen: # noqa: F841 - result = prototype_deploy(cmd, generate_scripts=True, json_output=True) - - assert result["status"] == "generated" - assert "my-app/deploy.sh" in result["scripts"] - - -# ====================================================================== -# Agent commands — Rich UI, new commands, validation -# ====================================================================== - -_MOD = "azext_prototype.custom" - - -class TestPrototypeAgentListRichUI: - """Test agent list Rich UI, json, and detailed modes.""" - - @patch(f"{_MOD}._get_project_dir") - def test_list_json_returns_list(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_list - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - result = prototype_agent_list(cmd, json_output=True) - assert isinstance(result, list) - assert len(result) >= 8 - - @patch(f"{_MOD}._get_project_dir") - def test_list_console_mode(self, mock_dir, project_with_config): - """Default (non-json) returns list and uses console.""" - from azext_prototype.custom import prototype_agent_list - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - result = prototype_agent_list(cmd, json_output=True) - assert isinstance(result, list) - - @patch(f"{_MOD}._get_project_dir") - def test_list_detailed_mode(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_list - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - result = prototype_agent_list(cmd, detailed=True, json_output=True) - assert isinstance(result, list) - - @patch(f"{_MOD}._get_project_dir") - def test_list_agents_have_source(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_list - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - result = prototype_agent_list(cmd, json_output=True) - for agent in result: - assert "source" in agent - - -class TestPrototypeAgentShowRichUI: - """Test agent show Rich UI, json, and detailed modes.""" - - @patch(f"{_MOD}._get_project_dir") - def test_show_json_returns_dict(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_show - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - result = prototype_agent_show(cmd, name="cloud-architect", json_output=True) - assert isinstance(result, dict) - assert result["name"] == "cloud-architect" - assert "system_prompt_preview" in result - - @patch(f"{_MOD}._get_project_dir") - def test_show_detailed_includes_full_prompt(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_show - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - result = prototype_agent_show(cmd, name="cloud-architect", detailed=True, json_output=True) - assert "system_prompt" in result - # detailed should not have preview - assert "system_prompt_preview" not in result - - @patch(f"{_MOD}._get_project_dir") - def test_show_console_mode(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_show - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - result = prototype_agent_show(cmd, name="cloud-architect", json_output=True) - assert isinstance(result, dict) - - -class TestPrototypeAgentUpdate: - """Test agent update command.""" - - @patch(f"{_MOD}._get_project_dir") - def test_update_description(self, mock_dir, project_with_config): - """Targeted field update — description only.""" - from azext_prototype.custom import prototype_agent_add, prototype_agent_update - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - prototype_agent_add(cmd, name="updatable", definition="cloud_architect") - result = prototype_agent_update(cmd, name="updatable", description="New desc", json_output=True) - assert result["status"] == "updated" - assert result["description"] == "New desc" - - @patch(f"{_MOD}._get_project_dir") - def test_update_capabilities(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_add, prototype_agent_update - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - prototype_agent_add(cmd, name="cap-update", definition="cloud_architect") - result = prototype_agent_update(cmd, name="cap-update", capabilities="architect,deploy", json_output=True) - assert result["status"] == "updated" - assert "architect" in result["capabilities"] - assert "deploy" in result["capabilities"] - - @patch(f"{_MOD}._get_project_dir") - def test_update_system_prompt_from_file(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_add, prototype_agent_update - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - prototype_agent_add(cmd, name="prompt-update", definition="cloud_architect") - - prompt_file = project_with_config / "new_prompt.txt" - prompt_file.write_text("You are an updated agent.", encoding="utf-8") - - result = prototype_agent_update( - cmd, name="prompt-update", system_prompt_file=str(prompt_file), json_output=True - ) - assert result["status"] == "updated" - - import yaml as _yaml - - agent_file = project_with_config / ".prototype" / "agents" / "prompt-update.yaml" - content = _yaml.safe_load(agent_file.read_text(encoding="utf-8")) - assert content["system_prompt"] == "You are an updated agent." - - @patch(f"{_MOD}._get_project_dir") - def test_update_interactive_mode(self, mock_dir, project_with_config): - """Interactive mode with mocked input.""" - from azext_prototype.custom import prototype_agent_add, prototype_agent_update - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - prototype_agent_add(cmd, name="interactive-up", definition="cloud_architect") - - # Mock interactive prompts: description, role, capabilities, constraints (empty), system prompt (empty=keep) - inputs = [ - "Updated description", # description - "architect", # role - "architect", # capabilities - "", # end constraints - "", # system prompt (keep existing - first empty line) - "", # examples (skip) - ] - with patch("builtins.input", side_effect=inputs): - result = prototype_agent_update(cmd, name="interactive-up", json_output=True) - - assert result["status"] == "updated" - assert result["description"] == "Updated description" - - @patch(f"{_MOD}._get_project_dir") - def test_update_manifest_sync(self, mock_dir, project_with_config): - """Manifest entry is updated after field update.""" - from azext_prototype.custom import ( - _load_config, - prototype_agent_add, - prototype_agent_update, - ) - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - prototype_agent_add(cmd, name="manifest-sync", definition="cloud_architect") - prototype_agent_update(cmd, name="manifest-sync", description="Synced desc") - - config = _load_config(str(project_with_config)) - custom = config.get("agents.custom", {}) - assert custom["manifest-sync"]["description"] == "Synced desc" - - def test_update_missing_name_raises(self): - from azext_prototype.custom import prototype_agent_update - - cmd = MagicMock() - with pytest.raises(CLIError, match="--name"): - prototype_agent_update(cmd, name=None) - - @patch(f"{_MOD}._get_project_dir") - def test_update_nonexistent_raises(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_update - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - with pytest.raises(CLIError, match="not found"): - prototype_agent_update(cmd, name="nonexistent-agent") - - @patch(f"{_MOD}._get_project_dir") - def test_update_invalid_capability_raises(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_add, prototype_agent_update - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - prototype_agent_add(cmd, name="bad-cap", definition="cloud_architect") - with pytest.raises(CLIError, match="Unknown capability"): - prototype_agent_update(cmd, name="bad-cap", capabilities="invalid_cap") - - @patch(f"{_MOD}._get_project_dir") - def test_update_prompt_file_not_found_raises(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_add, prototype_agent_update - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - prototype_agent_add(cmd, name="no-prompt", definition="cloud_architect") - with pytest.raises(CLIError, match="not found"): - prototype_agent_update(cmd, name="no-prompt", system_prompt_file="./does_not_exist.txt") - - -class TestPrototypeAgentTest: - """Test agent test command.""" - - @patch(f"{_MOD}._prepare_command") - def test_default_prompt(self, mock_prep, project_with_config, mock_ai_provider): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.custom import prototype_agent_test - - mock_agent = MagicMock() - mock_agent.name = "cloud-architect" - mock_agent.execute.return_value = AIResponse( - content="I am the cloud architect.", - model="gpt-4o", - usage={"prompt_tokens": 50, "completion_tokens": 20, "total_tokens": 70}, - ) - - mock_registry = MagicMock() - mock_registry.get.return_value = mock_agent - mock_prep.return_value = (str(project_with_config), MagicMock(), mock_registry, MagicMock()) - - cmd = MagicMock() - result = prototype_agent_test(cmd, name="cloud-architect", json_output=True) - - assert result["status"] == "tested" - assert result["name"] == "cloud-architect" - assert result["model"] == "gpt-4o" - assert result["tokens"] == 70 - mock_agent.execute.assert_called_once() - - @patch(f"{_MOD}._prepare_command") - def test_custom_prompt(self, mock_prep, project_with_config, mock_ai_provider): - from azext_prototype.ai.provider import AIResponse - from azext_prototype.custom import prototype_agent_test - - mock_agent = MagicMock() - mock_agent.name = "cloud-architect" - mock_agent.execute.return_value = AIResponse( - content="Here is a web app design.", - model="gpt-4o", - usage={"total_tokens": 100}, - ) - - mock_registry = MagicMock() - mock_registry.get.return_value = mock_agent - mock_prep.return_value = (str(project_with_config), MagicMock(), mock_registry, MagicMock()) - - cmd = MagicMock() - result = prototype_agent_test(cmd, name="cloud-architect", prompt="Design a web app", json_output=True) - - assert result["status"] == "tested" - # Verify custom prompt was passed - call_args = mock_agent.execute.call_args - assert "Design a web app" in call_args[0][1] - - def test_test_missing_name_raises(self): - from azext_prototype.custom import prototype_agent_test - - cmd = MagicMock() - with pytest.raises(CLIError, match="--name"): - prototype_agent_test(cmd, name=None) - - -class TestPrototypeAgentExport: - """Test agent export command.""" - - @patch(f"{_MOD}._get_project_dir") - def test_export_builtin(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_export - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - output_path = str(project_with_config / "exported.yaml") - result = prototype_agent_export(cmd, name="cloud-architect", output_file=output_path, json_output=True) - - assert result["status"] == "exported" - assert result["name"] == "cloud-architect" - - import yaml as _yaml - - exported = _yaml.safe_load((project_with_config / "exported.yaml").read_text(encoding="utf-8")) - assert exported["name"] == "cloud-architect" - assert "capabilities" in exported - assert "system_prompt" in exported - - @patch(f"{_MOD}._get_project_dir") - def test_export_custom(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_add, prototype_agent_export - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - prototype_agent_add(cmd, name="export-test", definition="bicep_agent") - output_path = str(project_with_config / "custom_export.yaml") - result = prototype_agent_export(cmd, name="export-test", output_file=output_path, json_output=True) - - assert result["status"] == "exported" - assert (project_with_config / "custom_export.yaml").exists() - - @patch(f"{_MOD}._get_project_dir") - def test_export_default_path(self, mock_dir, project_with_config): - """Default output path is ./{name}.yaml.""" - import os - - from azext_prototype.custom import prototype_agent_export - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - # Change cwd to project dir for default path - original_cwd = os.getcwd() - try: - os.chdir(str(project_with_config)) - result = prototype_agent_export(cmd, name="cloud-architect", json_output=True) - assert result["status"] == "exported" - assert (project_with_config / "cloud-architect.yaml").exists() - finally: - os.chdir(original_cwd) - - @patch(f"{_MOD}._get_project_dir") - def test_export_loadable_by_loader(self, mock_dir, project_with_config): - """Exported YAML is loadable by load_yaml_agent.""" - from azext_prototype.agents.loader import load_yaml_agent - from azext_prototype.custom import prototype_agent_export - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - output_path = str(project_with_config / "loadable.yaml") - prototype_agent_export(cmd, name="cloud-architect", output_file=output_path) - - agent = load_yaml_agent(output_path) - assert agent.name == "cloud-architect" - - def test_export_missing_name_raises(self): - from azext_prototype.custom import prototype_agent_export - - cmd = MagicMock() - with pytest.raises(CLIError, match="--name"): - prototype_agent_export(cmd, name=None) - - -class TestPrototypeAgentOverrideValidation: - """Test override validation enhancements.""" - - @patch(f"{_MOD}._get_project_dir") - def test_override_file_not_found_raises(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_override - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - with pytest.raises(CLIError, match="not found"): - prototype_agent_override(cmd, name="cloud-architect", file="./does_not_exist.yaml") - - @patch(f"{_MOD}._get_project_dir") - def test_override_invalid_yaml_raises(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_override - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - bad_yaml = project_with_config / "bad.yaml" - bad_yaml.write_text("{{invalid yaml::", encoding="utf-8") - - with pytest.raises(CLIError, match="Invalid YAML"): - prototype_agent_override(cmd, name="cloud-architect", file="bad.yaml") - - @patch(f"{_MOD}._get_project_dir") - def test_override_missing_name_field_raises(self, mock_dir, project_with_config): - from azext_prototype.custom import prototype_agent_override - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - no_name = project_with_config / "no_name.yaml" - no_name.write_text("description: test\n", encoding="utf-8") - - with pytest.raises(CLIError, match="name"): - prototype_agent_override(cmd, name="cloud-architect", file="no_name.yaml") - - @patch(f"{_MOD}._get_project_dir") - def test_override_non_builtin_warns(self, mock_dir, project_with_config): - """Overriding a non-builtin name should warn but succeed.""" - from azext_prototype.custom import prototype_agent_override - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - valid_yaml = project_with_config / "valid.yaml" - valid_yaml.write_text( - "name: nonexistent-agent\ndescription: test\ncapabilities:\n - develop\n" "system_prompt: test\n", - encoding="utf-8", - ) - - result = prototype_agent_override(cmd, name="nonexistent-agent", file="valid.yaml", json_output=True) - assert result["status"] == "override_registered" - - @patch(f"{_MOD}._get_project_dir") - def test_override_valid_builtin(self, mock_dir, project_with_config): - """Overriding a known builtin should succeed without warnings.""" - from azext_prototype.custom import prototype_agent_override - - mock_dir.return_value = str(project_with_config) - cmd = MagicMock() - - valid_yaml = project_with_config / "arch_override.yaml" - valid_yaml.write_text( - "name: cloud-architect\ndescription: Custom arch\ncapabilities:\n - architect\n" - "system_prompt: Custom prompt.\n", - encoding="utf-8", - ) - - result = prototype_agent_override(cmd, name="cloud-architect", file="arch_override.yaml", json_output=True) - assert result["status"] == "override_registered" - - -class TestPromptAgentDefinition: - """Test the _prompt_agent_definition interactive helper.""" - - def test_full_walkthrough(self): - from azext_prototype.custom import _prompt_agent_definition - from azext_prototype.ui.console import Console - - console = Console() - inputs = [ - "My agent description", # description - "architect", # role - "architect,deploy", # capabilities - "Must use PaaS only", # constraint 1 - "", # end constraints - "You are a custom agent.", # system prompt line 1 - "END", # end system prompt - "", # no examples - ] - with patch("builtins.input", side_effect=inputs): - result = _prompt_agent_definition(console, "test-agent") - - assert result["name"] == "test-agent" - assert result["description"] == "My agent description" - assert result["role"] == "architect" - assert "architect" in result["capabilities"] - assert "deploy" in result["capabilities"] - assert "Must use PaaS only" in result["constraints"] - assert "You are a custom agent." in result["system_prompt"] - - def test_existing_defaults(self): - from azext_prototype.custom import _prompt_agent_definition - from azext_prototype.ui.console import Console - - console = Console() - existing = { - "description": "Old desc", - "role": "developer", - "capabilities": ["develop"], - "constraints": ["Old constraint"], - "system_prompt": "Old prompt.", - "examples": [{"user": "hello", "assistant": "hi"}], - } - # All empty inputs → keep existing values - inputs = [ - "", # description (keep) - "", # role (keep) - "", # capabilities (keep) - "", # constraints (keep existing) - "", # system prompt (keep existing) - "", # examples (keep existing) - ] - with patch("builtins.input", side_effect=inputs): - result = _prompt_agent_definition(console, "test-agent", existing=existing) - - assert result["description"] == "Old desc" - assert result["role"] == "developer" - assert result["capabilities"] == ["develop"] - assert result["constraints"] == ["Old constraint"] - assert result["system_prompt"] == "Old prompt." - assert result["examples"] == [{"user": "hello", "assistant": "hi"}] - - def test_invalid_capability_skipped(self): - from azext_prototype.custom import _prompt_agent_definition - from azext_prototype.ui.console import Console - - console = Console() - inputs = [ - "desc", # description - "role", # role - "invalid_cap,architect", # capabilities — one invalid - "", # end constraints - "prompt", # system prompt - "END", # end system prompt - "", # no examples - ] - with patch("builtins.input", side_effect=inputs): - result = _prompt_agent_definition(console, "test-agent") - - assert "architect" in result["capabilities"] - assert "invalid_cap" not in result["capabilities"] - - -class TestReadMultilineInput: - """Test _read_multiline_input helper.""" - - def test_reads_until_end(self): - from azext_prototype.custom import _read_multiline_input - - with patch("builtins.input", side_effect=["line 1", "line 2", "END"]): - result = _read_multiline_input() - assert result == "line 1\nline 2" - - def test_empty_first_line_returns_empty(self): - from azext_prototype.custom import _read_multiline_input - - with patch("builtins.input", side_effect=[""]): - result = _read_multiline_input() - assert result == "" diff --git a/tests/tracking/__init__.py b/tests/tracking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_tracking.py b/tests/tracking/test___init__.py similarity index 100% rename from tests/test_tracking.py rename to tests/tracking/test___init__.py diff --git a/tests/ui/test_tui_widgets.py b/tests/ui/test_tui_widgets.py index 46f95b9..202ca4b 100644 --- a/tests/ui/test_tui_widgets.py +++ b/tests/ui/test_tui_widgets.py @@ -195,27 +195,6 @@ async def test_info_bar_updates(): # No exception = success -@pytest.mark.asyncio -async def test_prompt_input_disable(): - """PromptInput should be disabled by default.""" - app = PrototypeApp() - async with app.run_test() as pilot: # noqa: F841 - prompt = app.prompt_input - assert prompt._enabled is False - assert prompt.read_only is True - - -@pytest.mark.asyncio -async def test_prompt_input_enable(): - """PromptInput should allow enabling for input.""" - app = PrototypeApp() - async with app.run_test() as pilot: # noqa: F841 - prompt = app.prompt_input - prompt.enable() - assert prompt._enabled is True - assert prompt.read_only is False - - @pytest.mark.asyncio async def test_file_list(): """ConsoleView should render file lists.""" @@ -249,40 +228,3 @@ async def test_console_view_write_markup_invalid_falls_back(): app.console_view.write_markup("[invalid_tag_that_wont_parse") -# -------------------------------------------------------------------- # -# PromptInput allow_empty tests -# -------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_prompt_input_allow_empty(): - """PromptInput with allow_empty=True should submit empty string.""" - app = PrototypeApp() - async with app.run_test() as pilot: # noqa: F841 - prompt = app.prompt_input - prompt.enable(allow_empty=True) - assert prompt._allow_empty is True - assert prompt._enabled is True - - -@pytest.mark.asyncio -async def test_prompt_input_default_no_allow_empty(): - """PromptInput defaults to allow_empty=False.""" - app = PrototypeApp() - async with app.run_test() as pilot: # noqa: F841 - prompt = app.prompt_input - prompt.enable() - assert prompt._allow_empty is False - - -@pytest.mark.asyncio -async def test_prompt_input_input_mode(): - """In input mode (default), text has '> ' prefix and placeholder is empty.""" - app = PrototypeApp() - async with app.run_test() as pilot: # noqa: F841 - prompt = app.prompt_input - prompt.enable() - assert prompt._allow_empty is False - assert prompt._enabled is True - assert prompt.text == "> " - assert prompt.placeholder == "" From 699ee7d07e68e1a59fe9e27c4a11ea0639122929 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Wed, 8 Apr 2026 15:09:46 -0400 Subject: [PATCH 08/12] Build QA: fix re-entry skip, full stage retry, checklist hardening, PRU update - Fix QA remediation re-entry bug: mark_stage_generated -> mark_stage_validating in remediation loop so failed stages are retried on re-run instead of skipped - Add full stage retry (_MAX_FULL_STAGE_ATTEMPTS=2): when QA remediation exhausts all attempts, clean artifacts and regenerate from scratch with prior QA findings injected into the generation prompt - Harden QA checklist: response_export_values mandatory on every azapi_resource, deploy.sh -state= flag check, UUID hex validation - Front-load cross-stage dependency no-dead-code directive before architecture context - Backfill PRU multiplier table from GitHub Copilot docs (raptor-mini, gemini-2.5-pro, gpt-5.2-codex, gpt-5.3-codex, claude-opus-4.6-fast at 30 PRU) - 14 new tests (3 re-entry, 5 checklist/prompt, 6 full stage retry) --- HISTORY.rst | 58 ++ azext_prototype/agents/builtin/qa_engineer.py | 8 +- azext_prototype/ai/token_tracker.py | 11 +- azext_prototype/stages/build_session.py | 485 +++++++------ tests/agents/test_agents.py | 63 ++ tests/stages/test_build_session.py | 657 +++++++++++++++--- 6 files changed, 996 insertions(+), 286 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 66d0761..595db17 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,64 @@ Release History =============== +0.2.1b7 ++++++++ + +Build stage re-entry fix +~~~~~~~~~~~~~~~~~~~~~~~~~~ +* **QA remediation failure now retries on re-entry** — fixed a bug where + ``mark_stage_generated()`` was called after each remediation attempt + inside ``_run_stage_qa()``, leaving the stage with status ``"generated"`` + even when QA subsequently failed. On re-entry, the stage was skipped + instead of retried. Changed to ``mark_stage_validating()`` so failed + stages remain in the retry list. + +QA checklist hardening +~~~~~~~~~~~~~~~~~~~~~~~~ +* **Aligned response_export_values directive** — QA checklist now requires + ``response_export_values = ["*"]`` on EVERY ``azapi_resource``, matching + the terraform agent's mandatory rule (was conditional on output usage). +* **Added deploy.sh -state= flag check** — QA checklist now flags use of + ``terraform output -state=`` which was removed in Terraform 1.9. +* **Added UUID hex validation** — QA checklist now checks that UUID values + in role assignment names contain only valid hex characters ``[0-9a-f]``. + +Full stage retry on QA exhaustion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* **Full stage retry when QA remediation fails** -- when QA remediation + exhausts all attempts for a stage, the build retries the entire stage + from scratch instead of stopping. Previous QA findings are injected + into the new generation prompt so the model avoids the same classes of + mistakes. Controlled by ``_MAX_FULL_STAGE_ATTEMPTS`` (default 2: + 1 initial + 1 retry). + +Generation prompt improvements +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* **Front-loaded remote state no-dead-code directive** — when upstream + stages exist, a ``CROSS-STAGE DEPENDENCIES — NO DEAD CODE`` section + now appears before the architecture context in the generation prompt, + reducing unused ``terraform_remote_state`` data sources. + +Test suite consolidation +~~~~~~~~~~~~~~~~~~~~~~~~~~ +* **Consolidated and enhanced unit test coverage** — migrated flat test + files to a mirrored directory structure (1:1 test-to-source mapping), + merged split test files, and removed ~114 duplicate tests across 10 + files. Test suite reduced from 3,644 to 3,530 tests with zero loss + of unique coverage. + +QA review continuation for large stages +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* **QA review collects complete response before evaluating** — when the + QA review response is truncated (``finish_reason=length``), the build + session now continues requesting until the full review is received, + then evaluates the concatenated result. Uses the existing + ``_execute_with_continuation()`` pattern with a review-specific + continuation prompt that prevents the QA agent from generating code + in the continuation. Conversation history is saved and restored + around QA calls to prevent review messages from contaminating + subsequent stage generation. + 0.2.1b6 +++++++ diff --git a/azext_prototype/agents/builtin/qa_engineer.py b/azext_prototype/agents/builtin/qa_engineer.py index 96adf53..893495b 100644 --- a/azext_prototype/agents/builtin/qa_engineer.py +++ b/azext_prototype/agents/builtin/qa_engineer.py @@ -233,6 +233,8 @@ def _encode_image(path: str) -> str: - [ ] deploy.sh includes error handling (set -euo pipefail, trap) - [ ] deploy.sh exports outputs to JSON file for downstream stages - [ ] deploy.sh includes Azure login verification +- [ ] deploy.sh does NOT use `terraform output -state=` — this flag was removed + in Terraform 1.9. Use `jq` on the state file or `cd` into the stage directory ### 4. Output Completeness - [ ] outputs.tf exports resource group name(s) @@ -251,8 +253,8 @@ def _encode_image(path: str) -> str: - [ ] All referenced variables are defined in variables.tf - [ ] All referenced locals are defined in locals.tf - [ ] Application code includes all referenced classes/models/DTOs -- [ ] Every azapi_resource whose `.output.properties` is referenced in - outputs.tf MUST have `response_export_values = ["*"]` declared +- [ ] EVERY `azapi_resource` block MUST have `response_export_values = ["*"]` + declared — no exceptions, even if outputs.tf does not reference its properties - [ ] No .tf file is empty or contains only comments (dead files) ### 7. Terraform File Structure @@ -314,6 +316,8 @@ def _encode_image(path: str) -> str: **NOT** string interpolation on the storage account ID - [ ] RBAC assignments for the worker identity (Stage 1) are **unconditional** (no `count`). The worker identity exists before any service stage runs. +- [ ] UUID values in role assignment names contain only valid hex characters + `[0-9a-f]` — letters `g`-`z` are invalid and ARM rejects with `InvalidName` ### 13. Application Code (app stages only) - [ ] Application source code is syntactically correct and complete diff --git a/azext_prototype/ai/token_tracker.py b/azext_prototype/ai/token_tracker.py index ef7c50b..86f9bad 100644 --- a/azext_prototype/ai/token_tracker.py +++ b/azext_prototype/ai/token_tracker.py @@ -44,30 +44,35 @@ # GitHub Copilot Premium Request Unit (PRU) multipliers. # Each API call costs (1 × multiplier) PRUs. Only applies to the # Copilot provider — models not in this table produce 0 PRUs. -# Source: https://docs.github.com/en/copilot/concepts/billing/copilot-requests +# Source: https://docs.github.com/en/copilot/managing-copilot/monitoring-usage-and-entitlements/about-premium-requests +# Last updated: 2026-04-08 _PRU_MULTIPLIERS: dict[str, float] = { # Included with paid plans (0 PRUs) "gpt-5-mini": 0, "gpt-4.1": 0, "gpt-4o": 0, + "raptor-mini": 0, # Low-cost (0.25–0.33 PRUs per request) "grok-code-fast-1": 0.25, "claude-haiku-4.5": 0.33, "gemini-3-flash": 0.33, - "gpt-5.1-codex-mini": 0.33, "gpt-5.4-mini": 0.33, # Standard (1 PRU per request) "claude-sonnet-4": 1, "claude-sonnet-4.5": 1, "claude-sonnet-4.6": 1, + "gemini-2.5-pro": 1, "gemini-3-pro": 1, - "gemini-3-pro-1.5": 1, + "gemini-3.1-pro": 1, "gpt-5.1": 1, "gpt-5.2": 1, + "gpt-5.2-codex": 1, + "gpt-5.3-codex": 1, "gpt-5.4": 1, # Premium (3+ PRUs per request) "claude-opus-4.5": 3, "claude-opus-4.6": 3, + "claude-opus-4.6-fast": 30, } diff --git a/azext_prototype/stages/build_session.py b/azext_prototype/stages/build_session.py index 1eaed29..275a78d 100644 --- a/azext_prototype/stages/build_session.py +++ b/azext_prototype/stages/build_session.py @@ -29,7 +29,6 @@ from azext_prototype.agents.base import AgentCapability, AgentContext from azext_prototype.agents.governance import GovernanceContext -from azext_prototype.agents.orchestrator import AgentOrchestrator from azext_prototype.agents.registry import AgentRegistry from azext_prototype.config import ProjectConfig from azext_prototype.naming import create_naming_strategy @@ -59,6 +58,11 @@ # Maximum remediation cycles per stage before proceeding _MAX_STAGE_REMEDIATION_ATTEMPTS = 3 +# Maximum full stage attempts (generation + QA cycle). When QA remediation +# exhausts all attempts, the stage is cleaned and regenerated from scratch +# with prior QA findings injected into the generation prompt. +_MAX_FULL_STAGE_ATTEMPTS = 2 # 1 initial + 1 fresh retry + # Keywords that indicate QA found actionable issues (fallback tier) _QA_ISSUE_KEYWORDS = frozenset({"critical", "error", "missing", "fix", "issue", "broken"}) # Phrases that indicate QA found no issues (tier 2) @@ -247,6 +251,10 @@ def _first(cap: AgentCapability) -> Any | None: {"naming": {"strategy": "simple"}, "project": {"name": self._project_name}} ) + # Last QA content from a failed QA remediation — injected into the + # generation prompt on full stage retry so the model knows what to avoid. + self._last_qa_content: str = "" + # ------------------------------------------------------------------ # # Public API # ------------------------------------------------------------------ # @@ -522,57 +530,139 @@ def run( # Use condensed per-stage context (from one-time condensation call) focused_context = stage_contexts.get(stage_num, "") - # App-layer stages use architect → developer delegation - sub_layer_context = "" - if layer == "app" and (self._app_architect or self._csharp_dev or self._python_dev or self._react_dev): - agent, sub_layer_context = self._decompose_app_stage(stage, focused_context, _print) - else: - agent = self._select_agent(stage) - if not agent: - _print(f" Skipped (no agent for capability '{stage.get('capability', '')}')") - continue + # ---- Full stage retry loop ---- + # When QA remediation exhausts all attempts, clean the stage and + # regenerate from scratch with prior QA findings injected. + prior_qa_findings = "" + stage_completed = False + written_paths: list[str] = [] + agent = None - with self._agent_build_context(agent, stage): - # Clear conversation history so prior stage context cannot - # bleed into this stage (especially after truncation/continuation). - self._context.conversation_history.clear() + for full_attempt in range(_MAX_FULL_STAGE_ATTEMPTS): + if full_attempt > 0: + _print( + f" Full retry ({full_attempt}/{_MAX_FULL_STAGE_ATTEMPTS - 1}): " + f"regenerating stage from scratch..." + ) + self._build_state.clean_stage_artifacts(stage_num, self._context.project_dir) + + # App-layer stages use architect → developer delegation + sub_layer_context = "" + if layer == "app" and (self._app_architect or self._csharp_dev or self._python_dev or self._react_dev): + agent, sub_layer_context = self._decompose_app_stage(stage, focused_context, _print) + else: + agent = self._select_agent(stage) + if not agent: + _print(f" Skipped (no agent for capability '{stage.get('capability', '')}')") + break + + with self._agent_build_context(agent, stage): + # Clear conversation history so prior stage context cannot + # bleed into this stage (especially after truncation/continuation). + self._context.conversation_history.clear() + + _, task = self._build_stage_task( + stage, focused_context, templates, prior_qa_findings=prior_qa_findings + ) + + # Inject sub-layer guidance for app stages + if sub_layer_context: + task += f"\n{sub_layer_context}\n" + + _dbg_flow( + "build_session.generate", + f"Stage {stage_num} task prompt", + layer=layer, + capability=stage.get("capability", ""), + agent_name=agent.name, + delegated=bool(sub_layer_context), + task_len=len(task), + has_service_policies="MANDATORY RESOURCE POLICIES" in task, + has_api_versions="Resource API Versions" in task, + has_companion="Companion Resource Requirements" in task, + has_networking_note="Networking Stage" in task, + task_full=task, + ) - _, task = self._build_stage_task(stage, focused_context, templates) + self._build_state.mark_stage_generating(stage_num) + try: + with self._maybe_spinner(f"Building Stage {stage_num}: {stage_name}...", use_styled): + response = self._execute_with_retry( + agent, task, stage_num, stage_name, _print, stage_capability=layer + ) + if response is None: + # All retry attempts exhausted — stop build + build_stopped = True + break + except Exception as exc: + _print(f" Agent error in Stage {stage_num} — routing to QA for diagnosis...") + svc_names_list = [s.get("name", "") for s in services if s.get("name")] + route_error_to_qa( + exc, + f"Build Stage {stage_num}: {stage_name}", + self._qa_agent, + self._context, + self._token_tracker, + _print, + services=svc_names_list, + escalation_tracker=self._escalation_tracker, + source_agent=agent.name, + source_stage="build", + ) + break # Agent error — not stochastic, don't retry - # Inject sub-layer guidance for app stages - if sub_layer_context: - task += f"\n{sub_layer_context}\n" + if response: + self._token_tracker.record(response) + content = response.content if response else "" _dbg_flow( "build_session.generate", - f"Stage {stage_num} task prompt", + f"Stage {stage_num} response", layer=layer, capability=stage.get("capability", ""), agent_name=agent.name, - delegated=bool(sub_layer_context), - task_len=len(task), - has_service_policies="MANDATORY RESOURCE POLICIES" in task, - has_api_versions="Resource API Versions" in task, - has_companion="Companion Resource Requirements" in task, - has_networking_note="Networking Stage" in task, - task_full=task, + content_len=len(content) if content else 0, + content_type=type(content).__name__, + content_full=content if content else "(empty)", ) - self._build_state.mark_stage_generating(stage_num) - try: - with self._maybe_spinner(f"Building Stage {stage_num}: {stage_name}...", use_styled): - response = self._execute_with_retry( - agent, task, stage_num, stage_name, _print, stage_capability=layer + # Debug: scan response for anti-pattern violations before policy resolver + # Skip scanning for docs and app stages — docs describe the architecture + # and app stages generate source code, not IaC. Both trigger false positives. + if content and layer not in ("docs", "app"): + try: + from azext_prototype.governance.anti_patterns import ( + scan as _ap_scan, ) - if response is None: - # All retry attempts exhausted — stop build - build_stopped = True - break - except Exception as exc: - _print(f" Agent error in Stage {stage_num} — routing to QA for diagnosis...") + + stage_svc_types = [s.get("resource_type", "") for s in services if s.get("resource_type")] + _ap_violations = _ap_scan( + content, iac_tool=self._iac_tool, agent_name=agent.name, services=stage_svc_types + ) + if _ap_violations: + _dbg_flow( + "build_session.generate", + f"Stage {stage_num} anti-pattern violations detected", + violation_count=len(_ap_violations), + violations=_ap_violations, + ) + except Exception: + pass + + # Debug: check what the parser would extract + _dbg_files = parse_file_blocks(content) if content else {} + _dbg_flow( + "build_session.generate", + f"Stage {stage_num} parse_file_blocks", + file_count=len(_dbg_files), + filenames=list(_dbg_files.keys())[:10], + ) + + if not content: + _print(f" Empty response for Stage {stage_num} — routing to QA for diagnosis...") svc_names_list = [s.get("name", "") for s in services if s.get("name")] route_error_to_qa( - exc, + "Agent returned empty response", f"Build Stage {stage_num}: {stage_name}", self._qa_agent, self._context, @@ -583,150 +673,97 @@ def run( source_agent=agent.name, source_stage="build", ) - continue - - if response: - self._token_tracker.record(response) - content = response.content if response else "" - - _dbg_flow( - "build_session.generate", - f"Stage {stage_num} response", - layer=layer, - capability=stage.get("capability", ""), - agent_name=agent.name, - content_len=len(content) if content else 0, - content_type=type(content).__name__, - content_full=content if content else "(empty)", - ) - - # Debug: scan response for anti-pattern violations before policy resolver - # Skip scanning for docs and app stages — docs describe the architecture - # and app stages generate source code, not IaC. Both trigger false positives. - if content and layer not in ("docs", "app"): - try: - from azext_prototype.governance.anti_patterns import ( - scan as _ap_scan, - ) - - stage_svc_types = [s.get("resource_type", "") for s in services if s.get("resource_type")] - _ap_violations = _ap_scan( - content, iac_tool=self._iac_tool, agent_name=agent.name, services=stage_svc_types - ) - if _ap_violations: - _dbg_flow( - "build_session.generate", - f"Stage {stage_num} anti-pattern violations detected", - violation_count=len(_ap_violations), - violations=_ap_violations, - ) - except Exception: - pass + written_paths = self._write_stage_files(stage, content) + written_paths = self._apply_stage_transforms(stage, written_paths, _print) - # Debug: check what the parser would extract - _dbg_files = parse_file_blocks(content) if content else {} - _dbg_flow( - "build_session.generate", - f"Stage {stage_num} parse_file_blocks", - file_count=len(_dbg_files), - filenames=list(_dbg_files.keys())[:10], - ) - - if not content: - _print(f" Empty response for Stage {stage_num} — routing to QA for diagnosis...") - svc_names_list = [s.get("name", "") for s in services if s.get("name")] - route_error_to_qa( - "Agent returned empty response", - f"Build Stage {stage_num}: {stage_name}", - self._qa_agent, - self._context, - self._token_tracker, - _print, - services=svc_names_list, - escalation_tracker=self._escalation_tracker, - source_agent=agent.name, - source_stage="build", + _dbg_flow( + "build_session.generate", + f"Stage {stage_num} written_paths", + count=len(written_paths), + paths=written_paths[:5], ) - written_paths = self._write_stage_files(stage, content) - written_paths = self._apply_stage_transforms(stage, written_paths, _print) - - _dbg_flow( - "build_session.generate", - f"Stage {stage_num} written_paths", - count=len(written_paths), - paths=written_paths[:5], - ) - # Files written — mark as validating (ready for QA) - self._build_state.mark_stage_validating(stage_num, written_paths) + # Files written — mark as validating (ready for QA) + self._build_state.mark_stage_validating(stage_num, written_paths) - if written_paths: - if use_styled: - self._console.print_file_list(written_paths) + if written_paths: + if use_styled: + self._console.print_file_list(written_paths) + else: + for f in written_paths: + _print(f" {f}") else: - for f in written_paths: - _print(f" {f}") - else: - _print(" No files extracted from response.") - - # Policy check — runs on all stage categories - if content: - resolutions, needs_regen = self._policy_resolver.check_and_resolve( - agent.name, - content, - self._build_state, - stage_num, - input_fn=input_fn, - print_fn=print_fn, - iac_tool=self._iac_tool, - ) - - if needs_regen: - fix_instructions = self._policy_resolver.build_fix_instructions(resolutions) - _print("Regenerating with fix instructions...") + _print(" No files extracted from response.") + + # Policy check — runs on all stage categories + if content: + resolutions, needs_regen = self._policy_resolver.check_and_resolve( + agent.name, + content, + self._build_state, + stage_num, + input_fn=input_fn, + print_fn=print_fn, + iac_tool=self._iac_tool, + ) - try: - with self._maybe_spinner(f"Re-building Stage {stage_num}...", use_styled): - response = self._execute_with_retry( - agent, - task + fix_instructions, - stage_num, - stage_name, + if needs_regen: + fix_instructions = self._policy_resolver.build_fix_instructions(resolutions) + _print("Regenerating with fix instructions...") + + try: + with self._maybe_spinner(f"Re-building Stage {stage_num}...", use_styled): + response = self._execute_with_retry( + agent, + task + fix_instructions, + stage_num, + stage_name, + _print, + stage_capability=layer, + ) + if response is None: + build_stopped = True + break + except Exception as exc: + svc_names_list = [s.get("name", "") for s in services if s.get("name")] + route_error_to_qa( + exc, + f"Build Stage {stage_num} (regen): {stage_name}", + self._qa_agent, + self._context, + self._token_tracker, _print, - stage_capability=layer, + services=svc_names_list, + escalation_tracker=self._escalation_tracker, + source_agent=agent.name, + source_stage="build", ) - if response is None: - build_stopped = True - break - except Exception as exc: - svc_names_list = [s.get("name", "") for s in services if s.get("name")] - route_error_to_qa( - exc, - f"Build Stage {stage_num} (regen): {stage_name}", - self._qa_agent, - self._context, - self._token_tracker, - _print, - services=svc_names_list, - escalation_tracker=self._escalation_tracker, - source_agent=agent.name, - source_stage="build", - ) - continue + break # Agent error — not stochastic, don't retry + + if response: + self._token_tracker.record(response) + content = response.content if response else "" + written_paths = self._write_stage_files(stage, content) + written_paths = self._apply_stage_transforms(stage, written_paths, _print) + self._build_state.mark_stage_validating(stage_num, written_paths) + + # Per-stage QA validation — runs on all stages that produce files + qa_passed = True + if written_paths: + qa_passed = self._run_stage_qa(stage, architecture, templates, use_styled, _print) - if response: - self._token_tracker.record(response) - content = response.content if response else "" - written_paths = self._write_stage_files(stage, content) - written_paths = self._apply_stage_transforms(stage, written_paths, _print) - self._build_state.mark_stage_validating(stage_num, written_paths) + if qa_passed: + stage_completed = True + break # QA passed — exit retry loop + + # QA failed — capture findings for next full attempt + prior_qa_findings = self._last_qa_content - # Per-stage QA validation — runs on all stages that produce files - qa_passed = True - if written_paths: - qa_passed = self._run_stage_qa(stage, architecture, templates, use_styled, _print) + # ---- After full stage retry loop ---- + if build_stopped: + break # Propagate to stage loop - if qa_passed: + if stage_completed and agent is not None: self._build_state.mark_stage_generated(stage_num, written_paths, agent.name) # Per-stage advisory (non-blocking — failure is logged, not fatal) @@ -736,8 +773,10 @@ def run( if self._update_task_fn: self._update_task_fn(task_id, "completed") + elif not agent: + continue # No agent — skip to next stage else: - # QA failed after max attempts — stop build + # All full attempts exhausted — stop build build_stopped = True if use_styled: self._console.print_token_status(self._token_tracker.format_status()) @@ -2114,6 +2153,7 @@ def _build_stage_task( stage: dict, architecture: str, templates: list, + prior_qa_findings: str = "", ) -> tuple[Any | None, str]: """Build the task prompt for a stage. @@ -2200,9 +2240,37 @@ def _build_stage_task( layer = stage.get("layer", "") - task = ( - f"Generate{tool_label} code for deployment " - f"Stage {stage['stage']}: {stage_name}.\n\n" + task = f"Generate{tool_label} code for deployment " f"Stage {stage['stage']}: {stage_name}.\n\n" + + # Inject prior QA findings from a failed full-stage attempt so the + # model avoids repeating the same classes of mistakes. + if prior_qa_findings: + task += ( + "## CRITICAL: Previous QA Failures (DO NOT REPEAT THESE ISSUES)\n" + "A previous generation of this stage failed QA review after multiple\n" + "remediation attempts. The following issues could not be resolved.\n\n" + "NOTE: The file names and code structure below are from a previous\n" + "generation and may not match yours. Focus on the **underlying issues**\n" + "described — avoid the same classes of mistakes regardless of file\n" + "names or layout.\n\n" + f"{prior_qa_findings}\n\n" + "Generate this stage from scratch, ensuring these classes of issues\n" + "are avoided from the start.\n\n" + ) + + # Front-load the no-dead-code remote state directive so the model + # sees it BEFORE the architecture context (reduces unused data sources). + if is_iac and prev_context: + task += ( + "## CRITICAL: CROSS-STAGE DEPENDENCIES — NO DEAD CODE\n" + "ONLY declare `terraform_remote_state` data sources for stages whose\n" + "outputs you actually reference in resource definitions or locals.\n" + "Do NOT declare remote state data sources 'for completeness' or 'in case needed.'\n" + "Every `data.terraform_remote_state` block MUST have at least one output\n" + "referenced in `locals.tf` or `main.tf`. If it doesn't, do not create it.\n\n" + ) + + task += ( f"## Architecture Context\n{architecture}\n\n" f"## This Stage\n" f"Name: {stage_name}\n" @@ -3273,7 +3341,13 @@ def _run_stage_qa( from azext_prototype.debug_log import log_flow as _dbg stage_num = stage["stage"] - orchestrator = AgentOrchestrator(self._registry, self._context) + + _QA_CONTINUATION_PROMPT = ( + "Your previous review was cut off. Continue your review " + "EXACTLY where you left off. Do NOT regenerate or emit " + "any code — only continue the review analysis and " + "provide your VERDICT." + ) # Build context briefs once for all QA attempts services = stage.get("services", []) @@ -3289,7 +3363,7 @@ def _run_stage_qa( # 2. Build QA task qa_task = self._build_qa_task(stage_num, stage["name"], attempt, file_content, qa_context, layer) - # 3. Run QA (with timeout/rate-limit retry) + # 3. Run QA (with timeout/rate-limit retry and continuation) from azext_prototype.ai.copilot_provider import ( CopilotRateLimitError, CopilotTimeoutError, @@ -3298,12 +3372,16 @@ def _run_stage_qa( qa_result = None max_attempts = len(self._TIMEOUT_BACKOFFS) + 1 for qa_attempt in range(max_attempts): + # Save conversation history — QA messages must not + # leak into the generation context for subsequent stages. + saved_history = list(self._context.conversation_history) try: with self._maybe_spinner(f"QA reviewing Stage {stage_num}...", use_styled): - qa_result = orchestrator.delegate( - from_agent="build-session", - to_agent_name=self._qa_agent.name, - sub_task=qa_task, + qa_result = self._execute_with_continuation( + self._qa_agent, + qa_task, + max_continuations=3, + continuation_prompt=_QA_CONTINUATION_PROMPT, ) break except CopilotRateLimitError as exc: @@ -3320,6 +3398,8 @@ def _run_stage_qa( f"Stage {stage_num} will be retried on next build." ) return False + finally: + self._context.conversation_history = saved_history if qa_result: self._token_tracker.record(qa_result) @@ -3341,6 +3421,7 @@ def _run_stage_qa( if not has_issues: _print(f" Stage {stage_num} passed QA.") + self._last_qa_content = "" return True # 5. If at max attempts, report issues concisely and fail @@ -3354,6 +3435,7 @@ def _run_stage_qa( if stripped.startswith(("CRITICAL", "WARNING", "**CRITICAL", "**WARNING", "- [", "| ")): _print(f" {stripped}") _print("") + self._last_qa_content = qa_content or "" return False # 6. Remediate — re-invoke IaC agent with focused context + governance + knowledge @@ -3406,7 +3488,7 @@ def _run_stage_qa( content = response.content if response else "" written_paths = self._write_stage_files(stage, content) written_paths = self._apply_stage_transforms(stage, written_paths, _print) - self._build_state.mark_stage_generated(stage_num, written_paths, agent.name) + self._build_state.mark_stage_validating(stage_num, written_paths) return True # All remediation attempts completed without hitting max @@ -3560,6 +3642,7 @@ def _execute_with_continuation( stage_num: int = 0, stage_name: str = "", stage_capability: str = "", + continuation_prompt: str | None = None, ) -> Any: """Execute an agent task, automatically continuing if truncated. @@ -3568,6 +3651,11 @@ def _execute_with_continuation( an assistant message so the model can see what it already generated. A continuation prompt is then sent as a new user message, and the model picks up where it left off. + + If *continuation_prompt* is provided it is used verbatim instead of + the default code-generation continuation text. This is useful for + QA reviews where the continuation should request more review, not + more code. """ from azext_prototype.ai.provider import AIMessage, AIResponse @@ -3586,22 +3674,25 @@ def _execute_with_continuation( # model sees what it already generated when continuing. self._context.conversation_history.append(AIMessage(role="assistant", content=response.content or "")) - # Include stage context so the model stays on track - stage_hint = "" - if stage_num and stage_name: - stage_hint = ( - f" You are generating Stage {stage_num}: {stage_name} " - f"(layer: {stage_capability}). " - "Stay within this stage's scope — do not generate content " - "for any other stage." - ) + if continuation_prompt: + cont_task = continuation_prompt + else: + # Include stage context so the model stays on track + stage_hint = "" + if stage_num and stage_name: + stage_hint = ( + f" You are generating Stage {stage_num}: {stage_name} " + f"(layer: {stage_capability}). " + "Stay within this stage's scope — do not generate content " + "for any other stage." + ) - cont_task = ( - "Your previous response was cut off mid-generation. " - "Continue EXACTLY where you left off — do not repeat any " - "file or content already generated. Pick up mid-line if " - f"necessary. Maintain the same code block format.{stage_hint}" - ) + cont_task = ( + "Your previous response was cut off mid-generation. " + "Continue EXACTLY where you left off — do not repeat any " + "file or content already generated. Pick up mid-line if " + f"necessary. Maintain the same code block format.{stage_hint}" + ) self._context.conversation_history.append(AIMessage(role="user", content=cont_task)) cont = agent.execute(self._context, cont_task) diff --git a/tests/agents/test_agents.py b/tests/agents/test_agents.py index a08b654..b64aa19 100644 --- a/tests/agents/test_agents.py +++ b/tests/agents/test_agents.py @@ -505,3 +505,66 @@ def test_cloud_architect_injects_azure_api_version_for_bicep(self): joined = "\n".join(contents) assert "AZURE API VERSION" in joined assert "deployment-language-bicep" in joined + + +# ------------------------------------------------------------------ +# QA checklist content requirements +# ------------------------------------------------------------------ + + +class TestQaChecklistContent: + """QA checklist must contain directives that prevent recurring QA failures.""" + + def test_response_export_values_is_mandatory_on_every_resource(self): + """response_export_values must be required on EVERY azapi_resource, not conditional.""" + from azext_prototype.agents.builtin.qa_engineer import QAEngineerAgent + + agent = QAEngineerAgent() + prompt = agent.system_prompt + # Must NOT contain the conditional "whose .output.properties is referenced" + assert "whose `.output.properties` is referenced" not in prompt, ( + "QA checklist makes response_export_values conditional — " + "it must be mandatory on EVERY azapi_resource to match terraform agent" + ) + # Must require it on every resource + assert "EVERY" in prompt and "response_export_values" in prompt + + def test_deploy_sh_no_state_flag(self): + """QA checklist must flag deploy.sh using 'terraform output -state='.""" + from azext_prototype.agents.builtin.qa_engineer import QAEngineerAgent + + agent = QAEngineerAgent() + prompt = agent.system_prompt + assert "-state=" in prompt, ( + "QA checklist must explicitly check for terraform output -state= " + "flag which was removed in Terraform 1.9" + ) + + def test_uuid_hex_validation(self): + """QA checklist must flag invalid hex characters in UUID values.""" + from azext_prototype.agents.builtin.qa_engineer import QAEngineerAgent + + agent = QAEngineerAgent() + prompt = agent.system_prompt + assert "hex" in prompt.lower() and "uuid" in prompt.lower(), ( + "QA checklist must include check for valid hex characters in UUIDs" + ) + + +# ------------------------------------------------------------------ +# Terraform agent prompt requirements +# ------------------------------------------------------------------ + + +class TestTerraformAgentPromptContent: + """Terraform agent prompt must have strong directives to prevent recurring failures.""" + + def test_deploy_sh_no_state_flag_in_prompt(self): + """Terraform agent must prohibit 'terraform output -state=' in deploy.sh.""" + from azext_prototype.agents.builtin.terraform_agent import TerraformAgent + + agent = TerraformAgent() + prompt = agent.system_prompt + assert "-state=" in prompt, ( + "Terraform agent must explicitly prohibit -state= flag" + ) diff --git a/tests/stages/test_build_session.py b/tests/stages/test_build_session.py index 98ddd1c..f22dfb3 100644 --- a/tests/stages/test_build_session.py +++ b/tests/stages/test_build_session.py @@ -327,6 +327,182 @@ def mock_clean(stage_num, project_dir): assert 1 in clean_called, "Artifacts should be cleaned for generating stage" +# ------------------------------------------------------------------ +# Full stage retry on QA exhaustion +# ------------------------------------------------------------------ + + +class TestFullStageRetry: + """When QA remediation exhausts all attempts, the build retries the + entire stage from scratch with prior QA findings injected.""" + + def test_constant_default(self): + """_MAX_FULL_STAGE_ATTEMPTS must default to 2 (1 initial + 1 retry).""" + from azext_prototype.stages.build_session import _MAX_FULL_STAGE_ATTEMPTS + + assert _MAX_FULL_STAGE_ATTEMPTS == 2 + + def test_full_retry_on_qa_exhaustion(self, build_context, build_registry): + """QA fail on first full attempt → clean artifacts → retry → QA pass → stage generated.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + session._build_state.set_deployment_plan([_make_pending_stage(1, "Key Vault", layer="data")]) + session._build_state.set_design_snapshot(design) + + qa_call_count = [0] + + def mock_qa(*args, **kwargs): + qa_call_count[0] += 1 + if qa_call_count[0] == 1: + session._last_qa_content = "CRITICAL: missing auth" + return False # First full attempt fails + return True # Second full attempt passes + + session._run_stage_qa = mock_qa + + clean_called = [] + original_clean = session._build_state.clean_stage_artifacts + + def spy_clean(stage_num, project_dir): + clean_called.append(stage_num) + original_clean(stage_num, project_dir) + + session._build_state.clean_stage_artifacts = spy_clean + + with patch.object(session, "_build_stage_task", return_value=(MagicMock(name="tf"), "task")): + with patch.object(session, "_execute_with_retry", return_value=MagicMock(content="```main.tf\n#ok\n```")): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + assert qa_call_count[0] == 2, f"QA should run twice (1 per full attempt), got {qa_call_count[0]}" + assert 1 in clean_called, "Artifacts should be cleaned before retry" + final_status = session._build_state._state["deployment_stages"][0]["status"] + assert final_status in ("generated", "accepted"), f"Stage should be generated or accepted, got '{final_status}'" + + def test_full_retry_injects_qa_findings(self, build_context, build_registry): + """Second _build_stage_task call must include prior_qa_findings from failed attempt.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + session._build_state.set_deployment_plan([_make_pending_stage(1, "Key Vault", layer="data")]) + session._build_state.set_design_snapshot(design) + + qa_call_count = [0] + + def mock_qa(*args, **kwargs): + qa_call_count[0] += 1 + if qa_call_count[0] == 1: + session._last_qa_content = "CRITICAL: missing managed identity" + return False + return True + + session._run_stage_qa = mock_qa + + build_task_calls = [] + mock_agent = MagicMock(name="tf") + + def spy_build_task(stage, arch, templates, prior_qa_findings=""): + build_task_calls.append(prior_qa_findings) + return mock_agent, "task" + + with patch.object(session, "_build_stage_task", side_effect=spy_build_task): + with patch.object(session, "_execute_with_retry", return_value=MagicMock(content="```main.tf\n#ok\n```")): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + assert len(build_task_calls) >= 2, f"_build_stage_task should be called at least twice, got {len(build_task_calls)}" + assert build_task_calls[0] == "", "First attempt should have no prior QA findings" + assert "missing managed identity" in build_task_calls[1], ( + f"Second attempt should inject prior QA findings, got: {build_task_calls[1]!r}" + ) + + def test_full_retry_exhausted_stops_build(self, build_context, build_registry): + """Both full attempts fail → build stops, stage stays validating.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + session._build_state.set_deployment_plan([_make_pending_stage(1, "Key Vault", layer="data")]) + session._build_state.set_design_snapshot(design) + + def mock_qa_always_fail(*args, **kwargs): + session._last_qa_content = "CRITICAL: unfixable" + return False + + session._run_stage_qa = mock_qa_always_fail + + with patch.object(session, "_build_stage_task", return_value=(MagicMock(name="tf"), "task")): + with patch.object(session, "_execute_with_retry", return_value=MagicMock(content="```main.tf\n#ok\n```")): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + status = session._build_state._state["deployment_stages"][0]["status"] + assert status != "generated", f"Stage should NOT be generated after exhausted retries, got '{status}'" + + def test_build_stage_task_includes_prior_qa_findings(self, build_context, build_registry): + """Task string must include prior QA findings section before architecture context.""" + session = _make_session(build_context, build_registry) + + session._build_state.set_deployment_plan([{ + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "dir": "concept/infra/terraform/stage-1-key-vault", + "services": [{"name": "kv", "computed_name": "kv", "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", "component": "secrets"}], + "status": "pending", + "files": [], + }]) + + findings = "CRITICAL: missing managed identity RBAC assignment" + agent, task = session._build_stage_task( + session._build_state._state["deployment_stages"][0], + "arch", + [], + prior_qa_findings=findings, + ) + + assert agent is not None + assert "Previous QA Failures" in task, "Task must contain prior QA findings section" + assert findings in task, "Task must contain the actual QA findings text" + assert task.index("Previous QA Failures") < task.index("## Architecture Context"), ( + "Prior QA findings must appear BEFORE architecture context" + ) + + def test_no_retry_when_qa_passes_first_time(self, build_context, build_registry): + """QA passes on first attempt → no clean_stage_artifacts, build_stage_task called once.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + session._build_state.set_deployment_plan([_make_pending_stage(1, "Key Vault", layer="data")]) + session._build_state.set_design_snapshot(design) + + session._run_stage_qa = lambda *a, **kw: True + + clean_called = [] + session._build_state.clean_stage_artifacts = lambda sn, pd: clean_called.append(sn) + + build_task_calls = [0] + mock_agent = MagicMock(name="tf") + + def counting_build_task(*args, **kwargs): + build_task_calls[0] += 1 + return mock_agent, "task" + + with patch.object(session, "_build_stage_task", side_effect=counting_build_task): + with patch.object(session, "_execute_with_retry", return_value=MagicMock(content="```main.tf\n#ok\n```")): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + assert build_task_calls[0] == 1, f"_build_stage_task should be called once, got {build_task_calls[0]}" + assert len(clean_called) == 0, "clean_stage_artifacts should NOT be called when QA passes first time" + + # ------------------------------------------------------------------ # Build state: cascade_downstream_pending # ------------------------------------------------------------------ @@ -652,6 +828,69 @@ def test_docs_stage_task(self, build_context, build_registry): assert agent is not None assert "architecture.md" in task or "deployment-guide.md" in task + def test_iac_stage_task_has_remote_state_directive_before_context(self, build_context, build_registry): + """Remote state no-dead-code directive must appear before the architecture context.""" + session = _make_session(build_context, build_registry) + + # Set up a generated stage so prev_context is populated + session._build_state.set_deployment_plan([ + { + "stage": 1, + "name": "Managed Identity", + "layer": "core", + "capability": "core", + "services": [], + "status": "pending", + "dir": "concept/infra/terraform/stage-1-managed-identity", + "files": [], + }, + { + "stage": 2, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "dir": "concept/infra/terraform/stage-2-key-vault", + "services": [ + { + "name": "key-vault", + "computed_name": "kv-test", + "resource_type": "Microsoft.KeyVault/vaults", + "sku": "standard", + "component": "secrets", + } + ], + "status": "pending", + "files": [], + }, + ]) + # Mark stage 1 as generated (creates proper generation_log entry) + session._build_state.mark_stage_generated(1, ["main.tf"], "terraform-agent") + + # Verify the state is correct before calling _build_stage_task + gen_stages = session._build_state.get_generated_stages() + assert len(gen_stages) == 1, f"Expected 1 generated stage, got {len(gen_stages)}: {gen_stages}" + assert gen_stages[0]["stage"] == 1 + + stage = session._build_state._state["deployment_stages"][1] + agent, task = session._build_stage_task(stage, "arch", []) + assert agent is not None + assert "Previously Generated Stages" in task, ( + f"Task must contain prev stages section. Task length={len(task)}. " + f"Generated stages: {[s['stage'] for s in session._build_state.get_generated_stages()]}" + ) + + # The no-dead-code remote state directive must appear BEFORE architecture context + # to ensure the model prioritizes it during generation + directive_marker = "ONLY declare" + arch_marker = "## Architecture Context" + assert directive_marker in task, ( + "Task must contain no-dead-code remote state directive (ONLY declare...)" + ) + assert task.index(directive_marker) < task.index(arch_marker), ( + "No-dead-code remote state directive must appear BEFORE architecture context " + "to ensure the model sees it with highest priority" + ) + def test_no_agent_returns_empty(self, build_context, build_registry): session = _make_session(build_context, build_registry) # Remove all agents @@ -1851,21 +2090,17 @@ def test_qa_rate_limit_retries(self, build_context, build_registry): session._build_state.set_deployment_plan([stage]) - qa_response = MagicMock(content="VERDICT: PASS", model="test", usage={}) - call_count = [0] - def mock_delegate(**kwargs): + def mock_qa_execute(ctx, task): call_count[0] += 1 if call_count[0] == 1: raise CopilotRateLimitError("rate limited", retry_after=1) - return qa_response + return _make_response("VERDICT: PASS") - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as MockOrch: - mock_orch = MockOrch.return_value - mock_orch.delegate.side_effect = mock_delegate - with patch.object(session, "_countdown"): - passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) + session._qa_agent.execute = mock_qa_execute + with patch.object(session, "_countdown"): + passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) assert passed is True assert call_count[0] >= 2 @@ -1892,11 +2127,9 @@ def test_qa_timeout_exhausts_retries(self, build_context, build_registry): session._build_state.set_deployment_plan([stage]) - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as MockOrch: - mock_orch = MockOrch.return_value - mock_orch.delegate.side_effect = CopilotTimeoutError("timeout") - with patch.object(session, "_countdown"): - passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) + session._qa_agent.execute = MagicMock(side_effect=CopilotTimeoutError("timeout")) + with patch.object(session, "_countdown"): + passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) assert passed is False @@ -1923,33 +2156,173 @@ def test_qa_remediation_cycle(self, build_context, build_registry): call_count = [0] - def mock_delegate(**kwargs): + def mock_qa_execute(ctx, task): call_count[0] += 1 if call_count[0] == 1: # First QA call: issues found - return MagicMock(content="VERDICT: FAIL\nCRITICAL: missing auth", model="test", usage={}) + return _make_response("VERDICT: FAIL\nCRITICAL: missing auth") # Second QA call: pass - return MagicMock(content="VERDICT: PASS", model="test", usage={}) + return _make_response("VERDICT: PASS") - regen_response = MagicMock(content="```main.tf\nfixed\n```", model="test", usage={}) + regen_response = _make_response("```main.tf\nfixed\n```") mock_iac_agent = MagicMock() mock_iac_agent.name = "terraform-agent" - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as MockOrch: - mock_orch = MockOrch.return_value - mock_orch.delegate.side_effect = mock_delegate - with patch.object(session, "_select_agent", return_value=mock_iac_agent): - with patch.object(session, "_build_stage_task", return_value=(mock_iac_agent, "task")): - with patch.object(session, "_execute_with_retry", return_value=regen_response): - with patch.object(session, "_write_stage_files", return_value=["main.tf"]): - with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): - passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) + session._qa_agent.execute = mock_qa_execute + with patch.object(session, "_select_agent", return_value=mock_iac_agent): + with patch.object(session, "_build_stage_task", return_value=(mock_iac_agent, "task")): + with patch.object(session, "_execute_with_retry", return_value=regen_response): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) assert passed is True assert call_count[0] >= 2 +# ------------------------------------------------------------------ +# QA remediation status transitions +# ------------------------------------------------------------------ + + +class TestQaRemediationStatusTransition: + """After QA remediation, stage must stay 'validating' until QA passes. + + Bug: mark_stage_generated() was called after each remediation attempt, + leaving the stage as 'generated' even when QA subsequently failed. + On re-entry, 'generated' stages are skipped instead of retried. + """ + + def test_qa_remediation_failure_leaves_stage_validating(self, build_context, build_registry): + """After exhausted QA remediation, stage status must be 'validating' for re-entry.""" + session = _make_session(build_context, build_registry) + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [{"name": "key-vault", "resource_type": "Microsoft.KeyVault/vaults"}], + "files": ["main.tf"], + } + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1-key-vault" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource {}", encoding="utf-8") + stage["files"] = [str((stage_dir / "main.tf").relative_to(project_dir))] + + session._build_state.set_deployment_plan([stage]) + + # QA always returns FAIL — exhausts all remediation attempts + session._qa_agent.execute = MagicMock( + return_value=_make_response("CRITICAL: This will never be fixed.") + ) + + mock_iac_agent = MagicMock() + mock_iac_agent.name = "terraform-agent" + + with patch.object(session, "_select_agent", return_value=mock_iac_agent): + with patch.object(session, "_build_stage_task", return_value=(mock_iac_agent, "task")): + with patch.object(session, "_execute_with_retry", return_value=_make_response("```main.tf\nfixed\n```")): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) + + assert passed is False + actual_status = session._build_state._state["deployment_stages"][0]["status"] + assert actual_status == "validating", ( + f"After exhausted QA remediation, stage should be 'validating' for re-entry, got '{actual_status}'" + ) + + def test_reentry_after_qa_failure_retries_qa(self, build_context, build_registry): + """Re-running build after QA failure must re-attempt QA on the 'validating' stage.""" + session = _make_session(build_context, build_registry) + design = {"architecture": "Test"} + + session._build_state.set_deployment_plan([ + _make_validating_stage(1, "Key Vault", layer="data", files=["main.tf"]) + ]) + session._build_state.set_design_snapshot(design) + + qa_called = [] + + def mock_qa(*args, **kwargs): + qa_called.append(True) + return True + + session._run_stage_qa = mock_qa + + session.run(design=design, input_fn=lambda p: "done", print_fn=lambda m: None) + + assert len(qa_called) > 0, "Re-entry must re-run QA on a 'validating' stage" + + def test_qa_remediation_marks_validating_between_attempts(self, build_context, build_registry): + """After remediation writes files, status must be 'validating' before next QA check.""" + session = _make_session(build_context, build_registry) + + stage = { + "stage": 1, + "name": "Key Vault", + "layer": "data", + "capability": "data", + "services": [{"name": "key-vault", "resource_type": "Microsoft.KeyVault/vaults"}], + "files": ["main.tf"], + } + + project_dir = Path(build_context.project_dir) + stage_dir = project_dir / "concept" / "infra" / "terraform" / "stage-1-key-vault" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text("resource {}", encoding="utf-8") + stage["files"] = [str((stage_dir / "main.tf").relative_to(project_dir))] + + session._build_state.set_deployment_plan([stage]) + + # Track which mark_stage_* calls happen inside the remediation loop + status_calls = [] + original_validating = session._build_state.mark_stage_validating + original_generated = session._build_state.mark_stage_generated + + def spy_validating(sn, files): + original_validating(sn, files) + status_calls.append(("validating", sn)) + + def spy_generated(sn, files, agent): + original_generated(sn, files, agent) + status_calls.append(("generated", sn)) + + session._build_state.mark_stage_validating = spy_validating + session._build_state.mark_stage_generated = spy_generated + + call_count = [0] + + def mock_qa_execute(ctx, task): + call_count[0] += 1 + if call_count[0] == 1: + return _make_response("VERDICT: FAIL\nCRITICAL: missing auth") + return _make_response("VERDICT: PASS") + + mock_iac_agent = MagicMock() + mock_iac_agent.name = "terraform-agent" + + session._qa_agent.execute = mock_qa_execute + + with patch.object(session, "_select_agent", return_value=mock_iac_agent): + with patch.object(session, "_build_stage_task", return_value=(mock_iac_agent, "task")): + with patch.object(session, "_execute_with_retry", return_value=_make_response("```main.tf\nfixed\n```")): + with patch.object(session, "_write_stage_files", return_value=["main.tf"]): + with patch.object(session, "_apply_stage_transforms", return_value=["main.tf"]): + passed = session._run_stage_qa(stage, "arch", [], False, lambda m: None) + + assert passed is True + # The mark call inside the remediation loop must be "validating", not "generated" + assert any(status == "validating" for status, _ in status_calls), ( + f"Remediation loop must call mark_stage_validating, not mark_stage_generated. " + f"Calls observed: {status_calls}" + ) + + # ------------------------------------------------------------------ # _generate_stage_advisory (lines 3458-3503) # ------------------------------------------------------------------ @@ -3997,6 +4370,7 @@ def mock_architect_agent_for_build(): def mock_qa_agent(): agent = MagicMock() agent.name = "qa-engineer" + agent.execute.return_value = _make_response("All looks good. No issues found.") return agent @@ -4044,15 +4418,14 @@ def test_done_accepts(self, build_context, build_registry, mock_architect_agent_ session._governance = mock_gov_cls.return_value session._policy_resolver._governance = mock_gov_cls.return_value - # Patch AgentOrchestrator.delegate to avoid real QA call - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response("QA looks good") + # QA agent returns clean review + session._qa_agent.execute.return_value = _make_response("QA looks good. No issues found.") - result = session.run( - design={"architecture": "Sample architecture with key-vault and sql-database"}, - input_fn=lambda p: next(inputs), - print_fn=lambda m: None, - ) + result = session.run( + design={"architecture": "Sample architecture with key-vault and sql-database"}, + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) assert result.cancelled is False assert result.review_accepted is True @@ -4377,14 +4750,13 @@ def test_reentrant_skips_generated_stages(self, build_context, build_registry, m session._governance = mock_gov_cls.return_value session._policy_resolver._governance = mock_gov_cls.return_value - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response("QA ok") + session._qa_agent.execute.return_value = _make_response("QA ok. No issues found.") - session.run( - design=design, - input_fn=lambda p: next(inputs), - print_fn=lambda m: None, - ) + session.run( + design=design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) # Stage 1 (generated) should NOT have been re-run # Only doc agent should have been called (for stage 2) @@ -4845,14 +5217,13 @@ def test_incremental_run_with_changes( session._governance = mock_gov_cls.return_value session._policy_resolver._governance = mock_gov_cls.return_value - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response("QA ok") + session._qa_agent.execute.return_value = _make_response("QA ok. No issues found.") - result = session.run( - design=new_design, - input_fn=lambda p: next(inputs), - print_fn=lambda m: printed.append(m), - ) + result = session.run( + design=new_design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: printed.append(m), + ) output = "\n".join(printed) assert "Design changes detected" in output @@ -4941,14 +5312,13 @@ def architect_side_effect(ctx, task): session._governance = mock_gov_cls.return_value session._policy_resolver._governance = mock_gov_cls.return_value - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response("QA ok") + session._qa_agent.execute.return_value = _make_response("QA ok. No issues found.") - result = session.run( - design=new_design, - input_fn=lambda p: next(inputs), - print_fn=lambda m: printed.append(m), - ) + result = session.run( + design=new_design, + input_fn=lambda p: next(inputs), + print_fn=lambda m: printed.append(m), + ) output = "\n".join(printed) assert "full plan re-derive" in output.lower() @@ -6266,11 +6636,10 @@ def test_per_stage_qa_passes_clean(self, tmp_project): printed = [] - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.return_value = _make_response( - "All looks good. Code is clean and well-structured." - ) - session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) + qa_agent.execute = MagicMock( + return_value=_make_response("All looks good. Code is clean and well-structured.") + ) + session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) output = "\n".join(printed) assert "passed QA" in output @@ -6298,15 +6667,14 @@ def test_per_stage_qa_triggers_remediation(self, tmp_project): printed = [] call_count = [0] - def mock_delegate(**kwargs): + def mock_qa_execute(ctx, task): call_count[0] += 1 if call_count[0] == 1: return _make_response("CRITICAL: Missing managed identity config. Must fix.") return _make_response("All resolved, no remaining issues.") - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - mock_orch.return_value.delegate.side_effect = mock_delegate - session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) + qa_agent.execute = mock_qa_execute + session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) output = "\n".join(printed) assert "remediating" in output.lower() @@ -6314,8 +6682,6 @@ def mock_delegate(**kwargs): assert call_count[0] >= 2 def test_per_stage_qa_max_attempts(self, tmp_project): - pass - session, qa_agent, tf_agent = self._make_session(tmp_project) stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" @@ -6337,10 +6703,11 @@ def test_per_stage_qa_max_attempts(self, tmp_project): printed = [] - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - # Always return issues - mock_orch.return_value.delegate.return_value = _make_response("CRITICAL: This will never be fixed.") - session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) + # Always return issues + qa_agent.execute = MagicMock( + return_value=_make_response("CRITICAL: This will never be fixed.") + ) + session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) output = "\n".join(printed) assert "issues remain" in output.lower() @@ -6384,6 +6751,129 @@ def test_collect_stage_file_content_empty(self, tmp_project): content = session._collect_stage_file_content(stage) assert content == "" + def test_qa_collects_complete_review_before_evaluating(self, tmp_project): + """When a QA review response is truncated (finish_reason=length), + the system must continue collecting until the full review is received, + then evaluate the concatenated result. + + Business requirement: QA must never evaluate a partial review. + """ + session, qa_agent, tf_agent = self._make_session(tmp_project) + + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text( + 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' + ) + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "files": ["concept/infra/terraform/stage-1/main.tf"], + "status": "generated", + "services": [], + } + + # First response: truncated mid-review (no verdict yet) + truncated = _make_response( + "### Review\n\nChecking authentication...\nChecking cross-stage refs...\n", + finish_reason="length", + ) + # Second response: continuation completes the review with a verdict + complete = _make_response( + "\nAll checks passed.\n\nVERDICT: PASS", + finish_reason="stop", + ) + qa_agent.execute = MagicMock(side_effect=[truncated, complete]) + + printed = [] + passed = session._run_stage_qa(stage, "arch", [], False, lambda m: printed.append(m)) + + assert passed is True, "Stage should pass QA after full review is collected" + assert qa_agent.execute.call_count == 2, "QA agent should be called twice (initial + continuation)" + assert "passed QA" in "\n".join(printed) + + def test_qa_continuation_requests_review_not_code(self, tmp_project): + """When QA is continued after truncation, the continuation prompt + must instruct the agent to continue reviewing — not to generate code. + + Business requirement: a truncated QA review must never trigger + code generation in the continuation response. + """ + session, qa_agent, tf_agent = self._make_session(tmp_project) + + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text( + 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' + ) + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "files": ["concept/infra/terraform/stage-1/main.tf"], + "status": "generated", + "services": [], + } + + truncated = _make_response("Partial review...", finish_reason="length") + complete = _make_response("\nVERDICT: PASS", finish_reason="stop") + qa_agent.execute = MagicMock(side_effect=[truncated, complete]) + + session._run_stage_qa(stage, "arch", [], False, lambda m: None) + + # The continuation call (second invoke) must contain review language + second_call_args = qa_agent.execute.call_args_list[1] + continuation_task = second_call_args[0][1] # positional arg: (context, task) + continuation_lower = continuation_task.lower() + + assert "review" in continuation_lower, "Continuation prompt must mention 'review'" + assert "do not" in continuation_lower and "code" in continuation_lower, ( + "Continuation prompt must instruct agent NOT to generate code" + ) + + def test_qa_review_does_not_contaminate_generation_history(self, tmp_project): + """QA review messages — including continuations — must not leak + into the conversation history used for subsequent stage generation. + + Business requirement: each stage's generation must start from a + clean context, uncontaminated by QA review exchanges. + """ + session, qa_agent, tf_agent = self._make_session(tmp_project) + + stage_dir = tmp_project / "concept" / "infra" / "terraform" / "stage-1" + stage_dir.mkdir(parents=True, exist_ok=True) + (stage_dir / "main.tf").write_text( + 'resource "azapi_resource" "rg" {\n type = "Microsoft.Resources/resourceGroups@2025-06-01"\n}' + ) + + stage = { + "stage": 1, + "name": "Foundation", + "capability": "infra", + "dir": "concept/infra/terraform/stage-1", + "files": ["concept/infra/terraform/stage-1/main.tf"], + "status": "generated", + "services": [], + } + + history_before = len(session._context.conversation_history) + + truncated = _make_response("Partial review...", finish_reason="length") + complete = _make_response("\nVERDICT: PASS", finish_reason="stop") + qa_agent.execute = MagicMock(side_effect=[truncated, complete]) + + session._run_stage_qa(stage, "arch", [], False, lambda m: None) + + history_after = len(session._context.conversation_history) + assert history_after == history_before, ( + f"QA contaminated conversation history: {history_before} → {history_after} messages" + ) + # ====================================================================== # Advisory QA tests @@ -6529,21 +7019,20 @@ def test_advisory_qa_no_remediation_loop(self, tmp_project): session._governance = mock_gov_cls.return_value session._policy_resolver._governance = mock_gov_cls.return_value - with patch("azext_prototype.stages.build_session.AgentOrchestrator") as mock_orch: - # Return warnings — in old code this would trigger remediation - mock_orch.return_value.delegate.return_value = _make_response( - "WARNING: Missing monitoring. CRITICAL: No backup config." - ) + # QA agent returns warnings — in old code this would trigger remediation + qa_agent.execute = MagicMock( + return_value=_make_response("WARNING: Missing monitoring. CRITICAL: No backup config.") + ) - with patch.object(session, "_identify_affected_stages") as mock_identify: - session.run( - design={"architecture": "Simple architecture"}, - input_fn=lambda p: next(inputs), - print_fn=lambda m: None, - ) + with patch.object(session, "_identify_affected_stages") as mock_identify: + session.run( + design={"architecture": "Simple architecture"}, + input_fn=lambda p: next(inputs), + print_fn=lambda m: None, + ) - # _identify_affected_stages should NOT have been called during Phase 4 - mock_identify.assert_not_called() + # _identify_affected_stages should NOT have been called during Phase 4 + mock_identify.assert_not_called() def test_advisory_qa_header_says_advisory(self, tmp_project): """Output should contain 'Advisory notes' not 'QA Review'.""" From d429040dab1d25c71e8e4a048848c1313a7432fa Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Wed, 8 Apr 2026 15:32:05 -0400 Subject: [PATCH 09/12] Agent service filtering + ReDoS fix in transform handlers - Add stage_services field to AgentContext, populated by _agent_build_context() and passed through _apply_governance_check() to reduce false positive anti-pattern warnings for irrelevant service namespaces - Extract _find_azapi_blocks() shared brace-counting helper and rewrite _add_response_export_values, _add_resource_group_parent_id, and _remove_private_endpoint_resources to eliminate nested-quantifier regex - 5 new tests (2 service filtering, 3 brace counting safety) --- HISTORY.rst | 16 ++ azext_prototype/agents/base.py | 3 +- .../governance/transforms/__init__.py | 152 +++++++++--------- azext_prototype/stages/build_session.py | 3 + tests/agents/test_agents.py | 35 +++- .../governance/transforms/test_transforms.py | 58 +++++++ 6 files changed, 186 insertions(+), 81 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 595db17..14566f4 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -41,6 +41,22 @@ Generation prompt improvements now appears before the architecture context in the generation prompt, reducing unused ``terraform_remote_state`` data sources. +Agent-level service filtering +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* **Agent governance checks now filter by service namespace** — added + ``stage_services`` field to ``AgentContext``, populated by + ``_agent_build_context()``. ``_apply_governance_check()`` now passes + stage services to ``validate_response()``, reducing false positive + anti-pattern warnings for irrelevant service namespaces. + +ReDoS fix in transform handlers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* **Replaced nested-quantifier regex with brace counting** — extracted + shared ``_find_azapi_blocks()`` helper and rewrote + ``_add_response_export_values``, ``_add_resource_group_parent_id``, + and ``_remove_private_endpoint_resources`` to use it. Eliminates + potential exponential backtracking on pathological input. + Test suite consolidation ~~~~~~~~~~~~~~~~~~~~~~~~~~ * **Consolidated and enhanced unit test coverage** — migrated flat test diff --git a/azext_prototype/agents/base.py b/azext_prototype/agents/base.py index f6dddba..d0e17a5 100644 --- a/azext_prototype/agents/base.py +++ b/azext_prototype/agents/base.py @@ -81,6 +81,7 @@ class AgentContext: artifacts: dict[str, Any] = field(default_factory=dict) shared_state: dict[str, Any] = field(default_factory=dict) mcp_manager: Any = None # MCPManager | None — typed as Any to avoid circular import + stage_services: list[str] | None = None # ARM namespaces for service filtering def add_artifact(self, key: str, value: Any): """Store an artifact for other agents to reference.""" @@ -299,7 +300,7 @@ def _apply_governance_check(self, response: AIResponse, context: AgentContext) - avoid duplicating the governance warning block. """ iac_tool = context.project_config.get("project", {}).get("iac_tool") if context.project_config else None - warnings = self.validate_response(response.content, iac_tool=iac_tool, services=None) + warnings = self.validate_response(response.content, iac_tool=iac_tool, services=context.stage_services) if warnings: for w in warnings: logger.warning("Governance: %s", w) diff --git a/azext_prototype/governance/transforms/__init__.py b/azext_prototype/governance/transforms/__init__.py index f5ff700..af7359a 100644 --- a/azext_prototype/governance/transforms/__init__.py +++ b/azext_prototype/governance/transforms/__init__.py @@ -268,6 +268,32 @@ def _remove_unused_remote_state(content: str, stage_content: str | None = None) return result +def _find_azapi_blocks(content: str) -> list[tuple[int, int, str, str]]: + """Find all ``azapi_resource`` blocks using brace counting. + + Returns a list of ``(start, end, resource_name, block_text)`` tuples + where *start*/*end* are character offsets into *content*. + """ + pattern = re.compile(r'resource\s+"azapi_resource"\s+"(\w+)"\s*\{') + blocks: list[tuple[int, int, str, str]] = [] + for match in pattern.finditer(content): + name = match.group(1) + start = match.start() + brace_start = match.end() - 1 + depth = 1 + pos = brace_start + 1 + while pos < len(content) and depth > 0: + if content[pos] == "{": + depth += 1 + elif content[pos] == "}": + depth -= 1 + pos += 1 + if depth != 0: + continue # malformed block + blocks.append((start, pos, name, content[start:pos])) + return blocks + + def _remove_private_endpoint_resources(content: str) -> str: """Remove private endpoint and DNS zone resources from non-networking stages. @@ -287,42 +313,20 @@ def _remove_private_endpoint_resources(content: str) -> str: "virtualnetworklinks", ) - # Find resource block starts and use brace counting to find the end - block_start_pattern = re.compile( - r'resource\s+"azapi_resource"\s+"(\w+)"\s*\{', - ) - removed_names: list[str] = [] result = content - for match in reversed(list(block_start_pattern.finditer(result))): - resource_name = match.group(1) - # Find the matching closing brace using brace counting - start = match.start() - brace_start = match.end() - 1 # position of opening { - depth = 1 - pos = brace_start + 1 - while pos < len(result) and depth > 0: - if result[pos] == "{": - depth += 1 - elif result[pos] == "}": - depth -= 1 - pos += 1 - if depth != 0: - continue # malformed block, skip - - block_text = result[start:pos] - # Check if this block's type is a PE/DNS type + for start, end, resource_name, block_text in reversed(_find_azapi_blocks(result)): type_match = re.search(r'type\s*=\s*"([^"]+)"', block_text) if not type_match: continue resource_type = type_match.group(1).lower() if any(pt in resource_type for pt in pe_types): # Remove the block plus any trailing whitespace/newlines - end = pos - while end < len(result) and result[end] in ("\n", "\r", " "): - end += 1 - result = result[:start] + result[end:] + trim_end = end + while trim_end < len(result) and result[trim_end] in ("\n", "\r", " "): + trim_end += 1 + result = result[:start] + result[trim_end:] removed_names.append(resource_name) logger.debug("Removed PE/DNS resource: azapi_resource.%s", resource_name) @@ -351,29 +355,26 @@ def _remove_private_endpoint_resources(content: str) -> str: def _add_response_export_values(content: str) -> str: """Add ``response_export_values = ["*"]`` to azapi_resource blocks missing it. - Finds each ``resource "azapi_resource" "name" { ... }`` block and checks - if ``response_export_values`` appears inside it. If missing, inserts it - after the ``parent_id`` line (or after ``type`` if no ``parent_id``). + Uses brace-counting via :func:`_find_azapi_blocks` to avoid nested-quantifier + regex (ReDoS risk). Inserts after ``parent_id``, ``location``, or ``type``. """ - # Match azapi_resource blocks - block_pattern = re.compile( - r'(resource\s+"azapi_resource"\s+"\w+"\s*\{)(.*?\n)((?:.*?\n)*?)(})', - re.DOTALL, - ) - - def _inject(match: re.Match) -> str: # type: ignore[type-arg] - full = match.group(0) - if "response_export_values" in full: - return full # already has it + result = content + for start, end, _name, block_text in reversed(_find_azapi_blocks(result)): + if "response_export_values" in block_text: + continue - header = match.group(1) - first_line = match.group(2) - body = match.group(3) - closing = match.group(4) + # Split block body (after the opening { line) into lines + header_end = block_text.index("{") + 1 + header = block_text[:header_end] + body_plus_close = block_text[header_end:] + # Remove the final closing brace + body = body_plus_close.rstrip() + if body.endswith("}"): + body = body[:-1] + closing = "}" - # Find insertion point: after parent_id, or after location, or after type - lines = (first_line + body).splitlines(keepends=True) - insert_idx = len(lines) # fallback: before closing brace + lines = body.splitlines(keepends=True) + insert_idx = len(lines) for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith("parent_id"): @@ -384,48 +385,42 @@ def _inject(match: re.Match) -> str: # type: ignore[type-arg] elif stripped.startswith("type") and insert_idx == len(lines): insert_idx = i + 1 - # Detect indentation from the type/parent_id line indent = " " - if insert_idx > 0 and insert_idx <= len(lines): + if 0 < insert_idx <= len(lines): prev_line = lines[insert_idx - 1] leading = len(prev_line) - len(prev_line.lstrip()) indent = " " * leading lines.insert(insert_idx, f'\n{indent}response_export_values = ["*"]\n') - return header + "".join(lines) + closing + new_block = header + "".join(lines) + closing + result = result[:start] + new_block + result[end:] - new_content = block_pattern.sub(_inject, content) - if new_content != content: + if result != content: logger.debug("Added response_export_values to azapi_resource blocks") - return new_content + return result def _add_resource_group_parent_id(content: str) -> str: """Add ``parent_id`` to resource group azapi_resource blocks missing it. - Finds ``azapi_resource`` blocks whose type contains - ``Microsoft.Resources/resourceGroups`` and injects - ``parent_id = "/subscriptions/${var.subscription_id}"`` - after the ``name`` line. + Uses brace-counting via :func:`_find_azapi_blocks` to avoid nested-quantifier + regex (ReDoS risk). Injects after the ``name`` line. """ - # Match azapi_resource blocks with resourceGroups type - block_pattern = re.compile( - r'(resource\s+"azapi_resource"\s+"\w+"\s*\{)(.*?)(})', - re.DOTALL, - ) - - def _inject(match: re.Match) -> str: # type: ignore[type-arg] - full = match.group(0) - if "resourcegroups" not in full.lower(): - return full - if "parent_id" in full: - return full # already has it + result = content + for start, end, _name, block_text in reversed(_find_azapi_blocks(result)): + if "resourcegroups" not in block_text.lower(): + continue + if "parent_id" in block_text: + continue - header = match.group(1) - body = match.group(2) - closing = match.group(3) + header_end = block_text.index("{") + 1 + header = block_text[:header_end] + body_plus_close = block_text[header_end:] + body = body_plus_close.rstrip() + if body.endswith("}"): + body = body[:-1] + closing = "}" - # Insert after the name line lines = body.splitlines(keepends=True) insert_idx = len(lines) for i, line in enumerate(lines): @@ -433,20 +428,19 @@ def _inject(match: re.Match) -> str: # type: ignore[type-arg] insert_idx = i + 1 break - # Detect indentation indent = " " - if insert_idx > 0 and insert_idx <= len(lines): + if 0 < insert_idx <= len(lines): prev_line = lines[insert_idx - 1] leading = len(prev_line) - len(prev_line.lstrip()) indent = " " * leading lines.insert(insert_idx, f'{indent}parent_id = "/subscriptions/${{var.subscription_id}}"\n') - return header + "".join(lines) + closing + new_block = header + "".join(lines) + closing + result = result[:start] + new_block + result[end:] - new_content = block_pattern.sub(_inject, content) - if new_content != content: + if result != content: logger.debug("Added parent_id to resource group azapi_resource") - return new_content + return result _STRUCTURED_HANDLERS: dict[str, Callable] = { diff --git a/azext_prototype/stages/build_session.py b/azext_prototype/stages/build_session.py index 275a78d..602edb5 100644 --- a/azext_prototype/stages/build_session.py +++ b/azext_prototype/stages/build_session.py @@ -1843,10 +1843,13 @@ def _agent_build_context(self, agent: Any, stage: dict) -> Iterator[Any]: layer = stage.get("layer", "") self._apply_governor_brief(agent, stage.get("name", ""), stage.get("services", []), layer) self._apply_stage_knowledge(agent, stage) + svc_types = [s.get("resource_type", "") for s in stage.get("services", []) if s.get("resource_type")] + self._context.stage_services = svc_types or None try: yield agent finally: agent.set_knowledge_override("") + self._context.stage_services = None def _apply_stage_knowledge(self, agent: Any, stage: dict) -> None: """Set stage-specific knowledge on the agent. diff --git a/tests/agents/test_agents.py b/tests/agents/test_agents.py index b64aa19..6736c64 100644 --- a/tests/agents/test_agents.py +++ b/tests/agents/test_agents.py @@ -1,6 +1,6 @@ """Tests for azext_prototype.agents — registry, loader, base.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest import yaml @@ -507,6 +507,39 @@ def test_cloud_architect_injects_azure_api_version_for_bicep(self): assert "deployment-language-bicep" in joined +# ------------------------------------------------------------------ +# Agent-level service filtering +# ------------------------------------------------------------------ + + +class TestAgentServiceFiltering: + """AgentContext must carry stage_services so _apply_governance_check can filter.""" + + def test_agent_context_has_stage_services_field(self): + from azext_prototype.agents.base import AgentContext + + ctx = AgentContext(project_config={}, project_dir="/tmp", ai_provider=None) + assert ctx.stage_services is None, "Default stage_services should be None" + + def test_apply_governance_check_passes_stage_services(self): + from azext_prototype.agents.base import AgentContext + + agent = StubAgent() + provider = MagicMock() + ctx = AgentContext(project_config={"project": {"iac_tool": "terraform"}}, project_dir="/tmp", ai_provider=provider) + ctx.stage_services = ["Microsoft.KeyVault/vaults"] + + response = AIResponse(content="resource content", model="test", usage={}) + + with patch.object(agent, "validate_response", return_value=[]) as mock_validate: + agent._apply_governance_check(response, ctx) + mock_validate.assert_called_once() + call_kwargs = mock_validate.call_args + assert call_kwargs[1]["services"] == ["Microsoft.KeyVault/vaults"], ( + f"Expected stage_services to be passed through, got: {call_kwargs[1].get('services')}" + ) + + # ------------------------------------------------------------------ # QA checklist content requirements # ------------------------------------------------------------------ diff --git a/tests/governance/transforms/test_transforms.py b/tests/governance/transforms/test_transforms.py index 44fa4ee..cdb9709 100644 --- a/tests/governance/transforms/test_transforms.py +++ b/tests/governance/transforms/test_transforms.py @@ -393,3 +393,61 @@ def test_cross_file_unused_remote_state_removed(self): ) assert "TFM-TF-001" in ids assert "terraform_remote_state" not in result + + +# ------------------------------------------------------------------ +# ReDoS safety: brace-counting replaces nested quantifier regex +# ------------------------------------------------------------------ + + +class TestBraceCountingSafety: + """Transform handlers must use brace counting, not nested-quantifier regex.""" + + def test_response_export_values_pathological_input(self): + """Long line with no newlines must complete in <1 second (no backtracking).""" + import time + + # Pathological: very long body with no newlines, followed by closing brace + long_body = "x" * 50000 + content = f'resource "azapi_resource" "kv" {{\n type = "Microsoft.KeyVault/vaults@2023-07-01"\n {long_body}\n}}\n' + + start = time.monotonic() + result = _add_response_export_values(content) + elapsed = time.monotonic() - start + + assert elapsed < 1.0, f"_add_response_export_values took {elapsed:.2f}s on pathological input (ReDoS?)" + assert 'response_export_values = ["*"]' in result + + def test_resource_group_parent_id_pathological_input(self): + """Long line with no newlines must complete in <1 second (no backtracking).""" + import time + + long_body = "x" * 50000 + content = f'resource "azapi_resource" "rg" {{\n type = "Microsoft.Resources/resourceGroups@2024-03-01"\n name = var.rg\n {long_body}\n}}\n' + + start = time.monotonic() + result = _add_resource_group_parent_id(content) + elapsed = time.monotonic() - start + + assert elapsed < 1.0, f"_add_resource_group_parent_id took {elapsed:.2f}s on pathological input (ReDoS?)" + assert "parent_id" in result + + def test_find_azapi_blocks_nested_braces(self): + """Brace counting must handle nested blocks correctly.""" + from azext_prototype.governance.transforms import _find_azapi_blocks + + content = """resource "azapi_resource" "kv" { + type = "Microsoft.KeyVault/vaults@2023-07-01" + body = { + properties = { + tenantId = var.tenant_id + } + } +} +""" + blocks = _find_azapi_blocks(content) + assert len(blocks) == 1 + start, end, name, block_text = blocks[0] + assert name == "kv" + assert block_text.startswith('resource "azapi_resource"') + assert block_text.rstrip().endswith("}") From 15f02e139505c3aad315f9851c47c863e153df83 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Wed, 8 Apr 2026 16:00:29 -0400 Subject: [PATCH 10/12] Fix deploy guard test: patch check_az_login at import site, not source --- tests/stages/test_deploy_stage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/stages/test_deploy_stage.py b/tests/stages/test_deploy_stage.py index 00bc498..3aa2fba 100644 --- a/tests/stages/test_deploy_stage.py +++ b/tests/stages/test_deploy_stage.py @@ -62,7 +62,7 @@ def test_guards_return_three_guards(self, deploy_stage): def test_all_guards_pass(self, deploy_stage, project_with_build, monkeypatch): monkeypatch.chdir(project_with_build) - with patch("azext_prototype.stages.deploy_helpers.check_az_login", return_value=True): + with patch("azext_prototype.stages.deploy_stage.check_az_login", return_value=True): # Reload guards with the patched function from azext_prototype.stages.deploy_stage import DeployStage From 05dccb3c38b129dc9e4632f47d0f639b4e831fc8 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Wed, 8 Apr 2026 18:27:52 -0400 Subject: [PATCH 11/12] Expound on full stage retry rationale in HISTORY.rst --- .github/workflows/pr.yml | 2 +- HISTORY.rst | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a69745d..3933184 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -69,7 +69,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/HISTORY.rst b/HISTORY.rst index 14566f4..1812015 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -28,11 +28,24 @@ QA checklist hardening Full stage retry on QA exhaustion ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * **Full stage retry when QA remediation fails** -- when QA remediation - exhausts all attempts for a stage, the build retries the entire stage - from scratch instead of stopping. Previous QA findings are injected - into the new generation prompt so the model avoids the same classes of - mistakes. Controlled by ``_MAX_FULL_STAGE_ATTEMPTS`` (default 2: - 1 initial + 1 retry). + exhausts all attempts for a stage, the build now retries the entire + stage from scratch (clean artifacts, regenerate, QA) instead of + stopping the build immediately. Previous QA findings are injected + into the new generation prompt — framed as guidance rather than + file-specific instructions — so the model avoids the same classes + of mistakes on the fresh attempt. + + In practice, the same generation prompt produces passing code ~90% + of the time. The remaining ~10% failure rate is stochastic — not a + systematic prompt deficiency — meaning a fresh generation with + knowledge of what went wrong almost always succeeds. Without this + retry, that 10% forces the user to manually re-run the entire build, + losing the progress of all previously generated stages. The retry + doubles the token cost of one stage in the worst case, but saves + the full cost of restarting a 16-stage build from scratch. + + Controlled by ``_MAX_FULL_STAGE_ATTEMPTS`` (default 2: 1 initial + + 1 fresh retry). Generation prompt improvements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 8f1d7c418cc677d72fe56a889158e46f7eb86a7c Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Wed, 8 Apr 2026 19:45:37 -0400 Subject: [PATCH 12/12] Benchmark run 2026-04-08: 19 stages scored, reports generated - Updated extract.py to handle full stage retry (uses last task prompt per stage) - Simplified INSTRUCTIONS.md extraction section to reference extract.py directly - Individual run report: benchmarks/2026-04-08-14-40-57.html (19 stages, 14 benchmarks) - Updated overall.html trends dashboard with run #2 data - Generated PDF report with 29 charts (overall + 14 factor + 14 trend) - Updated generate_pdf.py data section with new scores and two-point trend history - Removed stale test run 2026-03-31-11-16-46.html --- benchmarks/2026-03-31-11-16-46.html | 484 --------------- benchmarks/2026-04-08-14-40-57.html | 664 +++++++++++++++++++++ benchmarks/2026-04-08_Benchmark_Report.pdf | Bin 0 -> 1143727 bytes benchmarks/INSTRUCTIONS.md | 77 +-- benchmarks/extract.py | 95 ++- benchmarks/overall.html | 50 ++ scripts/generate_pdf.py | 116 ++-- 7 files changed, 869 insertions(+), 617 deletions(-) delete mode 100644 benchmarks/2026-03-31-11-16-46.html create mode 100644 benchmarks/2026-04-08-14-40-57.html create mode 100644 benchmarks/2026-04-08_Benchmark_Report.pdf diff --git a/benchmarks/2026-03-31-11-16-46.html b/benchmarks/2026-03-31-11-16-46.html deleted file mode 100644 index 3f4898e..0000000 --- a/benchmarks/2026-03-31-11-16-46.html +++ /dev/null @@ -1,484 +0,0 @@ - - - - - - Benchmark Run: {{DATE}} - - - - - - - -
    -
    -
    -

    - GitHub Copilot - vs - Claude Code -

    -

    Benchmark Run —

    -
    -
    -

    Project:

    -

    Model:

    -

    Stages won — GHCP: • Claude Code:

    -
    -
    -
    - - - - -
    - - -
    - - -
    -
    -

    Project:

    -

    -
    -
    - - -
    -

    Benchmark Scores

    -
    - - - - - - - - - - - - - - - - - -
    BenchmarkDescriptionGHCPClaude CodeDeltaWinner
    Overall Average
    -
    -
    - - -
    -

    Aggregate Scores by Stage

    -
    - - - - - - - - - -
    StageServiceGHCPClaude CodeWinner
    -
    -
    - - -
    - - -
    - - -
    - - -
    -

    Final Verdict

    -
    -
    -
    -
    -
    GitHub Copilot
    -

    -
    -
    -
    -
    Claude Code
    -

    -
    -
    -
    -
    -
    - -
    - -
    - -
    -

    Benchmark Suite v1.0

    -
    - - - - - - - - diff --git a/benchmarks/2026-04-08-14-40-57.html b/benchmarks/2026-04-08-14-40-57.html new file mode 100644 index 0000000..4b382ed --- /dev/null +++ b/benchmarks/2026-04-08-14-40-57.html @@ -0,0 +1,664 @@ + + + + + + Benchmark Run: 2026-04-08 14:40:57 + + + + + +
    +
    +
    +

    + GitHub Copilot + vs + Claude Code +

    +

    Benchmark Run —

    +
    +
    +

    Project:

    +

    Model:

    +

    Stages won — GHCP: • Claude Code:

    +
    +
    +
    + + + + +
    + + +
    + + +
    +
    +

    Project:

    +

    +
    +
    + + +
    +

    Benchmark Scores

    +
    + + + + + + + + + + + + + + + + + +
    BenchmarkDescriptionGHCPClaude CodeDeltaWinner
    Overall Average
    +
    +
    + + +
    +

    Aggregate Scores by Stage

    +
    + + + + + + + + + +
    StageServiceGHCPClaude CodeWinner
    +
    +
    + + +
    + + +
    + + +
    + + +
    +

    Final Verdict

    +
    +
    +
    +
    +
    GitHub Copilot
    +

    +
    +
    +
    +
    Claude Code
    +

    +
    +
    +
    +
    +
    + +
    + +
    + +
    +

    Benchmark Suite v1.0

    +
    + + + + + + + + diff --git a/benchmarks/2026-04-08_Benchmark_Report.pdf b/benchmarks/2026-04-08_Benchmark_Report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b161d2620817d7def233903f07425ac1d8e082e4 GIT binary patch literal 1143727 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 z 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≫mq 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≦Q+l04B$PqSZ!HzVLq4j~YGg)P{6$gmD3 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 L3|;g1g1$!Z6K49T8h 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�=Cd=sG% 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 e 2 else "COMPARE" -TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|") - -with open(LOG, "r", encoding="utf-8", errors="replace") as f: - lines = f.readlines() - -def find_line(pattern, start=0): - for i in range(start, len(lines)): - if pattern in lines[i]: - return i - return -1 - -def extract_content(start_line, prefix): - first_line = lines[start_line] - idx = first_line.find(prefix + "=") - if idx == -1: - return "" - parts = [first_line[idx + len(prefix) + 1:]] - for i in range(start_line + 1, len(lines)): - if TIMESTAMP_RE.match(lines[i]): - break - parts.append(lines[i]) - return "".join(parts) - -os.makedirs(OUT, exist_ok=True) -for stage_num in range(1, 50): - prompt_line = find_line(f"Stage {stage_num} task prompt") - if prompt_line == -1: - break - # Extract post-transform output (final quality after governance transforms) - transform_line = find_line(f"Stage {stage_num} post-transform", prompt_line) - task_full_line = next((i for i in range(prompt_line, min(prompt_line+10, len(lines))) - if "task_full=" in lines[i]), -1) - transformed_full_line = -1 - if transform_line != -1: - transformed_full_line = next((i for i in range(transform_line, min(transform_line+10, len(lines))) - if "transformed_full=" in lines[i]), -1) - # Fallback to raw response if no post-transform entry (e.g., no transforms applied) - if transformed_full_line == -1: - response_line = find_line(f"Stage {stage_num} response", prompt_line) - if response_line != -1: - transformed_full_line = next((i for i in range(response_line, min(response_line+10, len(lines))) - if "content_full=" in lines[i]), -1) - content_key = "content_full" - else: - continue - else: - content_key = "transformed_full" - if task_full_line == -1 or transformed_full_line == -1: - continue - prompt = extract_content(task_full_line, "task_full") - response = extract_content(transformed_full_line, content_key) - with open(os.path.join(OUT, f"INPUT_{stage_num}.md"), "w") as f: - f.write(prompt) - with open(os.path.join(OUT, f"CP_RESPONSE_{stage_num}.md"), "w") as f: - f.write(response) - print(f"Stage {stage_num}: INPUT={len(prompt)}B CP_RESPONSE={len(response)}B (source: {content_key})") +### Extraction Script + +Use `benchmarks/extract.py` to extract from the debug log: + +```bash +python3 benchmarks/extract.py debug_20260408144057.log COMPARE ``` -Usage: `python3 extract.py debug_20260328024351.log COMPARE` +The script handles **full stage retries**: when a stage has multiple `task prompt` +entries (from the retry loop), it uses the **last** attempt's input and the final +post-transform output. Retried stages are marked with `[RETRY]` in the console output. + +See `benchmarks/extract.py` for the full implementation. --- diff --git a/benchmarks/extract.py b/benchmarks/extract.py index 27a3081..deae56b 100644 --- a/benchmarks/extract.py +++ b/benchmarks/extract.py @@ -1,6 +1,14 @@ #!/usr/bin/env python3 -"""Extract stage prompts and responses from debug log.""" -import re, os, sys +"""Extract stage prompts and responses from debug log. + +When a stage has a full retry (second ``task prompt`` entry), uses the +**last** task prompt and the final post-transform output for that stage. +This ensures benchmarks measure the retry attempt's input — the one that +includes prior QA findings — rather than the original attempt. +""" +import os +import re +import sys LOG = sys.argv[1] # Path to debug log OUT = sys.argv[2] if len(sys.argv) > 2 else "COMPARE" @@ -9,54 +17,105 @@ with open(LOG, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() -def find_line(pattern, start=0): + +def find_all_lines(pattern: str) -> list[int]: + """Return line indices of ALL occurrences of *pattern*.""" + return [i for i, line in enumerate(lines) if pattern in line] + + +def find_line(pattern: str, start: int = 0) -> int: for i in range(start, len(lines)): if pattern in lines[i]: return i return -1 -def extract_content(start_line, prefix): + +def extract_content(start_line: int, prefix: str) -> str: first_line = lines[start_line] idx = first_line.find(prefix + "=") if idx == -1: return "" - parts = [first_line[idx + len(prefix) + 1:]] + parts = [first_line[idx + len(prefix) + 1 :]] for i in range(start_line + 1, len(lines)): if TIMESTAMP_RE.match(lines[i]): break parts.append(lines[i]) return "".join(parts) + os.makedirs(OUT, exist_ok=True) + for stage_num in range(1, 50): - prompt_line = find_line(f"Stage {stage_num} task prompt") - if prompt_line == -1: + # Find ALL task prompt entries for this stage — use the LAST one + # (if a full retry happened, the second prompt includes prior QA findings) + all_prompts = find_all_lines(f"Stage {stage_num} task prompt") + if not all_prompts: break - # Extract post-transform output (final quality after governance transforms) + + prompt_line = all_prompts[-1] # Use last (retry) attempt + retried = len(all_prompts) > 1 + + # Find task_full= within a few lines of the prompt marker + task_full_line = next( + (i for i in range(prompt_line, min(prompt_line + 15, len(lines))) if "task_full=" in lines[i]), + -1, + ) + + # Find the LAST post-transform output after the last task prompt transform_line = find_line(f"Stage {stage_num} post-transform", prompt_line) - task_full_line = next((i for i in range(prompt_line, min(prompt_line+10, len(lines))) - if "task_full=" in lines[i]), -1) + # Walk forward to find the very last post-transform for this stage + # (there may be multiple from QA remediation cycles) + while True: + next_transform = find_line(f"Stage {stage_num} post-transform", transform_line + 1) + # Stop if we hit a different stage's task prompt or end of file + next_stage_prompt = find_line(f"Stage {stage_num + 1} task prompt", transform_line + 1) + if next_transform == -1: + break + if next_stage_prompt != -1 and next_transform > next_stage_prompt: + break + transform_line = next_transform + transformed_full_line = -1 + content_key = "transformed_full" + if transform_line != -1: - transformed_full_line = next((i for i in range(transform_line, min(transform_line+10, len(lines))) - if "transformed_full=" in lines[i]), -1) - # Fallback to raw response if no post-transform entry (e.g., no transforms applied) + transformed_full_line = next( + ( + i + for i in range(transform_line, min(transform_line + 15, len(lines))) + if "transformed_full=" in lines[i] + ), + -1, + ) + + # Fallback to raw response if no post-transform entry if transformed_full_line == -1: response_line = find_line(f"Stage {stage_num} response", prompt_line) if response_line != -1: - transformed_full_line = next((i for i in range(response_line, min(response_line+10, len(lines))) - if "content_full=" in lines[i]), -1) + transformed_full_line = next( + ( + i + for i in range(response_line, min(response_line + 15, len(lines))) + if "content_full=" in lines[i] + ), + -1, + ) content_key = "content_full" else: continue - else: - content_key = "transformed_full" + if task_full_line == -1 or transformed_full_line == -1: continue + prompt = extract_content(task_full_line, "task_full") response = extract_content(transformed_full_line, content_key) + + retry_tag = " [RETRY]" if retried else "" with open(os.path.join(OUT, f"INPUT_{stage_num}.md"), "w") as f: f.write(prompt) with open(os.path.join(OUT, f"CP_RESPONSE_{stage_num}.md"), "w") as f: f.write(response) - print(f"Stage {stage_num}: INPUT={len(prompt)}B CP_RESPONSE={len(response)}B (source: {content_key})") \ No newline at end of file + print( + f"Stage {stage_num}{retry_tag}: INPUT={len(prompt)}B " + f"CP_RESPONSE={len(response)}B (source: {content_key})" + ) diff --git a/benchmarks/overall.html b/benchmarks/overall.html index c0c16b2..679e78a 100644 --- a/benchmarks/overall.html +++ b/benchmarks/overall.html @@ -163,6 +163,56 @@