"""Tests for the agent context endpoint (GET /repos/{repo_id}/context). Covers every acceptance criterion: - GET /repos/{repo_id}/context returns all required sections - Musical state section is present (active_tracks, key, tempo, etc.) - History section includes recent commits - Active proposals section lists open proposals - Open issues section lists open issues - Suggestions section is present - ?depth=brief returns minimal context - ?depth=standard returns moderate context - ?depth=verbose returns full context - ?format=yaml returns valid YAML - Unknown repo returns 404 - Missing ref returns 404 - Endpoint requires MSign auth All tests use fixtures from conftest.py. """ from __future__ import annotations import pytest import yaml # PyYAML ships no py.typed marker from datetime import datetime, timezone from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id from musehub.core.genesis import ( compute_branch_id, compute_identity_id, compute_issue_id, compute_proposal_id, ) from musehub.types.json_types import StrDict from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo from musehub.db.musehub_social_models import MusehubIssue, MusehubProposal # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- async def _create_repo(client: AsyncClient, auth_headers: StrDict, name: str = "neo-soul") -> str: """Create a repo via the API and return its repo_id.""" response = await client.post( "/api/repos", json={"name": name, "owner": "testuser"}, headers=auth_headers, ) assert response.status_code == 201 repo_id: str = response.json()["repoId"] return repo_id async def _seed_repo_with_commits( db: AsyncSession, repo_id: str, branch_name: str = "main", num_commits: int = 3, ) -> tuple[str, list[str]]: """Seed a repo with a branch and commits. Returns (branch_id, list_of_commit_ids).""" commit_ids: list[str] = [] parent_id: str | None = None ts = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) from datetime import timedelta for i in range(num_commits): commit_id = fake_id(f"{repo_id}{branch_name}{i}") commit = MusehubCommit( commit_id=commit_id, branch=branch_name, parent_ids=[parent_id] if parent_id else [], message=f"Add layer {i + 1} — bass groove refinement", author="session-agent", timestamp=ts + timedelta(hours=i), ) db.add(commit) db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id)) commit_ids.append(commit_id) parent_id = commit_id from sqlalchemy import select as sa_select, update as sa_update existing = await db.scalar( sa_select(MusehubBranch).where( MusehubBranch.repo_id == repo_id, MusehubBranch.name == branch_name, ) ) if existing is not None: await db.execute( sa_update(MusehubBranch) .where(MusehubBranch.repo_id == repo_id, MusehubBranch.name == branch_name) .values(head_commit_id=commit_ids[-1]) ) else: branch = MusehubBranch( branch_id=compute_branch_id(repo_id, branch_name), repo_id=repo_id, name=branch_name, head_commit_id=commit_ids[-1], ) db.add(branch) await db.flush() return branch_name, commit_ids # --------------------------------------------------------------------------- # test_context_endpoint_returns_all_sections # --------------------------------------------------------------------------- async def test_context_endpoint_returns_all_sections( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET /repos/{repo_id}/context returns all required top-level sections.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context", headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert "repoId" in body assert "ref" in body assert "depth" in body assert "musicalState" in body assert "history" in body assert "analysis" in body assert "activeProposals" in body assert "openIssues" in body assert "suggestions" in body assert body["repoId"] == repo_id assert body["depth"] == "standard" # --------------------------------------------------------------------------- # test_context_includes_musical_state # --------------------------------------------------------------------------- async def test_context_includes_musical_state( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Musical state section contains expected fields (key, tempo, etc. may be None at MVP).""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context", headers=auth_headers, ) assert response.status_code == 200 state = response.json()["musicalState"] assert "activeTracks" in state assert isinstance(state["activeTracks"], list) # --------------------------------------------------------------------------- # test_context_includes_history # --------------------------------------------------------------------------- async def test_context_includes_history( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """History section includes recent commits (excluding the head commit).""" repo_id = await _create_repo(client, auth_headers) _, commit_ids = await _seed_repo_with_commits(db_session, repo_id, num_commits=5) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context", headers=auth_headers, ) assert response.status_code == 200 history = response.json()["history"] assert isinstance(history, list) # 5 commits seeded → head excluded → at most 4 in history at standard depth assert len(history) <= 10 assert len(history) >= 1 entry = history[0] assert "commitId" in entry assert "message" in entry assert "author" in entry assert "timestamp" in entry assert "activeTracks" in entry # --------------------------------------------------------------------------- # test_context_includes_active_proposals # --------------------------------------------------------------------------- async def test_context_includes_active_proposals( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Active proposals section lists open proposals for the repo.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id, branch_name="main") from datetime import timedelta feat_branch_name = "feat/tritone-subs" feature_branch = MusehubBranch( branch_id=compute_branch_id(repo_id, feat_branch_name), repo_id=repo_id, name=feat_branch_name, head_commit_id=fake_id(f"{repo_id}{feat_branch_name}"), ) db_session.add(feature_branch) await db_session.flush() now = datetime.now(tz=timezone.utc) author_id = compute_identity_id(b"session-agent") proposal = MusehubProposal( proposal_id=compute_proposal_id(repo_id, author_id, feat_branch_name, "main", now.isoformat()), repo_id=repo_id, proposal_number=1, title="Add tritone substitution in bridge", body="Resolves the harmonic monotony in bars 24-28.", state="open", from_branch=feat_branch_name, to_branch="main", ) db_session.add(proposal) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context", headers=auth_headers, ) assert response.status_code == 200 proposals_ctx = response.json()["activeProposals"] assert isinstance(proposals_ctx, list) assert len(proposals_ctx) == 1 assert proposals_ctx[0]["title"] == "Add tritone substitution in bridge" assert proposals_ctx[0]["state"] == "open" assert "proposalId" in proposals_ctx[0] assert "fromBranch" in proposals_ctx[0] assert "toBranch" in proposals_ctx[0] # --------------------------------------------------------------------------- # test_context_brief_depth # --------------------------------------------------------------------------- async def test_context_brief_depth( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """?depth=brief returns minimal context — at most 3 history entries and 2 suggestions.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id, num_commits=8) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context?depth=brief", headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert body["depth"] == "brief" assert len(body["history"]) <= 3 assert len(body["suggestions"]) <= 2 # --------------------------------------------------------------------------- # test_context_standard_depth # --------------------------------------------------------------------------- async def test_context_standard_depth( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """?depth=standard (default) returns at most 10 history entries.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id, num_commits=15) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context?depth=standard", headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert body["depth"] == "standard" assert len(body["history"]) <= 10 # --------------------------------------------------------------------------- # test_context_verbose_depth_includes_issue_bodies # --------------------------------------------------------------------------- async def test_context_verbose_depth_includes_issue_bodies( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """?depth=verbose includes full issue bodies; brief/standard do not.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id) issue_now = datetime.now(tz=timezone.utc) issue_author_id = compute_identity_id(b"session-agent") issue = MusehubIssue( issue_id=compute_issue_id(repo_id, issue_author_id, issue_now.isoformat()), repo_id=repo_id, number=1, title="Add more harmonic tension", body="Consider a tritone substitution in bar 24 to create tension before the resolution.", state="open", labels=["harmonic", "composition"], ) db_session.add(issue) await db_session.commit() # brief: body should be empty string brief_resp = await client.get( f"/api/repos/{repo_id}/context?depth=brief", headers=auth_headers, ) assert brief_resp.status_code == 200 brief_issues = brief_resp.json()["openIssues"] assert len(brief_issues) == 1 assert brief_issues[0]["body"] == "" # verbose: body should be included verbose_resp = await client.get( f"/api/repos/{repo_id}/context?depth=verbose", headers=auth_headers, ) assert verbose_resp.status_code == 200 verbose_issues = verbose_resp.json()["openIssues"] assert len(verbose_issues) == 1 assert "tritone substitution" in verbose_issues[0]["body"] # --------------------------------------------------------------------------- # test_context_yaml_format # --------------------------------------------------------------------------- async def test_context_yaml_format( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """?format=yaml returns valid YAML with the same structure as JSON.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context?format=yaml", headers=auth_headers, ) assert response.status_code == 200 assert "yaml" in response.headers["content-type"] parsed = yaml.safe_load(response.text) assert isinstance(parsed, dict) assert "repoId" in parsed assert "musicalState" in parsed assert "history" in parsed assert "analysis" in parsed # --------------------------------------------------------------------------- # test_context_unknown_repo_404 # --------------------------------------------------------------------------- async def test_context_unknown_repo_404( client: AsyncClient, auth_headers: StrDict, ) -> None: """GET /repos/{unknown_id}/context returns 404 for a non-existent repo.""" response = await client.get( "/api/repos/nonexistent-repo-id/context", headers=auth_headers, ) assert response.status_code == 404 # --------------------------------------------------------------------------- # test_context_ref_not_found_404 # --------------------------------------------------------------------------- async def test_context_ref_not_found_404( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET .../context?ref=nonexistent returns 404 when the ref has no commits.""" repo_id = await _create_repo(client, auth_headers) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context?ref=nonexistent-branch", headers=auth_headers, ) assert response.status_code == 404 # --------------------------------------------------------------------------- # test_context_requires_auth # --------------------------------------------------------------------------- async def test_context_nonexistent_repo_returns_404_without_auth( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /repos/{repo_id}/context returns 404 for a non-existent repo without auth. Context endpoint uses optional_token — auth check is visibility-based, so a missing repo returns 404 before the auth check fires. """ response = await client.get( "/api/repos/non-existent-repo-id/context", ) assert response.status_code == 404 # --------------------------------------------------------------------------- # test_context_default_ref_resolves_to_latest_commit # --------------------------------------------------------------------------- async def test_context_default_ref_resolves_to_latest_commit( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """?ref=HEAD (default) resolves to the latest commit and returns a valid ref in response.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id, branch_name="main") await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context", headers=auth_headers, ) assert response.status_code == 200 body = response.json() # ref should resolve to a branch name or commit id (not literally "HEAD") assert body["ref"] != "" # --------------------------------------------------------------------------- # test_context_branch_ref_resolution # --------------------------------------------------------------------------- async def test_context_branch_ref_resolution( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """?ref= resolves the branch head commit.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id, branch_name="main") await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context?ref=main", headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert body["ref"] == "main" # --------------------------------------------------------------------------- # test_context_suggestions_generated # --------------------------------------------------------------------------- async def test_context_suggestions_generated( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Suggestions are generated and returned as a list of strings.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context", headers=auth_headers, ) assert response.status_code == 200 suggestions = response.json()["suggestions"] assert isinstance(suggestions, list) assert all(isinstance(s, str) for s in suggestions) # At least one suggestion since no key/tempo detected (stubs) assert len(suggestions) >= 1 # --------------------------------------------------------------------------- # test_context_open_issues_excluded_when_closed # --------------------------------------------------------------------------- async def test_context_open_issues_excluded_when_closed( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Closed issues do not appear in the open_issues section.""" repo_id = await _create_repo(client, auth_headers) await _seed_repo_with_commits(db_session, repo_id) _aid = compute_identity_id(b"session-agent") _t1 = datetime.now(tz=timezone.utc) _t2 = datetime(_t1.year, _t1.month, _t1.day, _t1.hour, _t1.minute, _t1.second + 1, tzinfo=timezone.utc) closed_issue = MusehubIssue( issue_id=compute_issue_id(repo_id, _aid, _t1.isoformat()), repo_id=repo_id, number=1, title="Closed: fix the bridge", body="Already fixed.", state="closed", labels=[], ) open_issue = MusehubIssue( issue_id=compute_issue_id(repo_id, _aid, _t2.isoformat()), repo_id=repo_id, number=2, title="Add swing feel to verse", body="", state="open", labels=["groove"], ) db_session.add(closed_issue) db_session.add(open_issue) await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/context", headers=auth_headers, ) assert response.status_code == 200 issues = response.json()["openIssues"] assert len(issues) == 1 assert issues[0]["title"] == "Add swing feel to verse" assert issues[0]["number"] == 2