From 134f7610b4dffa9057f2079d0a077d892edf5045 Mon Sep 17 00:00:00 2001 From: Eduardo Chiarotti Date: Mon, 10 Aug 2026 11:45:51 -0300 Subject: [PATCH 1/2] Complete evolve status CLI documentation and tests --- README.md | 3 ++- src/ai_agent/entrypoints/cli.py | 1 + tests/features/evolve/test_status.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 tests/features/evolve/test_status.py diff --git a/README.md b/README.md index cdbb65d..6e14f06 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,8 @@ ai-agent -c config/agent_config.yaml | Mode | Command | Notes | |---|---|---| -| Console | `uv run ai-agent -c config/agent_config.yaml` | Single agent | +| Console | `uv run ai-agent -c config/agent_config.yaml` | +| Evolve status | `uv run ai-agent evolve status` | Show the current evolve organism status. | Single agent | | Research operator | `uv run ai-agent -c config/agents/operator.yaml` | Interactive Research Desk | | One-shot brief | `uv run ai-agent brief "agent harness"` | Writes `briefs/YYYYMMDD_slug.md` | | Brief + approve | `uv run ai-agent brief "topic" --approve` | Console Y/n before write | diff --git a/src/ai_agent/entrypoints/cli.py b/src/ai_agent/entrypoints/cli.py index f2b42b2..446477a 100644 --- a/src/ai_agent/entrypoints/cli.py +++ b/src/ai_agent/entrypoints/cli.py @@ -29,6 +29,7 @@ from ai_agent.features.evolve.goals import enqueue_goal, list_goals from ai_agent.features.evolve.organism import ensure_organism, worker_loop, worker_tick from ai_agent.features.evolve.service import run_evolve, save_organism +from ai_agent.features.evolve.status import render_status from ai_agent.features.harness_bank.bank import ( admit_if_screened, list_cells, diff --git a/tests/features/evolve/test_status.py b/tests/features/evolve/test_status.py new file mode 100644 index 0000000..fb8fac4 --- /dev/null +++ b/tests/features/evolve/test_status.py @@ -0,0 +1,28 @@ +from ai_agent.features.evolve.status import render_status + + +def test_render_status_includes_organism_and_last_run_details() -> None: + state = { + "organism_id": "org-1", + "stopped": False, + "goals_queue": ["improve tests"], + "evolves_today": 1, + "max_evolves_today": 3, + "merges_today": 0, + "next_wake_at": "2026-08-10T12:00:00Z", + "last_run_id": "run-1", + "last_run": { + "status": "opened", + "pr_url": "https://github.com/example/repo/pull/1", + "intent": "improve tests", + }, + } + + rendered = render_status(state) + + assert "organism_id: org-1" in rendered + assert "goals_queue: ['improve tests']" in rendered + assert "evolves_today: 1/3" in rendered + assert "status: opened" in rendered + assert "pr_url: https://github.com/example/repo/pull/1" in rendered + assert "intent: improve tests" in rendered From 33106130a17a25d10d067770f003848e7d2d58ac Mon Sep 17 00:00:00 2001 From: Eduardo Chiarotti Date: Mon, 10 Aug 2026 11:55:15 -0300 Subject: [PATCH 2/2] Wire evolve status CLI and block stub publishes. Complete the status command end-to-end and reject CLI intents that only import helpers without calling them. --- README.md | 4 +- config/agents/engineer.yaml | 8 +- config/evolve_backlog.yaml | 29 ++++- src/ai_agent/entrypoints/cli.py | 30 +++-- src/ai_agent/features/evolve/service.py | 164 ++++++++++++++++++++---- src/ai_agent/features/evolve/status.py | 41 ++++++ src/ai_agent/harness/loop.py | 59 ++++++++- tests/features/evolve/test_evolve.py | 44 ++++++- tests/features/evolve/test_status.py | 46 ++++++- 9 files changed, 367 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 6e14f06..4c9c62f 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,8 @@ ai-agent -c config/agent_config.yaml | Mode | Command | Notes | |---|---|---| -| Console | `uv run ai-agent -c config/agent_config.yaml` | -| Evolve status | `uv run ai-agent evolve status` | Show the current evolve organism status. | Single agent | +| Console | `uv run ai-agent -c config/agent_config.yaml` | Single agent | +| Evolve status | `uv run ai-agent evolve status` | Organism queue, budgets, last run/PR | | Research operator | `uv run ai-agent -c config/agents/operator.yaml` | Interactive Research Desk | | One-shot brief | `uv run ai-agent brief "agent harness"` | Writes `briefs/YYYYMMDD_slug.md` | | Brief + approve | `uv run ai-agent brief "topic" --approve` | Console Y/n before write | diff --git a/config/agents/engineer.yaml b/config/agents/engineer.yaml index f5c8ba8..457cff0 100644 --- a/config/agents/engineer.yaml +++ b/config/agents/engineer.yaml @@ -6,8 +6,9 @@ system_prompt: > Workflow for each intent: 1. Survey with workspace_search (search, or read with start_line/end_line). Do not open `.ai_agent/**` for doc intents. - 2. Edit ONCE with replace_in_file (preferred for README bullets). After it - succeeds, NEVER call replace_in_file/write_file/apply_patch again. + 2. Implement the FULL intent with up to 3 related edits (helper + CLI + test + when asked). A stub helper alone is NOT done — keep editing until usable. + After the feature is complete, NEVER call edit tools again. 3. VERIFY immediately: git_diff (must be NON-EMPTY) then run_checks preset=pytest. 4. PUBLISH in SEPARATE rounds: git_commit alone, then open_pull_request alone. Commit/PR are auto-approved unless the operator enabled HITL (--approve). @@ -17,7 +18,8 @@ system_prompt: > Allowlist only: src/, tests/, config/, docs/, README/DECISIONS/AGENTS. Never edit path_policy, merge policy kernel, STOP, or .env. Never invent tool results. Never narrate-and-stop mid-pipeline. -max_tool_rounds: 16 + Survey with at most 2-3 searches, then edit — do not burn rounds on search. +max_tool_rounds: 24 personality: tone: pragmatic style: concise diff --git a/config/evolve_backlog.yaml b/config/evolve_backlog.yaml index d73b306..e98e21a 100644 --- a/config/evolve_backlog.yaml +++ b/config/evolve_backlog.yaml @@ -1,8 +1,23 @@ -# Rotating intents used when the organism goal queue is empty. -# Keep items small, allowlisted (src/tests/config/docs/README*), never kernel edits. +# Rotating intents when the organism goal queue is empty. +# Prefer concrete harness features over one-line doc tweaks. goals: - - "Add or improve one unit test under tests/ for MergePolicy day-budget behavior." - - "Clarify Phase 2 evolve-worker usage in README.md with one short paragraph." - - "Add a brief AGENTS.md note about evolve-goal add/list and the STOP kill switch." - - "Improve DECISIONS.md with one sentence on goal-queue vs free-form invent intents." - - "Add or tighten one docstring in src/ai_agent/features/evolve/organism.py without behavior changes." + - >- + Add `ai-agent evolve status` CLI: print organism id, stopped flag, goal queue, + evolves_today/max_evolves_per_day, merges_today, next_wake_at, last_run_id, + and if last run exists print its status/pr_url/intent from run.json. + Implement in src/ai_agent/entrypoints/cli.py (+ thin helper under + features/evolve if needed). Add a focused unit test. Update the Modes table + in README.md with one row for evolve status. Do not edit PathPolicy/STOP/.env. + - >- + Add `ai-agent ops summary` that reads OpsEvent JSONL and prints counts by + event name plus success rate and average latency_ms when present. Keep it + under src/ai_agent/harness/ops_metrics.py + CLI wiring + one test. Update + README Modes/ops row briefly. + - >- + Add an engineer tool `evolve_inspect` (read-only) that returns JSON for the + current organism + last EvolveRun summary under .ai_agent/evolve/. Register + it for the engineer agent YAML. Include a unit test. No writes, no git. + - >- + Fix the broken Agent harness capabilities markdown table in README.md + (the Evolve CLI prose currently splits the table) so all capability rows + render as one table again, without changing meaning. diff --git a/src/ai_agent/entrypoints/cli.py b/src/ai_agent/entrypoints/cli.py index 446477a..160e90c 100644 --- a/src/ai_agent/entrypoints/cli.py +++ b/src/ai_agent/entrypoints/cli.py @@ -29,7 +29,7 @@ from ai_agent.features.evolve.goals import enqueue_goal, list_goals from ai_agent.features.evolve.organism import ensure_organism, worker_loop, worker_tick from ai_agent.features.evolve.service import run_evolve, save_organism -from ai_agent.features.evolve.status import render_status +from ai_agent.features.evolve.status import build_status_state, render_status from ai_agent.features.harness_bank.bank import ( admit_if_screened, list_cells, @@ -383,6 +383,10 @@ def _run_evolve_goal(args: argparse.Namespace) -> None: raise ValueError("evolve-goal requires: add | list") +def _run_evolve_status() -> None: + print(render_status(build_status_state())) + + def _run_harness_bank(args: argparse.Namespace) -> None: action = args.command_arg if action == "list": @@ -630,16 +634,22 @@ def main(argv: list[str] | None = None) -> None: args.message = args.command_arg2 or args.topic or "" _run_harness_command(args) elif args.command == "evolve": - intent = args.topic or args.command_arg - if not intent: - raise ValueError('evolve requires an intent: ai-agent evolve "…"') - asyncio.run( - _run_evolve( - intent, - config_path=args.config, - require_approval=args.approve, + if args.command_arg == "status" and not args.topic: + _run_evolve_status() + else: + intent = args.topic or args.command_arg + if not intent: + raise ValueError( + 'evolve requires an intent: ai-agent evolve "…" ' + "(or: ai-agent evolve status)" + ) + asyncio.run( + _run_evolve( + intent, + config_path=args.config, + require_approval=args.approve, + ) ) - ) elif args.command == "evolve-worker": _run_evolve_worker( auto_merge=args.auto_merge, diff --git a/src/ai_agent/features/evolve/service.py b/src/ai_agent/features/evolve/service.py index 8343bba..6610d74 100644 --- a/src/ai_agent/features/evolve/service.py +++ b/src/ai_agent/features/evolve/service.py @@ -33,19 +33,21 @@ Interpret the intent carefully: - Doc/README intents → EDIT README.md (allowlisted). Never open `.ai_agent/**`. -- Make ONE meaningful edit that matches the intent (not a tiny noop). +- Feature intents must be COMPLETE before publish — not a stub helper alone. Strict pipeline — CALL TOOLS in order; do not narrate: -1. Survey: workspace_search action=search (or read with start_line/end_line). -2. Edit ONCE with replace_in_file (preferred). Do not call replace_in_file again - after it succeeds. -3. VERIFY: git_diff (must be NON-EMPTY) then run_checks preset=pytest. +1. Survey briefly: at most 2 workspace_search/workspace_list calls, then STOP surveying. +2. Implement the FULL intent with up to 3 related edits (write_file/replace_in_file). + Example for a new CLI: helper module + CLI wiring + one test (and README row if asked). + A helper file with no CLI/test is NOT done — keep editing until the feature is usable. +3. VERIFY: git_diff (must be NON-EMPTY and cover the real change) then run_checks preset=pytest. 4. PUBLISH: git_commit alone (feature branch), then open_pull_request alone. 5. Respond with the PR URL only after open_pull_request succeeds. If the requested text is already present and git_status is clean, respond that the intent is already satisfied (no PR). Never push to main. Never edit PathPolicy / MergePolicy / STOP / .env. +Never treat the repo homepage URL as a PR URL. """ @@ -140,10 +142,16 @@ def is_stopped(*, root: Path = DEFAULT_EVOLVE_ROOT) -> bool: return (root / "STOP").is_file() -def pipeline_progress(tool_results: list[dict[str, object]]) -> PipelineProgress: - edited = any( - item.get("tool_name") in EDIT_TOOLS and item.get("success") for item in tool_results +def pipeline_progress( + tool_results: list[dict[str, object]], + *, + min_edits: int = 1, +) -> PipelineProgress: + edit_count = sum( + 1 for item in tool_results if item.get("tool_name") in EDIT_TOOLS and item.get("success") ) + # Feature intents may need several related files before verify/publish. + edited = edit_count >= max(1, min_edits) diff_seen = False for item in tool_results: if item.get("tool_name") != "git_diff" or not item.get("success"): @@ -161,6 +169,9 @@ def pipeline_progress(tool_results: list[dict[str, object]]) -> PipelineProgress item.get("tool_name") == "open_pull_request" and item.get("success") for item in tool_results ) + # Once verify/publish starts, treat edit phase as complete. + if diff_seen or checks_ok or committed or pr_opened: + edited = True return PipelineProgress( edited=edited, diff_seen=diff_seen or committed or pr_opened, @@ -170,8 +181,74 @@ def pipeline_progress(tool_results: list[dict[str, object]]) -> PipelineProgress ) -def continue_prompt_for(progress: PipelineProgress) -> str: +def intent_min_edits(intent: str) -> int: + """Feature-style intents need more than a stub helper before publish.""" + text = intent.lower() + markers = ( + "cli", + "test", + "readme", + "wire", + "status", + "helper", + "unit test", + "modes table", + ) + if sum(1 for m in markers if m in text) >= 2: + return 2 + return 1 + + +def publish_block_reason( + intent: str, + *, + workspace: Path = Path("."), +) -> str | None: + """ + Block verify/publish when a CLI feature is still a stub. + + Detects unused ``render_status`` imports and missing ``evolve status`` wiring. + """ + text = intent.lower() + wants_cli = "cli" in text or ("evolve" in text and "status" in text) + if not wants_cli: + return None + + cli_path = workspace / "src" / "ai_agent" / "entrypoints" / "cli.py" + if not cli_path.is_file(): + return "cli.py missing — implement the command before publish" + body = cli_path.read_text(encoding="utf-8") + + if "render_status" in body and "render_status(" not in body: + return ( + "cli.py imports render_status but never calls it — " + "wire `ai-agent evolve status` before publish" + ) + if "status" in text and "_run_evolve_status" not in body and '== "status"' not in body: + return ( + "cli.py has no evolve status handler — " + "add `evolve status` command wiring before publish" + ) + if "test" in text: + tests_dir = workspace / "tests" / "features" / "evolve" + has_status_test = (tests_dir / "test_status.py").is_file() + if not has_status_test: + return "missing tests/features/evolve/test_status.py — add a test before publish" + return None + + +def continue_prompt_for( + progress: PipelineProgress, + *, + block_reason: str | None = None, +) -> str: """State-aware nudge so evolve does not re-edit forever.""" + if block_reason: + return ( + f"CONTINUE: publish blocked — {block_reason}. " + "Edit the missing wiring/tests with replace_in_file or write_file NOW. " + "Do not git_commit or open_pull_request yet." + ) action = progress.next_action if action == "done": return "PR already opened. Respond with the PR URL only." @@ -192,12 +269,14 @@ def continue_prompt_for(progress: PipelineProgress) -> str: ) if action == "git_diff": return ( - "CONTINUE: edit already succeeded. Call git_diff then run_checks. " - "Do NOT call replace_in_file/write_file/apply_patch again." + "CONTINUE: if the intent still needs CLI wiring or a test, edit those " + "files NOW (up to 3 total edits). Otherwise call git_diff then run_checks. " + "Do not open a PR for a stub helper alone." ) return ( - "CONTINUE: make the intent edit with replace_in_file ONCE, then git_diff, " - "run_checks, git_commit, open_pull_request. Do not narrate." + "CONTINUE: STOP surveying. Implement the FULL intent with write_file/" + "replace_in_file (helper + CLI + test if the intent asks). Then git_diff, " + "run_checks, git_commit, open_pull_request. Do not publish stubs." ) @@ -254,6 +333,7 @@ async def run_evolve( collected: list[dict[str, object]] = [] last_result: StepResult | None = None turns = max(1, max_continue_turns) + min_edits = intent_min_edits(cleaned) try: for turn in range(turns): @@ -265,8 +345,20 @@ async def run_evolve( emit_ops_event(name="evolve.stopped", run_id=rid, success=False) return run - progress = pipeline_progress(collected) - user_input = prompt if turn == 0 else continue_prompt_for(progress) + progress = pipeline_progress(collected, min_edits=min_edits) + block = None if progress.pr_opened else publish_block_reason(cleaned) + if block and progress.next_action != "edit": + # Force another edit cycle instead of verify/publish on stubs. + progress = PipelineProgress( + edited=False, + diff_seen=False, + checks_ok=False, + committed=False, + pr_opened=False, + ) + user_input = ( + prompt if turn == 0 else continue_prompt_for(progress, block_reason=block) + ) if turn > 0: status_by_next: dict[ str, @@ -291,7 +383,23 @@ async def run_evolve( result = await agent.step(session=session, user_input=user_input) last_result = result collected.extend(result.tool_results or []) - progress = pipeline_progress(collected) + progress = pipeline_progress(collected, min_edits=min_edits) + block_after = None if progress.pr_opened else publish_block_reason(cleaned) + if block_after and progress.next_action in { + "git_diff", + "run_checks", + "git_commit", + "open_pull_request", + "done", + }: + # Stub still incomplete — keep evolving even if model raced to verify. + progress = PipelineProgress( + edited=False, + diff_seen=False, + checks_ok=False, + committed=False, + pr_opened=False, + ) logger.info( "evolve_turn run_id=%s turn=%s kind=%s next=%s msg=%s", rid, @@ -316,9 +424,12 @@ async def run_evolve( raise RuntimeError(result.message) pr_url = _extract_pr_url(result.message, collected) - if pr_url or progress.pr_opened: + # Only finish when open_pull_request actually succeeded — never + # treat a random github.com link from README/survey text as a PR. + stub = publish_block_reason(cleaned) + if progress.pr_opened and pr_url and not stub: latency_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) - run.pr_url = pr_url or _extract_pr_url("", collected) + run.pr_url = pr_url run.status = "done" run.error = None run.last_check_log = _last_check_log(collected) @@ -355,7 +466,7 @@ async def run_evolve( assert last_result is not None latency_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) _write_result(artifact_dir, last_result, collected) - progress = pipeline_progress(collected) + progress = pipeline_progress(collected, min_edits=min_edits) if progress.committed: run.status = "awaiting_approval" run.error = "commit succeeded but open_pull_request did not run or failed" @@ -448,14 +559,15 @@ def _tool_succeeded(tool_results: list[dict[str, object]] | None, name: str) -> def _extract_pr_url(message: str, tool_results: list[dict[str, object]] | None) -> str | None: + """Accept only URLs from a successful open_pull_request tool result.""" + _ = message # never scrape free-form assistant text (README links false-positive) for item in tool_results or []: - if item.get("tool_name") == "open_pull_request" and item.get("success"): - out = str(item.get("output") or "") - if out.startswith("http"): - return out.strip() - match = re.search(r"https://github\.com/[^\s)]+", message) - if match: - return match.group(0) + if item.get("tool_name") != "open_pull_request" or not item.get("success"): + continue + out = str(item.get("output") or "").strip() + if "/pull/" in out and out.startswith("http"): + # First URL token only + return out.split()[0].rstrip("\"')].>") return None diff --git a/src/ai_agent/features/evolve/status.py b/src/ai_agent/features/evolve/status.py index e0cdd40..4594f3d 100644 --- a/src/ai_agent/features/evolve/status.py +++ b/src/ai_agent/features/evolve/status.py @@ -3,8 +3,11 @@ from __future__ import annotations from collections.abc import Mapping +from pathlib import Path from typing import Any +from ai_agent.features.evolve.service import DEFAULT_EVOLVE_ROOT, load_organism, load_run + def render_status(state: Mapping[str, Any]) -> str: """Render the public evolve status in a stable, human-readable format.""" @@ -27,3 +30,41 @@ def render_status(state: Mapping[str, Any]) -> str: ] ) return "\n".join(lines) + + +def build_status_state(*, root: Path = DEFAULT_EVOLVE_ROOT) -> dict[str, Any]: + """Load organism (+ optional last run) into the dict ``render_status`` expects.""" + organism = load_organism(root=root) + if organism is None: + return { + "organism_id": "", + "stopped": (root / "STOP").is_file(), + "goals_queue": [], + "evolves_today": 0, + "max_evolves_today": 0, + "merges_today": 0, + "next_wake_at": "", + "last_run_id": "", + } + + state: dict[str, Any] = { + "organism_id": organism.id, + "stopped": organism.stopped or (root / "STOP").is_file(), + "goals_queue": list(organism.goals), + "evolves_today": organism.evolves_today, + "max_evolves_today": organism.max_evolves_per_day, + "merges_today": organism.merges_today, + "next_wake_at": organism.next_wake_at or "", + "last_run_id": organism.last_run_id or "", + } + if organism.last_run_id: + try: + run = load_run(organism.last_run_id, root=root) + state["last_run"] = { + "status": run.status, + "pr_url": run.pr_url or "", + "intent": run.intent, + } + except FileNotFoundError: + pass + return state diff --git a/src/ai_agent/harness/loop.py b/src/ai_agent/harness/loop.py index bf808b9..5531615 100644 --- a/src/ai_agent/harness/loop.py +++ b/src/ai_agent/harness/loop.py @@ -176,19 +176,51 @@ async def run_tool_loop( edit_successes = sum( 1 for r in collected if r.tool_name in EDIT_TOOLS and r.success ) + survey_calls = sum( + 1 + for r in collected + if r.tool_name in {"workspace_search", "workspace_list"} + ) verified = any( r.tool_name in {"git_diff", "run_checks", "git_commit", "open_pull_request"} and r.success for r in collected ) if edit_successes >= 1 and not verified: + if edit_successes < 3: + observations.append( + json.dumps( + { + "harness_hint": ( + f"Edit progress {edit_successes}/3. If the intent " + "still needs CLI wiring, tests, or README, edit those " + "next. Do not publish a stub. When the feature is " + "complete: git_diff → run_checks → git_commit → " + "open_pull_request." + ) + } + ) + ) + else: + observations.append( + json.dumps( + { + "harness_hint": ( + "Edit budget reached (3 files). Do NOT edit again. " + "Next: git_diff, then run_checks, then git_commit, " + "then open_pull_request." + ) + } + ) + ) + if survey_calls >= 4 and edit_successes == 0: observations.append( json.dumps( { "harness_hint": ( - "Edit already succeeded. Do NOT call replace_in_file/" - "write_file/apply_patch again. Next: git_diff, then " - "run_checks, then git_commit, then open_pull_request." + "Survey budget exhausted. Do NOT call workspace_search " + "or workspace_list again. EDIT NOW with replace_in_file " + "or write_file to implement the intent." ) } ) @@ -215,10 +247,25 @@ async def run_tool_loop( tool_label = decision.tool_calls[0].name if len(decision.tool_calls) == 1 else "tools" state.add_message("tool", "\n".join(observations), tool_name=tool_label) - # Stop re-editing loops: two successful edits without verify/publish. - if edit_successes >= 2 and not verified: + # Stop survey death spirals before max_tool_rounds is wasted. + if survey_calls >= 8 and edit_successes == 0: + msg = ( + "Stopping survey loop: enough context gathered. " + "Next turn must edit with replace_in_file or write_file." + ) + state.add_message("assistant", msg) + logger.warning("survey_loop_breaker survey_calls=%s", survey_calls) + return StepResult( + message=msg, + kind="respond", + tool_results=[r.model_dump() for r in collected], + rounds_used=rounds, + ) + + # Stop re-editing loops after enough related files for one feature. + if edit_successes >= 4 and not verified: msg = ( - "Stopping edit loop: changes already applied. " + "Stopping edit loop: enough files changed for one PR. " "Next turn must git_diff → run_checks → git_commit → open_pull_request." ) state.add_message("assistant", msg) diff --git a/tests/features/evolve/test_evolve.py b/tests/features/evolve/test_evolve.py index 6185ee8..90b2e19 100644 --- a/tests/features/evolve/test_evolve.py +++ b/tests/features/evolve/test_evolve.py @@ -28,13 +28,23 @@ def test_save_load_run(tmp_path: Path) -> None: def test_pipeline_progress_and_continue_prompt() -> None: progress = pipeline_progress([]) assert progress.next_action == "edit" - assert "replace_in_file" in continue_prompt_for(progress) + assert "FULL intent" in continue_prompt_for(progress) progress = pipeline_progress( - [{"tool_name": "replace_in_file", "success": True, "output": "ok", "error": None}] + [{"tool_name": "replace_in_file", "success": True, "output": "ok", "error": None}], + min_edits=2, + ) + assert progress.next_action == "edit" + + progress = pipeline_progress( + [ + {"tool_name": "write_file", "success": True, "output": "ok", "error": None}, + {"tool_name": "replace_in_file", "success": True, "output": "ok", "error": None}, + ], + min_edits=2, ) assert progress.next_action == "git_diff" - assert "Do NOT call replace_in_file" in continue_prompt_for(progress) + assert "CLI wiring" in continue_prompt_for(progress) progress = pipeline_progress( [ @@ -47,6 +57,34 @@ def test_pipeline_progress_and_continue_prompt() -> None: assert "git_commit ONLY" in continue_prompt_for(progress) +def test_intent_min_edits() -> None: + from ai_agent.features.evolve.service import intent_min_edits + + assert intent_min_edits("fix typo in docs") == 1 + assert intent_min_edits("Add evolve status CLI + unit test helper") == 2 + + +def test_extract_pr_url_ignores_repo_homepage() -> None: + from ai_agent.features.evolve.service import _extract_pr_url + + fake = 'I learned so far from https://github.com/pythonbyte/ai-agent" README' + assert _extract_pr_url(fake, []) is None + assert ( + _extract_pr_url( + fake, + [ + { + "tool_name": "open_pull_request", + "success": True, + "output": "https://github.com/pythonbyte/ai-agent/pull/9", + "error": None, + } + ], + ) + == "https://github.com/pythonbyte/ai-agent/pull/9" + ) + + @pytest.mark.asyncio async def test_run_evolve_persists(tmp_path: Path, sample_config) -> None: sample_config.tools = ["calculator"] diff --git a/tests/features/evolve/test_status.py b/tests/features/evolve/test_status.py index fb8fac4..2a51eba 100644 --- a/tests/features/evolve/test_status.py +++ b/tests/features/evolve/test_status.py @@ -1,4 +1,8 @@ -from ai_agent.features.evolve.status import render_status +from pathlib import Path + +from ai_agent.domain.platform import EvolveOrganism, EvolveRun +from ai_agent.features.evolve.service import publish_block_reason, save_organism, save_run +from ai_agent.features.evolve.status import build_status_state, render_status def test_render_status_includes_organism_and_last_run_details() -> None: @@ -26,3 +30,43 @@ def test_render_status_includes_organism_and_last_run_details() -> None: assert "status: opened" in rendered assert "pr_url: https://github.com/example/repo/pull/1" in rendered assert "intent: improve tests" in rendered + + +def test_build_status_state_loads_organism(tmp_path: Path) -> None: + organism = EvolveOrganism( + id="organism_default", + goals=["ship"], + evolves_today=2, + max_evolves_per_day=5, + last_run_id="evolve_x", + ) + save_organism(organism, root=tmp_path) + save_run( + EvolveRun( + id="evolve_x", + intent="add status", + status="done", + pr_url="https://github.com/example/pull/2", + ), + root=tmp_path, + ) + state = build_status_state(root=tmp_path) + text = render_status(state) + assert "organism_id: organism_default" in text + assert "evolves_today: 2/5" in text + assert "pr_url: https://github.com/example/pull/2" in text + + +def test_publish_block_reason_catches_unused_import(tmp_path: Path) -> None: + cli = tmp_path / "src" / "ai_agent" / "entrypoints" / "cli.py" + cli.parent.mkdir(parents=True) + cli.write_text( + "from ai_agent.features.evolve.status import render_status\n", + encoding="utf-8", + ) + reason = publish_block_reason( + "Complete evolve status CLI and unit test", + workspace=tmp_path, + ) + assert reason is not None + assert "never calls" in reason