|
| 1 | +"""Tests for the live agent's project-context loader (aai_cli.agent_cascade.project_context). |
| 2 | +
|
| 3 | +`assembly live` reads the launch directory's AGENTS.md/CLAUDE.md into its system prompt so a |
| 4 | +spoken answer is grounded in the project it's run from — the same convention coding agents follow. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import types |
| 10 | + |
| 11 | +from aai_cli.agent_cascade import project_context |
| 12 | +from aai_cli.app.context import AppState |
| 13 | +from aai_cli.commands.agent_cascade import _exec |
| 14 | +from aai_cli.commands.agent_cascade._exec import run_agent_cascade |
| 15 | +from aai_cli.core import config |
| 16 | +from tests.test_agent_cascade_command import _opts |
| 17 | + |
| 18 | + |
| 19 | +def test_returns_none_when_no_instruction_files(tmp_path): |
| 20 | + # An empty directory has nothing to inject, so the prompt stays the plain persona. |
| 21 | + assert project_context.load_project_context(tmp_path) is None |
| 22 | + |
| 23 | + |
| 24 | +def test_reads_agents_md_under_a_heading(tmp_path): |
| 25 | + (tmp_path / "AGENTS.md").write_text("Use uv run for everything.", encoding="utf-8") |
| 26 | + loaded = project_context.load_project_context(tmp_path) |
| 27 | + # The content is included verbatim under a per-file heading naming its source. |
| 28 | + assert loaded == "# AGENTS.md\n\nUse uv run for everything." |
| 29 | + |
| 30 | + |
| 31 | +def test_reads_claude_md_when_agents_md_absent(tmp_path): |
| 32 | + (tmp_path / "CLAUDE.md").write_text("Project rules here.", encoding="utf-8") |
| 33 | + loaded = project_context.load_project_context(tmp_path) |
| 34 | + assert loaded == "# CLAUDE.md\n\nProject rules here." |
| 35 | + |
| 36 | + |
| 37 | +def test_includes_both_files_in_precedence_order_when_they_differ(tmp_path): |
| 38 | + (tmp_path / "AGENTS.md").write_text("Agents rules.", encoding="utf-8") |
| 39 | + (tmp_path / "CLAUDE.md").write_text("Claude rules.", encoding="utf-8") |
| 40 | + loaded = project_context.load_project_context(tmp_path) |
| 41 | + # Both distinct files are present, AGENTS.md first (its precedence), then CLAUDE.md. |
| 42 | + assert loaded == "# AGENTS.md\n\nAgents rules.\n\n# CLAUDE.md\n\nClaude rules." |
| 43 | + |
| 44 | + |
| 45 | +def test_identical_content_is_included_once(tmp_path): |
| 46 | + # CLAUDE.md is commonly a symlink to AGENTS.md (as in this repo); identical content must not |
| 47 | + # be duplicated into the prompt. We assert the dedup on content, so it covers the symlink case |
| 48 | + # without depending on symlink support being available on the test platform. |
| 49 | + (tmp_path / "AGENTS.md").write_text("Same guidance.", encoding="utf-8") |
| 50 | + (tmp_path / "CLAUDE.md").write_text("Same guidance.", encoding="utf-8") |
| 51 | + loaded = project_context.load_project_context(tmp_path) |
| 52 | + assert loaded == "# AGENTS.md\n\nSame guidance." |
| 53 | + assert loaded.count("Same guidance.") == 1 |
| 54 | + |
| 55 | + |
| 56 | +def test_whitespace_only_file_is_skipped(tmp_path): |
| 57 | + # A blank instruction file carries no guidance, so it's treated as absent (None, not an |
| 58 | + # empty heading) — the stripped-empty branch. |
| 59 | + (tmp_path / "AGENTS.md").write_text(" \n\t\n", encoding="utf-8") |
| 60 | + assert project_context.load_project_context(tmp_path) is None |
| 61 | + |
| 62 | + |
| 63 | +def test_oversized_content_is_truncated_to_the_budget(tmp_path): |
| 64 | + body = "x" * (project_context.MAX_CONTEXT_CHARS + 5000) |
| 65 | + (tmp_path / "AGENTS.md").write_text(body, encoding="utf-8") |
| 66 | + loaded = project_context.load_project_context(tmp_path) |
| 67 | + assert loaded is not None |
| 68 | + # The marker is counted against the budget, so the total never exceeds the cap — it's a true |
| 69 | + # upper bound, not a target the marker overshoots. |
| 70 | + assert loaded.endswith("[project context truncated]") |
| 71 | + assert len(loaded) == project_context.MAX_CONTEXT_CHARS |
| 72 | + assert len(loaded) < len(body) |
| 73 | + |
| 74 | + |
| 75 | +def test_content_at_the_budget_is_left_whole(tmp_path): |
| 76 | + # A file exactly at the cap is included untruncated (the boundary is inclusive). |
| 77 | + # Account for the "# AGENTS.md\n\n" heading so the combined string lands exactly at the cap. |
| 78 | + heading = "# AGENTS.md\n\n" |
| 79 | + body = "y" * (project_context.MAX_CONTEXT_CHARS - len(heading)) |
| 80 | + (tmp_path / "AGENTS.md").write_text(body, encoding="utf-8") |
| 81 | + loaded = project_context.load_project_context(tmp_path) |
| 82 | + assert loaded is not None |
| 83 | + assert "truncated" not in loaded |
| 84 | + assert len(loaded) == project_context.MAX_CONTEXT_CHARS |
| 85 | + |
| 86 | + |
| 87 | +def test_defaults_to_the_current_working_directory(tmp_path, monkeypatch): |
| 88 | + (tmp_path / "AGENTS.md").write_text("cwd guidance", encoding="utf-8") |
| 89 | + monkeypatch.chdir(tmp_path) |
| 90 | + # No directory argument -> reads cwd, so the live command picks up the project it's launched in. |
| 91 | + assert project_context.load_project_context() == "# AGENTS.md\n\ncwd guidance" |
| 92 | + |
| 93 | + |
| 94 | +def test_missing_directory_reads_as_no_context(tmp_path): |
| 95 | + # A nonexistent base directory raises OSError per candidate, which is swallowed -> None. |
| 96 | + assert project_context.load_project_context(tmp_path / "does-not-exist") is None |
| 97 | + |
| 98 | + |
| 99 | +def test_context_filenames_order(): |
| 100 | + # AGENTS.md (the cross-agent standard) takes precedence over CLAUDE.md. |
| 101 | + assert project_context.CONTEXT_FILENAMES == ("AGENTS.md", "CLAUDE.md") |
| 102 | + |
| 103 | + |
| 104 | +# --- command wiring: run_agent_cascade reads the loader into the config ------ |
| 105 | + |
| 106 | + |
| 107 | +def test_run_reads_project_context_into_config(monkeypatch): |
| 108 | + monkeypatch.setattr(_exec.tts_session, "require_available", lambda _c: None) |
| 109 | + monkeypatch.setattr(config, "resolve_api_key", lambda **_: "k") |
| 110 | + monkeypatch.setattr(_exec, "FileSource", lambda src: types.SimpleNamespace(sample_rate=16000)) |
| 111 | + monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav") |
| 112 | + # Stub the loader so the assertion doesn't depend on the repo's own (large) instruction file. |
| 113 | + monkeypatch.setattr(_exec, "load_project_context", lambda: "# AGENTS.md\n\nProject background.") |
| 114 | + captured = {} |
| 115 | + |
| 116 | + def fake_real(api_key, config, *, audio, stt_params, approver=None): |
| 117 | + captured["config"] = config |
| 118 | + return "deps" |
| 119 | + |
| 120 | + monkeypatch.setattr(_exec.engine.CascadeDeps, "real", fake_real) |
| 121 | + monkeypatch.setattr(_exec.engine, "run_cascade", lambda **kwargs: None) |
| 122 | + run_agent_cascade(_opts(source="clip.wav"), AppState(), json_mode=False) |
| 123 | + # The launch directory's AGENTS.md/CLAUDE.md rides into the cascade config. |
| 124 | + assert captured["config"].project_context == "# AGENTS.md\n\nProject background." |
0 commit comments