from __future__ import annotations import asyncio from unittest.mock import AsyncMock, patch from app.services.autonomous_cdsl_generation import ( AutonomousCdslGenerationRunner, parse_modeling_plan, ) from app.services.storage import WorkspaceStore from pathlib import Path from app.settings import ProviderConfig, ProviderModel, Settings def _plan(): return parse_modeling_plan( """ [STEP step_3] goal: 两端夹紧槽 relationship: 左右槽属于同一夹紧结构,但分别作用于左右轴套 [ACTION left_clamp_slot] step: step_3 title: 左轴套夹紧槽 target: left_hub operation: extrude_cut_blind [ACTION right_clamp_slot] step: step_3 title: 右轴套夹紧槽 target: right_hub operation: extrude_cut_blind depends_on: left_clamp_slot """, checklist=["两端夹紧槽"], runtime_atomic_ids={"extrude_cut_blind"}, ) def test_related_targets_stay_in_one_step_with_independent_actions(): plan = _plan() assert [item["action_id"] for item in plan["steps"][0]["actions"]] == [ "left_clamp_slot", "right_clamp_slot", ] assert plan["steps"][0]["relationship"] == "左右槽属于同一夹紧结构,但分别作用于左右轴套" assert plan["actions"][1]["depends_on"] == ["left_clamp_slot"] def test_soft_action_headings_are_indexed_without_forcing_machine_fields(): plan = parse_modeling_plan( "## Step 3 - 两端夹紧槽\n### Action 3a - 左槽\n切左槽\n### Action 3b - 右槽\n切右槽", checklist=["两端夹紧槽"], runtime_atomic_ids={"extrude_cut_blind"}, ) assert [item["title"] for item in plan["steps"][0]["actions"]] == ["左槽", "右槽"] assert plan["steps"][0]["actions"][1]["depends_on"] == ["step_1_action_1"] def test_action_progress_advances_within_step_before_next_step(tmp_path): provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) settings = Settings(task_root=tmp_path / "tasks", conversation_root=tmp_path / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) store = WorkspaceStore(settings) runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock()) state = { "modeling_plan_enforced": True, "modeling_plan_status": "approved", "modeling_plan": _plan(), "active_plan_step_id": "step_3", "active_plan_action_id": "left_clamp_slot", "plan_action_status": {"left_clamp_slot": "pending", "right_clamp_slot": "pending"}, "plan_feature_status": {}, "plan_step_status": {"step_3": "pending"}, } runner._mark_plan_progress(state, { "plan_step_id": "step_3", "plan_action_id": "left_clamp_slot", "atomic_ids": ["extrude_cut_blind"], }) assert state["active_plan_step_id"] == "step_3" assert state["active_plan_action_id"] == "right_clamp_slot" assert state["plan_action_status"]["left_clamp_slot"] == "complete" assert state["plan_action_status"]["right_clamp_slot"] == "pending" assert state["plan_step_status"]["step_3"] == "pending" def test_action_operation_mismatch_is_explicit(): provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) settings = Settings(task_root=Path("/tmp/cdsl-cad-plan-action-test/tasks"), conversation_root=Path("/tmp/cdsl-cad-plan-action-test/conversations"), library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) store = WorkspaceStore(settings) runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock()) state = { "modeling_plan_enforced": True, "modeling_plan_status": "approved", "modeling_plan": _plan(), "active_plan_step_id": "step_3", "active_plan_action_id": "left_clamp_slot", "plan_action_status": {"left_clamp_slot": "pending", "right_clamp_slot": "pending"}, } action = runner._plan_current_action(state) assert action is not None assert action["operation"] == "extrude_cut_blind" def test_enforced_submit_schema_exposes_action_id(): from app.services.autonomous_cdsl_generation import _operation_contract_payload from app.services.cdsl_authoring_schema import operation_contract_hash from app.services.engine_service import load_engine provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) root = Path("/tmp/cdsl-cad-plan-action-schema") settings = Settings(task_root=root / "tasks", conversation_root=root / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) store = WorkspaceStore(settings) task = store.ensure_task(None, "schema") contract = _operation_contract_payload(load_engine(settings), "extrude_cut_blind") state = {"modeling_plan_enforced": True, "modeling_plan_mode": "indexed", "active_operation_contract": contract, "pending_operation_revision": "", "pending_operation_contract_hash": operation_contract_hash("extrude_cut_blind", contract["canonical_fragment_schema"])} tool = AutonomousCdslGenerationRunner(settings, store, AsyncMock())._canonical_submit_tool(task, state) assert tool is not None assert "plan_action_id" in tool["function"]["parameters"]["properties"] assert "plan_action_id" in tool["function"]["parameters"]["required"] def test_multiple_outer_profiles_request_plan_action_split_without_schema_loop(tmp_path): from app.services.engine_service import load_engine provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) settings = Settings(task_root=tmp_path / "tasks", conversation_root=tmp_path / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) store = WorkspaceStore(settings) task = store.ensure_task(None, "left and right slots") task_id = str(task["task_id"]) store.write_requirements_document(task_id, "# Frozen\nCreate left and right slots.") runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock()) engine = load_engine(settings) plan = parse_modeling_plan( "## Step 1 - Slots\n### Action 1a - Left slot\n### Action 1b - Right slot", checklist=["left and right slots"], runtime_atomic_ids={"extrude_cut_blind"}, ) state = { "modeling_plan_enforced": True, "modeling_plan_status": "approved", "modeling_plan": plan, "active_plan_step_id": "step_1", "active_plan_action_id": "step_1_action_1", "plan_step_status": {"step_1": "pending"}, "plan_action_status": {"step_1_action_1": "pending", "step_1_action_2": "pending"}, "candidate_attempts_by_head": {}, } asyncio.run(runner._execute_tool( task_id, "left and right slots", state, engine, "get_cdsl_operation_contract", {"atomic_id": "extrude_cut_blind"}, )) fragment = { "sketch": { "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, "profile": { "type": "analytic_contours", "contours": [ {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [-10, 0], "radius_mm": 2}]}, {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [10, 0], "radius_mm": 2}]}, ], }, }, "feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 5}}, } events, progressed = asyncio.run(runner._execute_tool( task_id, "left and right slots", state, engine, "submit_cdsl_fragment", { "plan_step_id": "step_1", "plan_action_id": "step_1_action_1", "batch_goal": "Create both slots.", "fragment": fragment, }, )) assert progressed is False assert events[0][1]["diagnostic"]["code"] == "CDSL_PROFILE_MULTIPLE_OUTERS" assert state["modeling_plan_status"] == "revise" assert state["candidate_action_required"]["reason"] == "plan_action_split_required" assert state["format_correction"] == {} assert not state.get("schema_retry_counts") assert (store.read_task(task_id) or {}).get("active_revision") == "" def test_reviewer_action_count_can_require_plan_revision_without_server_semantic_rules(tmp_path): from app.services.engine_service import load_engine provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) settings = Settings(task_root=tmp_path / "tasks", conversation_root=tmp_path / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) store = WorkspaceStore(settings) task = store.ensure_task(None, "left and right slots") task_id = str(task["task_id"]) store.write_requirements_document(task_id, "# Frozen\nCreate left and right slots.") store.write_completion_checklist(task_id, "- [ ] left and right slots") runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock()) state = { "modeling_plan_enforced": True, "modeling_plan_status": "missing", "modeling_plan_review_attempts": 0, } reviewer_revise = { "schema_version": "cad.modeling-plan-review.v1", "verdict": "revise", "confidence": 0.9, "issues": [{"type": "action_granularity", "step_id": "step_1", "message": "The two independently located slots need separate actions."}], "coverage": [{"requirement": "left and right slots", "step_id": "step_1", "status": "covered", "evidence": "The step names both slots."}], "step_checks": [{"step_id": "step_1", "status": "pass", "notes": "The related slots remain together."}], "action_checks": [{"step_id": "step_1", "status": "fail", "notes": "The plan has one action but requires two.", "required_action_count": 2}], } with patch("app.services.autonomous_cdsl_generation.review_modeling_plan", AsyncMock(return_value=reviewer_revise)): events, progressed = asyncio.run(runner._execute_tool( task_id, "left and right slots", state, load_engine(settings), "write_modeling_plan", {"plan_text": "## Step 1 - Clamp slots\nCreate the left slot and right slot."}, )) assert progressed is True assert state["modeling_plan_status"] == "revise" assert "action_granularity_issues" not in state["modeling_plan"] review_event = next(payload for name, payload in events if name == "modeling_plan_review") assert review_event["review"]["verdict"] == "revise" assert review_event["review"]["action_checks"][0]["status"] == "fail" assert [item["function"]["name"] for item in runner._author_tools(store.read_task(task_id) or {}, requirements_frozen=True, state=state)] == ["write_modeling_plan"]