# SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc """Tests for app agent discovery, loading, and route helpers.""" import json import re from pathlib import Path import pytest from solstone.apps.sol.routes import _resolve_output_path from solstone.think.journal_config import read_journal_config from solstone.think.talent import _resolve_talent_path, get_talent, get_talent_configs from tests.helpers.journal_config import seed_journal_config @pytest.fixture def fixture_journal(monkeypatch): """Set SOLSTONE_JOURNAL to tests/fixtures/journal for testing.""" monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") yield @pytest.fixture def app_with_agent(tmp_path, monkeypatch): """Create a temporary app with an agent for testing. Creates apps/testapp/talent/myhelper.md with frontmatter in a temp directory, then monkeypatches the apps directory path. """ # Create app structure app_dir = tmp_path / "apps" / "testapp" talent_dir = app_dir / "talent" talent_dir.mkdir(parents=True) # Create workspace.html (required for app discovery, though not used here) (app_dir / "workspace.html").write_text("

Test App

") # Create agent file with frontmatter metadata = { "type": "cogitate", "title": "My Test Helper", "provider": "openai", "tools": "journal", "schedule": "daily", "priority": 42, } json_str = json.dumps(metadata, indent=2) (talent_dir / "myhelper.md").write_text( f"{{\n{json_str[1:-1]}\n}}\n\nYou are a test helper agent.\n\n## Purpose\nHelp with testing." ) # Create another agent without metadata (defaults only) (talent_dir / "simple.md").write_text("A simple test agent with no metadata.") # Monkeypatch the parent directory so apps discovery finds our temp apps monkeypatch.setattr( "solstone.think.utils.Path.__file__", str(tmp_path / "think" / "utils.py"), ) # Actually we need to patch where get_agents looks for apps # It uses the package root / "apps" # Let's patch it differently - create a mock apps dir structure yield { "tmp_path": tmp_path, "app_dir": app_dir, "talent_dir": talent_dir, } def test_resolve_agent_path_system_agent(): """Test _resolve_talent_path returns correct path for system agents.""" agent_dir, agent_name = _resolve_talent_path("chat") assert agent_name == "chat" assert agent_dir.name == "talent" def test_resolve_agent_path_app_agent(): """Test _resolve_talent_path returns correct path for app agents.""" agent_dir, agent_name = _resolve_talent_path("support:support") assert agent_name == "support" assert agent_dir.name == "talent" assert agent_dir.parent.name == "support" assert "apps" in str(agent_dir) def test_resolve_agent_path_app_agent_with_underscores(): """Test _resolve_talent_path handles app names with underscores.""" agent_dir, agent_name = _resolve_talent_path("my_app:my_agent") assert agent_name == "my_agent" assert agent_dir.parent.name == "my_app" def test_get_agent_system_agent(fixture_journal): """Test get_talent loads system agents correctly.""" config = get_talent("chat") assert config["name"] == "chat" assert "user_instruction" in config assert len(config["user_instruction"]) > 0 def test_get_agent_nonexistent_raises(): """Test get_talent raises FileNotFoundError for nonexistent agents.""" with pytest.raises(FileNotFoundError) as exc_info: get_talent("nonexistent_agent_xyz") assert "nonexistent_agent_xyz" in str(exc_info.value) def test_get_agent_legacy_alias_raises(): """The legacy chat alias is removed in the chat backend cutover.""" with pytest.raises(FileNotFoundError): get_talent("uni" + "fied") def test_get_agent_nonexistent_app_agent_raises(): """Test get_talent raises FileNotFoundError for nonexistent app agents.""" with pytest.raises(FileNotFoundError) as exc_info: get_talent("fakeapp:fakeagent") assert "fakeapp:fakeagent" in str(exc_info.value) def test_get_talent_configs_includes_system_agents(fixture_journal): """Test get_talent_configs returns system agents with metadata.""" agents = get_talent_configs(type="cogitate") # Should include known system agents with frontmatter metadata assert "exec" in agents assert agents["exec"]["source"] == "system" assert "title" in agents["exec"] assert "path" in agents["exec"] def test_get_talent_configs_system_agents_have_metadata(fixture_journal): """Test system agents have proper metadata fields.""" agents = get_talent_configs(type="cogitate") # Check a known system agent exec_talent = agents.get("exec") assert exec_talent is not None assert exec_talent["source"] == "system" assert "title" in exec_talent assert "color" in exec_talent def test_get_talent_configs_excludes_private_apps( fixture_journal, tmp_path, monkeypatch ): """Test get_talent_configs skips apps starting with underscore.""" # Create a private app with an agent private_app = tmp_path / "_private_app" / "talents" private_app.mkdir(parents=True) (private_app / "secret.md").write_text("Secret agent") # This is tricky to test without modifying the actual apps directory # The current implementation filters by app_path.name.startswith("_") # We verify this by checking the code behavior with get_talent_configs() agents = get_talent_configs(type="cogitate") # No agents should have keys starting with "_" for key in agents: assert not key.startswith("_"), f"Private app agent found: {key}" def test_app_agent_namespace_format(fixture_journal): """Test app agent keys follow {app}:{agent} format.""" agents = get_talent_configs(type="cogitate") for key, config in agents.items(): if config.get("source") == "app": # App agents must have colon in key assert ":" in key, f"App agent key missing namespace: {key}" app_name, agent_name = key.split(":", 1) assert config.get("app") == app_name # --- _resolve_output_path tests --- class TestResolveOutputPath: """Tests for _resolve_output_path route helper.""" def test_explicit_output_path_returned_directly(self): """When output_path is set, return it as-is without derivation.""" event = { "output_path": "/journal/facets/work/activities/20260214/coding_100/summary.md" } result = _resolve_output_path(event, "/journal") assert result == Path( "/journal/facets/work/activities/20260214/coding_100/summary.md" ) def test_derives_path_from_request_fields(self, fixture_journal): """Without output_path, derives from day/name/segment fields.""" event = { "day": "20260214", "name": "chat", "segment": "100", "facet": "health", } result = _resolve_output_path(event, "tests/fixtures/journal") assert result is not None assert "20260214" in str(result) assert result.suffix in (".md", ".json") def test_returns_none_without_day_or_output_path(self): """Returns None when neither output_path nor day is present.""" event = {"name": "chat"} result = _resolve_output_path(event, "/journal") assert result is None def test_empty_output_path_falls_through(self, fixture_journal): """Empty string output_path falls through to derivation.""" event = {"output_path": "", "day": "20260214", "name": "chat"} result = _resolve_output_path(event, "tests/fixtures/journal") # Empty string is falsy, so falls through to derivation assert result is not None def test_uses_env_stream_name(self, fixture_journal): """SOL_STREAM from env is passed through to get_output_path.""" event = { "day": "20260214", "name": "chat", "env": {"SOL_STREAM": "mystream"}, } result = _resolve_output_path(event, "tests/fixtures/journal") assert result is not None def test_explicit_path_ignores_other_fields(self): """When output_path is set, day/name/segment are ignored.""" event = { "output_path": "/custom/path/output.md", "day": "20260214", "name": "chat", "segment": "100", } result = _resolve_output_path(event, "/journal") assert result == Path("/custom/path/output.md") # --- api_output_file endpoint tests --- @pytest.fixture def agents_client(tmp_path, monkeypatch): """Create a Flask test client with agents blueprint and tmp journal.""" from flask import Flask from solstone.apps.sol.routes import sol_bp from solstone.convey import state app = Flask(__name__) app.register_blueprint(sol_bp) # Point state at our tmp journal monkeypatch.setattr(state, "journal_root", str(tmp_path)) # Create test files day_dir = tmp_path / "chronicle" / "20260214" day_dir.mkdir(parents=True) (day_dir / "talents" / "flow.md").parent.mkdir(parents=True) (day_dir / "talents" / "flow.md").write_text("# Day agent output") facet_dir = tmp_path / "facets" / "work" / "activities" / "20260214" / "coding_100" facet_dir.mkdir(parents=True) (facet_dir / "summary.md").write_text("# Activity summary") yield app.test_client() @pytest.fixture def sol_identity_client(tmp_path, monkeypatch): """Create a sol blueprint client backed by a temporary journal.""" from flask import Flask from solstone.apps.sol.routes import sol_bp from solstone.convey import state monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) app = Flask(__name__) app.config["TESTING"] = True app.register_blueprint(sol_bp) monkeypatch.setattr(state, "journal_root", str(tmp_path)) return app.test_client(), tmp_path class TestApiOutputFile: """Tests for api_output_file endpoint.""" def test_serves_day_relative_file(self, agents_client): """Day-relative paths resolve under {journal}/{day}/.""" resp = agents_client.get("/app/sol/api/output/20260214/talents/flow.md") assert resp.status_code == 200 data = resp.get_json() assert data["content"] == "# Day agent output" assert data["format"] == "md" assert data["filename"] == "flow.md" def test_serves_facet_scoped_activity_file(self, agents_client): """Paths starting with facets/ resolve from journal root.""" resp = agents_client.get( "/app/sol/api/output/20260214/" "facets/work/activities/20260214/coding_100/summary.md" ) assert resp.status_code == 200 data = resp.get_json() assert data["content"] == "# Activity summary" assert data["format"] == "md" def test_rejects_invalid_day_format(self, agents_client): """Non-YYYYMMDD day returns 400.""" resp = agents_client.get("/app/sol/api/output/bad-day/talents/flow.md") assert resp.status_code == 400 def test_rejects_path_traversal(self, agents_client): """Path traversal attempts return 403.""" resp = agents_client.get("/app/sol/api/output/20260214/../../etc/passwd") assert resp.status_code in (403, 404) def test_missing_file_returns_404(self, agents_client): """Non-existent file returns 404.""" resp = agents_client.get("/app/sol/api/output/20260214/talents/nonexistent.md") assert resp.status_code == 404 @pytest.fixture def sol_listing_client(tmp_path, monkeypatch): """Create a sol app client backed by a temporary journal.""" from flask import Flask from solstone.apps.sol.routes import sol_bp from solstone.convey import state app = Flask(__name__) app.register_blueprint(sol_bp) talents_dir = tmp_path / "talents" talents_dir.mkdir() monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr(state, "journal_root", str(tmp_path)) monkeypatch.setattr("solstone.apps.sol.routes.get_facets", lambda: {}) monkeypatch.setattr("solstone.apps.sol.routes._build_talents_meta", lambda: {}) return app.test_client(), talents_dir def _write_day_index(talents_dir: Path, day: str, entries: list[dict]) -> Path: path = talents_dir / f"{day}.jsonl" lines = [json.dumps(entry) + "\n" for entry in entries] path.write_text("".join(lines), encoding="utf-8") return path class TestApiTalentsDayListing: """Tests for day-index-backed talent listing.""" def test_index_only_entry_returns_full_summary(self, sol_listing_client): """A complete day-index entry is enough without a per-use file.""" client, talents_dir = sol_listing_client day = "20990101" entry = { "use_id": "4070908800001", "name": "flow", "day": day, "facet": "work", "ts": 4070908800000, "status": "error", "runtime_seconds": 12.3, "provider": "google", "model": "gemini-2.5-flash", "schedule": "daily", "thinking_count": 4, "tool_count": 2, "cost": 0.0123, "error_message": "rate limited", "reason_code": "no_output", "output_file": "talents/flow.md", "prompt": "Summarize the day", } _write_day_index(talents_dir, day, [entry]) resp = client.get(f"/app/sol/api/talents/{day}") assert resp.status_code == 200 uses = resp.get_json()["uses"] assert len(uses) == 1 assert uses[0] == { "id": "4070908800001", "name": "flow", "start": 4070908800000, "status": "error", "prompt": "Summarize the day", "facet": "work", "failed": True, "runtime_seconds": 12.3, "thinking_count": 4, "tool_count": 2, "cost": 0.0123, "model": "gemini-2.5-flash", "provider": "google", "error_message": "rate limited", "reason_code": "no_output", "output_file": "talents/flow.md", } def test_legacy_agent_id_entry_returns_with_blank_new_fields( self, sol_listing_client ): """Legacy agent_id day-index entries are visible with missing fields blank.""" client, talents_dir = sol_listing_client day = "20990102" agent_id = "4070995200001" _write_day_index( talents_dir, day, [ { "agent_id": agent_id, "name": "entities", "day": day, "facet": "personal", "ts": 4070995200000, "status": "completed", "runtime_seconds": 8.4, "provider": "google", "model": "gemini-2.5-flash-lite", } ], ) resp = client.get(f"/app/sol/api/talents/{day}") assert resp.status_code == 200 use = resp.get_json()["uses"][0] assert use["id"] == agent_id assert use["failed"] is False for field in ( "thinking_count", "tool_count", "cost", "error_message", "reason_code", "output_file", "prompt", ): assert use[field] is None def test_current_thin_entry_returns_without_rewriting_index( self, sol_listing_client ): """Current thin use_id entries return with missing fields blank.""" client, talents_dir = sol_listing_client day = "20990103" index_path = _write_day_index( talents_dir, day, [ { "use_id": "4071081600001", "name": "knowledge_graph", "day": day, "facet": None, "ts": 4071081600000, "status": "completed", "runtime_seconds": 9.1, "provider": "anthropic", "model": "claude-sonnet-4-5", "schedule": "daily", } ], ) before = index_path.read_bytes() resp = client.get(f"/app/sol/api/talents/{day}") assert resp.status_code == 200 use = resp.get_json()["uses"][0] assert use["id"] == "4071081600001" for field in ( "thinking_count", "tool_count", "cost", "error_message", "reason_code", "output_file", "prompt", ): assert use[field] is None assert index_path.read_bytes() == before def test_run_detail_exposes_error_reason_code(self, sol_listing_client): """Run detail exposes terminal error reason_code and keeps event payload.""" client, talents_dir = sol_listing_client use_id = "4071168000001" run_dir = talents_dir / "flow" run_dir.mkdir() events = [ { "event": "request", "ts": 4071168000000, "name": "flow", "day": "20990104", "prompt": "Summarize the day", "provider": "google", }, { "event": "error", "ts": 4071168000100, "error": "no_output: expects-output talent finished without producing a result", "reason_code": "no_output", "provider": "google", "terminal": True, }, ] (run_dir / f"{use_id}.jsonl").write_text( "".join(json.dumps(event) + "\n" for event in events), encoding="utf-8", ) resp = client.get(f"/app/sol/api/run/{use_id}") assert resp.status_code == 200 payload = resp.get_json() assert payload["failed"] is True assert payload["reason_code"] == "no_output" assert payload["events"][0]["reason_code"] == "no_output" class TestApiUpdatedDays: """Tests for api_updated_days endpoint.""" def test_logs_and_returns_500_on_failure(self, agents_client, monkeypatch): """updated_days failures return a detectable error envelope.""" def boom(**_kwargs): raise RuntimeError("simulated") monkeypatch.setattr("solstone.apps.sol.routes.updated_days", boom) resp = agents_client.get("/app/sol/api/updated-days") assert resp.status_code == 500 payload = resp.get_json() assert "error" in payload class TestApiIdentity: """Tests for identity endpoint.""" def test_api_identity_returns_agent_and_identity_keys(self, agents_client): resp = agents_client.get("/app/sol/api/identity") assert resp.status_code == 200 assert set(resp.get_json().keys()) == {"agent", "identity"} class TestSolIdentityRoutes: """Tests for the sol call HTTP cutover routes.""" def test_api_set_name_updates_agent(self, sol_identity_client): client, _journal = sol_identity_client seed_journal_config({}) resp = client.post( "/app/sol/api/set-name", json={"name": "aria", "status": "chosen"}, ) assert resp.status_code == 200 payload = resp.get_json() assert payload["name"] == "aria" assert payload["name_status"] == "chosen" assert re.fullmatch(r"\d{4}-\d{2}-\d{2}", payload["named_date"]) assert read_journal_config()["agent"] == payload def test_api_reset_updates_agent(self, sol_identity_client): client, _journal = sol_identity_client seed_journal_config( { "agent": { "name": "aria", "name_status": "chosen", "named_date": "2026-04-19", } } ) resp = client.post("/app/sol/api/reset") assert resp.status_code == 200 assert resp.get_json() == { "name": "sol", "name_status": "default", "named_date": None, } assert read_journal_config()["agent"]["name"] == "sol" def test_api_set_owner_updates_identity(self, sol_identity_client): client, _journal = sol_identity_client seed_journal_config({}) resp = client.post( "/app/sol/api/set-owner", json={"name": "Jer", "bio": "Building solstone"}, ) assert resp.status_code == 200 assert resp.get_json() == {"name": "Jer", "bio": "Building solstone"} config = read_journal_config() assert config["identity"]["name"] == "Jer" assert config["identity"]["bio"] == "Building solstone" def test_api_sol_init_creates_identity_directory(self, sol_identity_client): client, journal = sol_identity_client resp = client.post("/app/sol/api/sol-init") assert resp.status_code == 200 assert resp.get_json() == { "identity_dir": str(journal / "identity"), "status": "ok", } assert (journal / "identity" / "partner.md").exists()