import shutil import subprocess import textwrap import time from pathlib import Path import pytest from automation import runner, workflow, summary as summary_module SUMMARY_TEMPLATE = """ # Summary — ## Decisions (ADR-style) - (none yet) ## Open Questions - (none yet) ## Awaiting Replies - (none yet) ## Action Items - (none yet) ## Votes (latest per participant) READY: 0 • CHANGES: 0 • REJECT: 0 - (no votes yet) ## Participants - (none yet) ## Timeline (most recent first) - : ## Links - Related PRs: – - Commits: – - Design/Plan: ../design/design.md """ @pytest.fixture() def temp_repo(tmp_path, monkeypatch): repo = tmp_path / "repo" repo.mkdir() run_git(repo, "init") run_git(repo, "config", "user.email", "dev@example.com") run_git(repo, "config", "user.name", "Dev") monkeypatch.chdir(repo) return repo def run_git(cwd: Path, *args: str) -> None: subprocess.run( ["git", *args], cwd=cwd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) def write_file(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(textwrap.dedent(content).strip() + "\n", encoding="utf-8") def test_parse_votes_reads_index_snapshot(temp_repo): repo = temp_repo discussion = repo / "Docs/features/demo/discussions/example.discussion.md" write_file( discussion, """ ## Thread """, ) run_git(repo, "add", ".") run_git(repo, "commit", "-m", "seed") # Stage a vote from Alice write_file( discussion, """ ## Thread Name: Alice Looks good. VOTE: READY """, ) run_git(repo, "add", discussion.relative_to(repo).as_posix()) # Add an unstaged vote from Bob (should be ignored) discussion.write_text( textwrap.dedent( """ ## Thread Name: Alice Looks good. VOTE: READY Name: Bob Still concerned. VOTE: REJECT """ ).strip() + "\n", encoding="utf-8", ) votes = workflow.parse_votes(Path("Docs/features/demo/discussions/example.discussion.md")) assert votes == {"Alice": "READY"} def test_parse_votes_supports_multiline_comments(temp_repo): repo = temp_repo discussion = repo / "Docs/features/demo/discussions/multiline.discussion.md" write_file( discussion, """ ## Thread """ ) run_git(repo, "add", ".") run_git(repo, "commit", "-m", "seed") write_file( discussion, """ ## Thread Name: Dana This spans multiple lines to ensure parsing keeps ownership. We should capture every sentence without needing extra prefixes. VOTE: READY """ ) run_git(repo, "add", discussion.relative_to(repo).as_posix()) votes = workflow.parse_votes(Path("Docs/features/demo/discussions/multiline.discussion.md")) assert votes == {"Dana": "READY"} def test_summarize_participants_tracks_latest_comment(): text = """ Name: Alice Initial thoughts on the rollout. VOTE: READY Name: Bob Adding telemetry requirements. VOTE: READY Name: Alice Needs revision if security scope expands? Consider adding an auth gateway. VOTE: CHANGES """ blocks = workflow.extract_comment_blocks(text) roster = workflow.summarize_participants(blocks) assert len(roster) == 2 alice = next(item for item in roster if item["name"] == "Alice") assert alice["vote"] == "CHANGES" assert "Needs revision if security scope expands?" in alice["last_comment"] def test_summary_sanitizes_garbled_participant_name(tmp_path): summary_path = tmp_path / "summary.md" summary_path.write_text(textwrap.dedent(SUMMARY_TEMPLATE).strip() + "\n", encoding="utf-8") summary_module.update_summary_file( summary_path, votes={"PySide6/QtWidgets/QComboBox.sip": "READY"}, action_items=[{ "participant": "PySide6/QtWidgets/QComboBox.sip", "action": "Review the combo box layout across breakpoints.", "status": "TODO", }], questions=[{ "participant": "PySide6/QtWidgets/QComboBox.sip", "question": "Will combo boxes resize gracefully?", "status": "OPEN", }], decisions=[{ "participant": "PySide6/QtWidgets/QComboBox.sip", "decision": "Adopt the shared combo box widget.", "rationale": "", "supporters": [], }], mentions=[{ "from": "PySide6/QtWidgets/QComboBox.sip", "to": "PySide6/QtWidgets/QComboBox.sip", "context": "@PySide6/QtWidgets/QComboBox.sip needs review", }], participants=[{ "name": "PySide6/QtWidgets/QComboBox.sip", "vote": "READY", "last_comment": "Ensuring combo boxes adapt to expansion.", }], ) content = summary_path.read_text(encoding="utf-8") assert "suggested by @QComboBox" in content assert "- QComboBox: READY" in content def test_check_promotion_threshold_requires_human_ready(): vote_groups = { "READY": ["AI_Helper"], "CHANGES": [], "REJECT": [], } result = workflow.check_promotion_threshold( vote_groups, "1_human", "all", "READY_FOR_TESTING", "REJECTED", ) assert result is None vote_groups["READY"].append("Dana") result = workflow.check_promotion_threshold( vote_groups, "1_human", "all", "READY_FOR_TESTING", "REJECTED", ) assert result == "READY_FOR_TESTING" def test_implementation_tasks_summary_and_promotion_guard(temp_repo): repo = temp_repo impl_disc = repo / "Docs/features/demo/discussions/implementation.discussion.md" impl_sum = repo / "Docs/features/demo/discussions/implementation.discussion.sum.md" write_file( impl_disc, """ --- type: implementation-discussion stage: implementation status: OPEN feature_id: FR_2025-11-02_test-feature created: 2025-11-02 promotion_rule: allow_agent_votes: true ready_min_eligible_votes: 1 reject_min_eligible_votes: all --- """ ) write_file( impl_sum, """ # Summary — Implementation {FeatureId} ## Tasks - (none yet) ## Votes (latest per participant) READY: 0 • CHANGES: 0 • REJECT: 0 - (no votes yet) ## Participants - (none yet) ## Decisions (ADR-style) - (none yet) ## Open Questions - (none yet) ## Awaiting Replies - (none yet) ## Action Items - (none yet) ## Timeline (most recent first) - 2025-11-02 00:00 Maintainer: Kickoff ## Links - Design/Plan: ../design/design.md """ ) run_git(repo, "add", ".") run_git(repo, "commit", "-m", "seed") write_file( impl_disc, """ --- type: implementation-discussion stage: implementation status: OPEN feature_id: FR_2025-11-02_test-feature created: 2025-11-02 promotion_rule: allow_agent_votes: true ready_min_eligible_votes: 1 reject_min_eligible_votes: all --- ## Tasks - [ ] Bootstrap services container [#456] @Dana - [x] Update deployment manifests 1a2b3c4 Name: Dana Implementation underway. VOTE: READY Name: AI_Helper Tracking automation state. VOTE: READY """ ) run_git(repo, "add", impl_disc.relative_to(repo).as_posix()) workflow._run_status() summary_after_first = impl_sum.read_text() assert "Progress: 1/2 complete (50%)" in summary_after_first assert "- [ ] Bootstrap services container" in summary_after_first assert "refs: PR #456" in summary_after_first assert "- [x] Update deployment manifests" in summary_after_first assert "refs: commit 1a2b3c4" in summary_after_first content_after_first = impl_disc.read_text() assert "status: OPEN" in content_after_first assert "Name: AI_Implementer" in content_after_first assert "Tracked tasks: 1/2 complete (50%)" in content_after_first assert "VOTE: CHANGES" in content_after_first tasks_file = repo / "Docs/features/demo/implementation/tasks.md" assert tasks_file.exists() tasks_content = tasks_file.read_text() assert "- [ ] Bootstrap services container [#456] @Dana" in tasks_content assert "- [x] Update deployment manifests 1a2b3c4" in tasks_content write_file( impl_disc, """ --- type: implementation-discussion stage: implementation status: OPEN feature_id: FR_2025-11-02_test-feature created: 2025-11-02 promotion_rule: allow_agent_votes: true ready_min_eligible_votes: 1 reject_min_eligible_votes: all --- ## Tasks - [x] Bootstrap services container [#456] @Dana - [x] Update deployment manifests 1a2b3c4 Name: Dana Implementation complete. VOTE: READY Name: AI_Helper Tracking automation state. VOTE: READY """ ) run_git(repo, "add", impl_disc.relative_to(repo).as_posix()) workflow._run_status() final_content = impl_disc.read_text() assert "status: READY_FOR_TESTING" in final_content assert "Tracked tasks: 2/2 complete (100%)" in final_content assert "VOTE: READY" in final_content summary_after_completion = impl_sum.read_text() assert "Progress: 2/2 complete (100%)" in summary_after_completion assert "refs: PR #456" in summary_after_completion assert "refs: commit 1a2b3c4" in summary_after_completion tasks_section = summary_after_completion.split("", 1)[1].split("", 1)[0] assert "- [ ]" not in tasks_section updated_tasks = tasks_file.read_text() assert updated_tasks.count("- [x]") == 2 def test_get_discussion_changes_returns_only_staged_lines(temp_repo): repo = temp_repo discussion = repo / "Docs/features/demo/discussions/sample.discussion.md" write_file( discussion, """ ## Discussion """, ) run_git(repo, "add", ".") run_git(repo, "commit", "-m", "base") write_file( discussion, """ ## Discussion Name: Alice Proposal incoming. VOTE: READY """, ) run_git(repo, "add", discussion.relative_to(repo).as_posix()) # Unstaged change should be ignored discussion.write_text( textwrap.dedent( """ ## Discussion Name: Alice Proposal incoming. VOTE: READY Name: Bob Needs changes. VOTE: CHANGES """ ).strip() + "\n", encoding="utf-8", ) additions = workflow.get_discussion_changes(Path("Docs/features/demo/discussions/sample.discussion.md")) assert "Alice" in additions assert "Bob" not in additions def test_get_discussion_changes_new_file_returns_full_content(temp_repo): repo = temp_repo discussion = repo / "Docs/features/new/discussions/brand-new.discussion.md" write_file( discussion, """ ## Kickoff Name: Maintainer Bootstrapping. VOTE: READY """, ) run_git(repo, "add", discussion.relative_to(repo).as_posix()) additions = workflow.get_discussion_changes(Path("Docs/features/new/discussions/brand-new.discussion.md")) assert "Bootstrapping" in additions assert "Maintainer" in additions def test_run_status_updates_summary_sections(temp_repo): repo = temp_repo discussion = repo / "Docs/features/demo/discussions/example.discussion.md" summary = repo / "Docs/features/demo/discussions/example.discussion.sum.md" write_file(discussion, """ ## Discussion """) write_file(summary, SUMMARY_TEMPLATE) run_git(repo, "add", ".") run_git(repo, "commit", "-m", "seed") write_file(discussion, """ ## Discussion Name: Alice Kickoff. VOTE: READY Name: Bob QUESTION: What is the rollout plan? VOTE: CHANGES Name: Bob TODO: Document rollout plan VOTE: READY Name: Carol DONE: Documented rollout plan VOTE: READY Name: Alice DECISION: Ship approach A VOTE: READY Name: Alice Thanks team! @bob @carol VOTE: READY """) run_git(repo, "add", discussion.relative_to(repo).as_posix()) workflow._run_status() content = summary.read_text(encoding="utf-8") assert "READY: 3 • CHANGES: 0 • REJECT: 0" in content assert "- Alice: READY" in content assert "- Bob: READY" in content assert "- Carol: READY" in content assert "## Open Questions" in content and "@Bob: What is the rollout plan?" in content assert "### TODO (unassigned):" in content and "Document rollout plan" in content assert "### Completed:" in content and "Documented rollout plan" in content assert "### Decision 1: Ship approach A" in content assert "### @bob" in content assert "@Alice: Kickoff." in content assert "## Participants" in content assert "Total: 3 (Humans: 3 • Agents: 0)" in content assert "- Alice — READY" in content assert "- Bob — READY" in content assert "- Carol — READY" in content assert "Last: Thanks team! @bob @carol" in content staged = subprocess.run( ["git", "diff", "--cached", "--name-only"], cwd=repo, check=True, capture_output=True, text=True, ).stdout.split() assert "Docs/features/demo/discussions/example.discussion.sum.md" in staged def test_run_status_promotes_feature_stage(temp_repo): repo = temp_repo discussion = repo / "Docs/features/demo/discussions/feature.discussion.md" write_file( discussion, """ --- type: feature-discussion stage: feature status: OPEN feature_id: DEMO-123 created: 2025-01-01 promotion_rule: allow_agent_votes: false ready_min_eligible_votes: 2 reject_min_eligible_votes: 1 --- Name: Alice Looks good. VOTE: READY --- Name: Bob Ship it. VOTE: READY """, ) run_git(repo, "add", discussion.relative_to(repo).as_posix()) workflow._run_status() content = discussion.read_text(encoding="utf-8") assert "status: READY_FOR_DESIGN" in content def test_run_status_promotes_design_stage(temp_repo): repo = temp_repo discussion = repo / "Docs/features/demo/discussions/design.discussion.md" write_file( discussion, """ --- type: design-discussion stage: design status: OPEN feature_id: DEMO-123 created: 2025-01-01 promotion_rule: allow_agent_votes: false ready_min_eligible_votes: 2 reject_min_eligible_votes: 1 --- Name: Alice Architecture is solid. VOTE: READY --- Name: Bob Agree with the approach. VOTE: READY """, ) run_git(repo, "add", discussion.relative_to(repo).as_posix()) workflow._run_status() content = discussion.read_text(encoding="utf-8") assert "status: READY_FOR_IMPLEMENTATION" in content def test_extract_structured_basic(): """Test lightweight pattern matching for discussion markers.""" text = """ # Discussion Title Name: Alice QUESTION: What about security considerations? VOTE: READY Name: Bob TODO: Review OAuth libraries for security vulnerabilities @Alice I'll handle the security review VOTE: READY Name: Carol DECISION: Use OAuth2 for third-party authentication VOTE: READY Name: Dave DONE: Completed initial research on OAuth2 providers VOTE: READY Name: Eve Question: Should we support social login providers? VOTE: CHANGES Name: Frank We should definitely support Google. What about GitHub? VOTE: READY Name: Grace ACTION: Create comparison matrix for OAuth providers ASSIGNED: OAuth provider comparison (@Grace taking this) VOTE: READY """ result = workflow.extract_structured_basic(text) # Check questions assert len(result["questions"]) == 3 question_texts = [q["question"] for q in result["questions"]] assert "What about security considerations?" in question_texts assert "Should we support social login providers?" in question_texts assert "We should definitely support Google. What about GitHub?" in question_texts # Check participants assert result["questions"][0]["participant"] == "Alice" assert result["questions"][1]["participant"] == "Eve" assert result["questions"][2]["participant"] == "Frank" # Check action items assert len(result["action_items"]) == 4 actions = result["action_items"] # TODO items (Bob's TODO and Grace's ACTION both become TODO) todo_items = [a for a in actions if a["status"] == "TODO"] assert len(todo_items) == 2 bob_todo = next(a for a in todo_items if a["participant"] == "Bob") assert "Review OAuth libraries" in bob_todo["action"] grace_action = next(a for a in todo_items if "comparison matrix" in a["action"]) assert grace_action["participant"] == "Grace" # DONE item done = next(a for a in actions if a["status"] == "DONE") assert "Completed initial research" in done["action"] assert done["participant"] == "Dave" assert done["completed_by"] == "Dave" # ASSIGNED item assigned = next(a for a in actions if a["status"] == "ASSIGNED") assert "OAuth provider comparison" in assigned["action"] assert assigned["participant"] == "Grace" assert assigned["assignee"] == "Grace" # Check decisions assert len(result["decisions"]) == 1 decision = result["decisions"][0] assert "Use OAuth2" in decision["decision"] assert decision["participant"] == "Carol" # Check mentions assert len(result["mentions"]) == 2 mention_targets = [m["to"] for m in result["mentions"]] assert "Alice" in mention_targets assert "Grace" in mention_targets # Check timeline assert result["timeline"] is not None assert result["timeline"]["participant"] == "Alice" assert len(result["timeline"]["summary"]) <= 120 def test_extract_structured_basic_handles_edge_cases(): """Test edge cases in pattern matching.""" text = """ Name: Alice This is just a comment without markers VOTE: READY Name: Bob TODO: VOTE: READY Name: Carol DECISION: VOTE: READY Name: Dave https://example.com/?param=value VOTE: CHANGES Name: Eve TODO: Valid action item here VOTE: READY """ result = workflow.extract_structured_basic(text) # Empty markers should be ignored assert len(result["action_items"]) == 1 assert "Valid action item" in result["action_items"][0]["action"] # Empty decision should be ignored assert len(result["decisions"]) == 0 # URL with ? should not be treated as question assert len(result["questions"]) == 0 # Timeline should capture first meaningful comment assert result["timeline"]["participant"] == "Alice" def test_extract_structured_basic_skips_headers(): """Test that markdown headers are skipped.""" text = """ # Main Header ## Sub Header - Alice: Q: Real question here? """ result = workflow.extract_structured_basic(text) # Should have one question, headers ignored assert len(result["questions"]) == 1 assert result["questions"][0]["question"] == "Real question here?" # Timeline should use Alice, not the headers assert result["timeline"]["participant"] == "Alice" def test_runner_invokes_participant_agent(temp_repo): repo = temp_repo project_root = Path(__file__).resolve().parent.parent # Set up a fake vendored SDK and a mock provider (repo / ".cascadingdev" / "cascadingdev" / "agent").mkdir(parents=True, exist_ok=True) shutil.copytree(project_root / "src" / "cascadingdev", repo / ".cascadingdev" / "cascadingdev", dirs_exist_ok=True) shutil.rmtree(repo / ".cascadingdev" / "cascadingdev" / "agent" / "__pycache__", ignore_errors=True) providers_stub = """ class ProviderClient: def __init__(self, repo_root, override=None): self.repo_root = repo_root def structured(self, prompt: str, model_hint: str = "") -> dict: return {"comment": "Moderator intro guidance.", "vote": "READY"} """ (repo / ".cascadingdev" / "cascadingdev" / "agent" / "providers.py").write_text(textwrap.dedent(providers_stub).strip() + "\n", encoding="utf-8") # Copy the real moderator agent agents_dir = repo / "agents" agents_dir.mkdir(parents=True, exist_ok=True) mod_src = project_root / "agents" / "moderator.py" (agents_dir / "moderator.py").write_text(mod_src.read_text(), encoding="utf-8") # Write the rules to invoke the agent rules_content = """ version: 1 file_associations: "feature.discussion.md": "feature_discussion_update" rules: feature_discussion_update: participants: - "agents/moderator.py" """ write_file(repo / ".ai-rules.yml", rules_content) discussion = repo / "Docs/features/demo/discussions/feature.discussion.md" write_file(discussion, "Name: Alice\nNo vote here.") run_git(repo, "add", ".") # Run the runner from automation.config import RulesConfig from automation.patcher import ModelConfig rules = RulesConfig.load(repo) model = ModelConfig.from_sources(repo) runner.process(repo, rules, model) # Check that the moderator added its comment updated = discussion.read_text(encoding="utf-8") assert "Moderator intro guidance." in updated assert "VOTE: READY" in updated def test_moderator_agent_posts_each_commit(temp_repo): repo = temp_repo project_root = Path(__file__).resolve().parent.parent # Fake vendored SDK with deterministic provider (repo / ".cascadingdev" / "cascadingdev" / "agent").mkdir(parents=True, exist_ok=True) shutil.copytree(project_root / "src" / "cascadingdev", repo / ".cascadingdev" / "cascadingdev", dirs_exist_ok=True) shutil.rmtree(repo / ".cascadingdev" / "cascadingdev" / "agent" / "__pycache__", ignore_errors=True) providers_stub = """ class ProviderClient: def __init__(self, repo_root, override=None): self.repo_root = repo_root def structured(self, prompt: str, model_hint: str = "") -> dict: return {"comment": "One-time moderator reply.", "vote": "READY"} """ (repo / ".cascadingdev" / "cascadingdev" / "agent" / "providers.py").write_text( textwrap.dedent(providers_stub).strip() + "\n", encoding="utf-8", ) agents_dir = repo / "agents" agents_dir.mkdir(parents=True, exist_ok=True) mod_src = project_root / "agents" / "moderator.py" (agents_dir / "moderator.py").write_text(mod_src.read_text(), encoding="utf-8") rules_content = """ version: 1 file_associations: "feature.discussion.md": "feature_discussion_update" rules: feature_discussion_update: participants: - "agents/moderator.py" """ write_file(repo / ".ai-rules.yml", rules_content) discussion = repo / "Docs/features/demo/discussions/feature.discussion.md" write_file(discussion, "Name: Alex\nSummary incoming soon.") run_git(repo, "add", ".") from automation.config import RulesConfig from automation.patcher import ModelConfig rules = RulesConfig.load(repo) model = ModelConfig.from_sources(repo) runner.process(repo, rules, model) first_pass = discussion.read_text(encoding="utf-8") assert first_pass.count("Name: AI_Moderator") == 1 # Stage and run again; moderator should contribute a fresh comment. run_git(repo, "add", ".") runner.process(repo, rules, model) second_pass = discussion.read_text(encoding="utf-8") assert second_pass.count("Name: AI_Moderator") == 2 def test_visualizer_generates_diagram(temp_repo): repo = temp_repo project_root = Path(__file__).resolve().parent.parent # Minimal AI config (repo / "config").mkdir(parents=True, exist_ok=True) (repo / "config" / "ai.yml").write_text("version: 1\nrunner:\n command_chain:\n - echo\n") # Copy the real visualizer agent agents_dir = repo / "agents" agents_dir.mkdir(parents=True, exist_ok=True) vis_src = project_root / "agents" / "visualizer.py" (agents_dir / "visualizer.py").write_text(vis_src.read_text(), encoding="utf-8") # Write rules to invoke the visualizer rules_content = """ version: 1 file_associations: "design.discussion.md": "design_discussion_update" rules: design_discussion_update: participants: - "agents/visualizer.py" """ write_file(repo / ".ai-rules.yml", rules_content) discussion = repo / "Docs/features/demo/discussions/design.discussion.md" write_file(discussion, "Name: Dana\n@AI_visual please sketch the login flow.\nVOTE: READY") run_git(repo, "add", ".") # Run the runner from automation.config import RulesConfig from automation.patcher import ModelConfig rules = RulesConfig.load(repo) model = ModelConfig.from_sources(repo) runner.process(repo, rules, model) # Check that a diagram was created diagram_dir = repo / "Docs/features/demo" / "diagrams" generated = sorted(diagram_dir.glob("diagram_*.puml")) assert generated, "diagram not generated" assert "please sketch the login flow." in generated[0].read_text(encoding="utf-8") # Check that a comment was posted updated = discussion.read_text(encoding="utf-8") assert "Name: AI_Visualizer" in updated assert generated[0].relative_to(repo).as_posix() in updated start = updated.index("") end = updated.index("", start) agent_block = updated[start:end] assert "VOTE:" not in agent_block def test_researcher_handles_request(temp_repo): repo = temp_repo project_root = Path(__file__).resolve().parent.parent # Fake vendored SDK with deterministic provider (repo / ".cascadingdev" / "cascadingdev" / "agent").mkdir(parents=True, exist_ok=True) shutil.copytree(project_root / "src" / "cascadingdev", repo / ".cascadingdev" / "cascadingdev", dirs_exist_ok=True) shutil.rmtree(repo / ".cascadingdev" / "cascadingdev" / "agent" / "__pycache__", ignore_errors=True) providers_stub = """ class ProviderClient: def __init__(self, repo_root, override=None): self.repo_root = repo_root def structured(self, prompt: str, model_hint: str = "") -> dict: return { "summary": "- Found two maintained libraries for terminal UIs.\\n- Rich has the broadest widget support.", "sources": [ {"title": "Textual", "url": "https://github.com/Textualize/textual", "insight": "Active project for rich TUI apps."}, {"title": "Rich", "url": "https://github.com/Textualize/rich", "insight": "Provides underlying rendering primitives."} ] } """ (repo / ".cascadingdev" / "cascadingdev" / "agent" / "providers.py").write_text( textwrap.dedent(providers_stub).strip() + "\n", encoding="utf-8", ) agents_dir = repo / "agents" agents_dir.mkdir(parents=True, exist_ok=True) researcher_src = project_root / "agents" / "researcher.py" (agents_dir / "researcher.py").write_text(researcher_src.read_text(), encoding="utf-8") rules_content = """ version: 1 file_associations: "design.discussion.md": "design_discussion_update" rules: design_discussion_update: participants: - path: "agents/researcher.py" background: true """ write_file(repo / ".ai-rules.yml", rules_content) discussion = repo / "Docs/features/demo/discussions/design.discussion.md" write_file( discussion, "Name: Casey\n@AI-researcher: find python libraries for interactive terminal UIs.\nVOTE: READY", ) run_git(repo, "add", ".") from automation.config import RulesConfig from automation.patcher import ModelConfig rules = RulesConfig.load(repo) model = ModelConfig.from_sources(repo) runner.process(repo, rules, model) # Poll for asynchronous completion deadline = time.time() + 2.0 while time.time() < deadline: text = discussion.read_text(encoding="utf-8") if "AI_Researcher" in text: break time.sleep(0.05) else: raise AssertionError("Researcher agent did not respond in time") updated = discussion.read_text(encoding="utf-8") assert "### Research Findings" in updated assert "Textual" in updated assert "Rich" in updated start = updated.index("") end = updated.index("", start) agent_block = updated[start:end] assert "VOTE:" not in agent_block def test_background_participant_runs_async(temp_repo): repo = temp_repo agents_dir = repo / "agents" agents_dir.mkdir(parents=True, exist_ok=True) background_agent = agents_dir / "background.py" background_agent.write_text( textwrap.dedent( """ #!/usr/bin/env python3 import argparse import time from pathlib import Path def main(): parser = argparse.ArgumentParser() parser.add_argument("--repo-root", required=True) parser.add_argument("--path", required=True) args = parser.parse_args() repo_root = Path(args.repo_root) discussion = (repo_root / args.path).resolve() time.sleep(0.2) text = discussion.read_text(encoding="utf-8") if not text.endswith("\\n"): text += "\\n" text += "Name: AI_Background\\nCompleted async job.\\nVOTE: READY\\n" discussion.write_text(text, encoding="utf-8") if __name__ == "__main__": main() """ ).strip() + "\n", encoding="utf-8", ) rules_content = """ version: 1 file_associations: "feature.discussion.md": "feature_discussion_update" rules: feature_discussion_update: participants: - path: "agents/background.py" background: true """ write_file(repo / ".ai-rules.yml", rules_content) discussion = repo / "Docs/features/demo/discussions/feature.discussion.md" write_file(discussion, "Name: Casey\nRequesting async support.\nVOTE: READY") run_git(repo, "add", ".") from automation.config import RulesConfig from automation.patcher import ModelConfig rules = RulesConfig.load(repo) model = ModelConfig.from_sources(repo) start = time.time() runner.process(repo, rules, model) duration = time.time() - start # Runner should return before the agent finishes sleeping for 0.2s assert duration < 0.15 # Wait for background agent to finish its work deadline = time.time() + 2.0 while time.time() < deadline: text = discussion.read_text(encoding="utf-8") if "AI_Background" in text: break time.sleep(0.05) else: # pragma: no cover - defensive raise AssertionError("Background participant did not complete in time") assert "Completed async job." in discussion.read_text(encoding="utf-8")