CascadingDev/tests/test_workflow.py

1120 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 — <Stage Title>
<!-- SUMMARY:DECISIONS START -->
## Decisions (ADR-style)
- (none yet)
<!-- SUMMARY:DECISIONS END -->
<!-- SUMMARY:OPEN_QUESTIONS START -->
## Open Questions
- (none yet)
<!-- SUMMARY:OPEN_QUESTIONS END -->
<!-- SUMMARY:AWAITING START -->
## Awaiting Replies
- (none yet)
<!-- SUMMARY:AWAITING END -->
<!-- SUMMARY:ACTION_ITEMS START -->
## Action Items
- (none yet)
<!-- SUMMARY:ACTION_ITEMS END -->
<!-- SUMMARY:VOTES START -->
## Votes (latest per participant)
READY: 0 • CHANGES: 0 • REJECT: 0
- (no votes yet)
<!-- SUMMARY:VOTES END -->
<!-- SUMMARY:PARTICIPANTS START -->
## Participants
- (none yet)
<!-- SUMMARY:PARTICIPANTS END -->
<!-- SUMMARY:TIMELINE START -->
## Timeline (most recent first)
- <YYYY-MM-DD HH:MM> <name>: <one-liner>
<!-- SUMMARY:TIMELINE END -->
<!-- SUMMARY:LINKS START -->
## Links
- Related PRs:
- Commits:
- Design/Plan: ../design/design.md
<!-- SUMMARY:LINKS END -->
"""
@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,
"""
<!--META
{
"kind": "discussion_summary",
"tokens": ["FeatureId", "CreatedDate"]
}
-->
# Summary — Implementation {FeatureId}
<!-- SUMMARY:TASKS START -->
## Tasks
- (none yet)
<!-- SUMMARY:TASKS END -->
<!-- SUMMARY:VOTES START -->
## Votes (latest per participant)
READY: 0 • CHANGES: 0 • REJECT: 0
- (no votes yet)
<!-- SUMMARY:VOTES END -->
<!-- SUMMARY:PARTICIPANTS START -->
## Participants
- (none yet)
<!-- SUMMARY:PARTICIPANTS END -->
<!-- SUMMARY:DECISIONS START -->
## Decisions (ADR-style)
- (none yet)
<!-- SUMMARY:DECISIONS END -->
<!-- SUMMARY:OPEN_QUESTIONS START -->
## Open Questions
- (none yet)
<!-- SUMMARY:OPEN_QUESTIONS END -->
<!-- SUMMARY:AWAITING START -->
## Awaiting Replies
- (none yet)
<!-- SUMMARY:AWAITING END -->
<!-- SUMMARY:ACTION_ITEMS START -->
## Action Items
- (none yet)
<!-- SUMMARY:ACTION_ITEMS END -->
<!-- SUMMARY:TIMELINE START -->
## Timeline (most recent first)
- 2025-11-02 00:00 Maintainer: Kickoff
<!-- SUMMARY:TIMELINE END -->
<!-- SUMMARY:LINKS START -->
## Links
- Design/Plan: ../design/design.md
<!-- SUMMARY:LINKS END -->
"""
)
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("<!-- SUMMARY:TASKS START -->", 1)[1].split("<!-- SUMMARY:TASKS END -->", 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("<!-- AUTO:VISUALIZER START -->")
end = updated.index("<!-- AUTO:VISUALIZER END -->", 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("<!-- AUTO:RESEARCHER START -->")
end = updated.index("<!-- AUTO:RESEARCHER END -->", 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")