# 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()