gabriel / musehub public
test_musehub_context.py python
586 lines 18.8 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Tests for the agent context endpoint (GET /repos/{repo_id}/context).
2
3 Covers every acceptance criterion:
4 - GET /repos/{repo_id}/context returns all required sections
5 - Musical state section is present (active_tracks, key, tempo, etc.)
6 - History section includes recent commits
7 - Active proposals section lists open proposals
8 - Open issues section lists open issues
9 - Suggestions section is present
10 - ?depth=brief returns minimal context
11 - ?depth=standard returns moderate context
12 - ?depth=verbose returns full context
13 - ?format=yaml returns valid YAML
14 - Unknown repo returns 404
15 - Missing ref returns 404
16 - Endpoint requires MSign auth
17
18 All tests use fixtures from conftest.py.
19 """
20 from __future__ import annotations
21
22 import pytest
23 import yaml # PyYAML ships no py.typed marker
24 from datetime import datetime, timezone
25 from httpx import AsyncClient
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from muse.core.types import fake_id
29 from musehub.core.genesis import (
30 compute_branch_id,
31 compute_identity_id,
32 compute_issue_id,
33 compute_proposal_id,
34 )
35 from musehub.types.json_types import StrDict
36 from musehub.db.musehub_models import (
37 MusehubBranch,
38 MusehubCommit,
39 MusehubIssue,
40 MusehubProposal,
41 MusehubRepo,
42 )
43
44
45 # ---------------------------------------------------------------------------
46 # Shared helpers
47 # ---------------------------------------------------------------------------
48
49
50 async def _create_repo(client: AsyncClient, auth_headers: StrDict, name: str = "neo-soul") -> str:
51 """Create a repo via the API and return its repo_id."""
52 response = await client.post(
53 "/api/repos",
54 json={"name": name, "owner": "testuser"},
55 headers=auth_headers,
56 )
57 assert response.status_code == 201
58 repo_id: str = response.json()["repoId"]
59 return repo_id
60
61
62 async def _seed_repo_with_commits(
63 db: AsyncSession,
64 repo_id: str,
65 branch_name: str = "main",
66 num_commits: int = 3,
67 ) -> tuple[str, list[str]]:
68 """Seed a repo with a branch and commits. Returns (branch_id, list_of_commit_ids)."""
69 commit_ids: list[str] = []
70 parent_id: str | None = None
71 ts = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
72
73 from datetime import timedelta
74
75 for i in range(num_commits):
76 commit_id = fake_id(f"{repo_id}{branch_name}{i}")
77 commit = MusehubCommit(
78 commit_id=commit_id,
79 repo_id=repo_id,
80 branch=branch_name,
81 parent_ids=[parent_id] if parent_id else [],
82 message=f"Add layer {i + 1} — bass groove refinement",
83 author="session-agent",
84 timestamp=ts + timedelta(hours=i),
85 )
86 db.add(commit)
87 commit_ids.append(commit_id)
88 parent_id = commit_id
89
90 from sqlalchemy import select as sa_select, update as sa_update
91 existing = await db.scalar(
92 sa_select(MusehubBranch).where(
93 MusehubBranch.repo_id == repo_id,
94 MusehubBranch.name == branch_name,
95 )
96 )
97 if existing is not None:
98 await db.execute(
99 sa_update(MusehubBranch)
100 .where(MusehubBranch.repo_id == repo_id, MusehubBranch.name == branch_name)
101 .values(head_commit_id=commit_ids[-1])
102 )
103 else:
104 branch = MusehubBranch(
105 branch_id=compute_branch_id(repo_id, branch_name),
106 repo_id=repo_id,
107 name=branch_name,
108 head_commit_id=commit_ids[-1],
109 )
110 db.add(branch)
111 await db.flush()
112
113 return branch_name, commit_ids
114
115
116 # ---------------------------------------------------------------------------
117 # test_context_endpoint_returns_all_sections
118 # ---------------------------------------------------------------------------
119
120
121 async def test_context_endpoint_returns_all_sections(
122 client: AsyncClient,
123 auth_headers: StrDict,
124 db_session: AsyncSession,
125 ) -> None:
126 """GET /repos/{repo_id}/context returns all required top-level sections."""
127 repo_id = await _create_repo(client, auth_headers)
128 await _seed_repo_with_commits(db_session, repo_id)
129 await db_session.commit()
130
131 response = await client.get(
132 f"/api/repos/{repo_id}/context",
133 headers=auth_headers,
134 )
135 assert response.status_code == 200
136 body = response.json()
137
138 assert "repoId" in body
139 assert "ref" in body
140 assert "depth" in body
141 assert "musicalState" in body
142 assert "history" in body
143 assert "analysis" in body
144 assert "activeProposals" in body
145 assert "openIssues" in body
146 assert "suggestions" in body
147
148 assert body["repoId"] == repo_id
149 assert body["depth"] == "standard"
150
151
152 # ---------------------------------------------------------------------------
153 # test_context_includes_musical_state
154 # ---------------------------------------------------------------------------
155
156
157 async def test_context_includes_musical_state(
158 client: AsyncClient,
159 auth_headers: StrDict,
160 db_session: AsyncSession,
161 ) -> None:
162 """Musical state section contains expected fields (key, tempo, etc. may be None at MVP)."""
163 repo_id = await _create_repo(client, auth_headers)
164 await _seed_repo_with_commits(db_session, repo_id)
165 await db_session.commit()
166
167 response = await client.get(
168 f"/api/repos/{repo_id}/context",
169 headers=auth_headers,
170 )
171 assert response.status_code == 200
172 state = response.json()["musicalState"]
173
174 assert "activeTracks" in state
175 assert isinstance(state["activeTracks"], list)
176
177
178 # ---------------------------------------------------------------------------
179 # test_context_includes_history
180 # ---------------------------------------------------------------------------
181
182
183 async def test_context_includes_history(
184 client: AsyncClient,
185 auth_headers: StrDict,
186 db_session: AsyncSession,
187 ) -> None:
188 """History section includes recent commits (excluding the head commit)."""
189 repo_id = await _create_repo(client, auth_headers)
190 _, commit_ids = await _seed_repo_with_commits(db_session, repo_id, num_commits=5)
191 await db_session.commit()
192
193 response = await client.get(
194 f"/api/repos/{repo_id}/context",
195 headers=auth_headers,
196 )
197 assert response.status_code == 200
198 history = response.json()["history"]
199
200 assert isinstance(history, list)
201 # 5 commits seeded → head excluded → at most 4 in history at standard depth
202 assert len(history) <= 10
203 assert len(history) >= 1
204
205 entry = history[0]
206 assert "commitId" in entry
207 assert "message" in entry
208 assert "author" in entry
209 assert "timestamp" in entry
210 assert "activeTracks" in entry
211
212
213 # ---------------------------------------------------------------------------
214 # test_context_includes_active_proposals
215 # ---------------------------------------------------------------------------
216
217
218 async def test_context_includes_active_proposals(
219 client: AsyncClient,
220 auth_headers: StrDict,
221 db_session: AsyncSession,
222 ) -> None:
223 """Active proposals section lists open proposals for the repo."""
224 repo_id = await _create_repo(client, auth_headers)
225 await _seed_repo_with_commits(db_session, repo_id, branch_name="main")
226
227 from datetime import timedelta
228
229 feat_branch_name = "feat/tritone-subs"
230 feature_branch = MusehubBranch(
231 branch_id=compute_branch_id(repo_id, feat_branch_name),
232 repo_id=repo_id,
233 name=feat_branch_name,
234 head_commit_id=fake_id(f"{repo_id}{feat_branch_name}"),
235 )
236 db_session.add(feature_branch)
237 await db_session.flush()
238
239 now = datetime.now(tz=timezone.utc)
240 author_id = compute_identity_id(b"session-agent")
241 proposal = MusehubProposal(
242 proposal_id=compute_proposal_id(repo_id, author_id, feat_branch_name, "main", now.isoformat()),
243 repo_id=repo_id,
244 proposal_number=1,
245 title="Add tritone substitution in bridge",
246 body="Resolves the harmonic monotony in bars 24-28.",
247 state="open",
248 from_branch=feat_branch_name,
249 to_branch="main",
250 )
251 db_session.add(proposal)
252 await db_session.commit()
253
254 response = await client.get(
255 f"/api/repos/{repo_id}/context",
256 headers=auth_headers,
257 )
258 assert response.status_code == 200
259 proposals_ctx = response.json()["activeProposals"]
260
261 assert isinstance(proposals_ctx, list)
262 assert len(proposals_ctx) == 1
263 assert proposals_ctx[0]["title"] == "Add tritone substitution in bridge"
264 assert proposals_ctx[0]["state"] == "open"
265 assert "proposalId" in proposals_ctx[0]
266 assert "fromBranch" in proposals_ctx[0]
267 assert "toBranch" in proposals_ctx[0]
268
269
270 # ---------------------------------------------------------------------------
271 # test_context_brief_depth
272 # ---------------------------------------------------------------------------
273
274
275 async def test_context_brief_depth(
276 client: AsyncClient,
277 auth_headers: StrDict,
278 db_session: AsyncSession,
279 ) -> None:
280 """?depth=brief returns minimal context — at most 3 history entries and 2 suggestions."""
281 repo_id = await _create_repo(client, auth_headers)
282 await _seed_repo_with_commits(db_session, repo_id, num_commits=8)
283 await db_session.commit()
284
285 response = await client.get(
286 f"/api/repos/{repo_id}/context?depth=brief",
287 headers=auth_headers,
288 )
289 assert response.status_code == 200
290 body = response.json()
291
292 assert body["depth"] == "brief"
293 assert len(body["history"]) <= 3
294 assert len(body["suggestions"]) <= 2
295
296
297 # ---------------------------------------------------------------------------
298 # test_context_standard_depth
299 # ---------------------------------------------------------------------------
300
301
302 async def test_context_standard_depth(
303 client: AsyncClient,
304 auth_headers: StrDict,
305 db_session: AsyncSession,
306 ) -> None:
307 """?depth=standard (default) returns at most 10 history entries."""
308 repo_id = await _create_repo(client, auth_headers)
309 await _seed_repo_with_commits(db_session, repo_id, num_commits=15)
310 await db_session.commit()
311
312 response = await client.get(
313 f"/api/repos/{repo_id}/context?depth=standard",
314 headers=auth_headers,
315 )
316 assert response.status_code == 200
317 body = response.json()
318
319 assert body["depth"] == "standard"
320 assert len(body["history"]) <= 10
321
322
323 # ---------------------------------------------------------------------------
324 # test_context_verbose_depth_includes_issue_bodies
325 # ---------------------------------------------------------------------------
326
327
328 async def test_context_verbose_depth_includes_issue_bodies(
329 client: AsyncClient,
330 auth_headers: StrDict,
331 db_session: AsyncSession,
332 ) -> None:
333 """?depth=verbose includes full issue bodies; brief/standard do not."""
334 repo_id = await _create_repo(client, auth_headers)
335 await _seed_repo_with_commits(db_session, repo_id)
336
337 issue_now = datetime.now(tz=timezone.utc)
338 issue_author_id = compute_identity_id(b"session-agent")
339 issue = MusehubIssue(
340 issue_id=compute_issue_id(repo_id, issue_author_id, issue_now.isoformat()),
341 repo_id=repo_id,
342 number=1,
343 title="Add more harmonic tension",
344 body="Consider a tritone substitution in bar 24 to create tension before the resolution.",
345 state="open",
346 labels=["harmonic", "composition"],
347 )
348 db_session.add(issue)
349 await db_session.commit()
350
351 # brief: body should be empty string
352 brief_resp = await client.get(
353 f"/api/repos/{repo_id}/context?depth=brief",
354 headers=auth_headers,
355 )
356 assert brief_resp.status_code == 200
357 brief_issues = brief_resp.json()["openIssues"]
358 assert len(brief_issues) == 1
359 assert brief_issues[0]["body"] == ""
360
361 # verbose: body should be included
362 verbose_resp = await client.get(
363 f"/api/repos/{repo_id}/context?depth=verbose",
364 headers=auth_headers,
365 )
366 assert verbose_resp.status_code == 200
367 verbose_issues = verbose_resp.json()["openIssues"]
368 assert len(verbose_issues) == 1
369 assert "tritone substitution" in verbose_issues[0]["body"]
370
371
372 # ---------------------------------------------------------------------------
373 # test_context_yaml_format
374 # ---------------------------------------------------------------------------
375
376
377 async def test_context_yaml_format(
378 client: AsyncClient,
379 auth_headers: StrDict,
380 db_session: AsyncSession,
381 ) -> None:
382 """?format=yaml returns valid YAML with the same structure as JSON."""
383 repo_id = await _create_repo(client, auth_headers)
384 await _seed_repo_with_commits(db_session, repo_id)
385 await db_session.commit()
386
387 response = await client.get(
388 f"/api/repos/{repo_id}/context?format=yaml",
389 headers=auth_headers,
390 )
391 assert response.status_code == 200
392 assert "yaml" in response.headers["content-type"]
393
394 parsed = yaml.safe_load(response.text)
395 assert isinstance(parsed, dict)
396 assert "repoId" in parsed
397 assert "musicalState" in parsed
398 assert "history" in parsed
399 assert "analysis" in parsed
400
401
402 # ---------------------------------------------------------------------------
403 # test_context_unknown_repo_404
404 # ---------------------------------------------------------------------------
405
406
407 async def test_context_unknown_repo_404(
408 client: AsyncClient,
409 auth_headers: StrDict,
410 ) -> None:
411 """GET /repos/{unknown_id}/context returns 404 for a non-existent repo."""
412 response = await client.get(
413 "/api/repos/nonexistent-repo-id/context",
414 headers=auth_headers,
415 )
416 assert response.status_code == 404
417
418
419 # ---------------------------------------------------------------------------
420 # test_context_ref_not_found_404
421 # ---------------------------------------------------------------------------
422
423
424 async def test_context_ref_not_found_404(
425 client: AsyncClient,
426 auth_headers: StrDict,
427 db_session: AsyncSession,
428 ) -> None:
429 """GET .../context?ref=nonexistent returns 404 when the ref has no commits."""
430 repo_id = await _create_repo(client, auth_headers)
431 await db_session.commit()
432
433 response = await client.get(
434 f"/api/repos/{repo_id}/context?ref=nonexistent-branch",
435 headers=auth_headers,
436 )
437 assert response.status_code == 404
438
439
440 # ---------------------------------------------------------------------------
441 # test_context_requires_auth
442 # ---------------------------------------------------------------------------
443
444
445 async def test_context_nonexistent_repo_returns_404_without_auth(
446 client: AsyncClient,
447 db_session: AsyncSession,
448 ) -> None:
449 """GET /repos/{repo_id}/context returns 404 for a non-existent repo without auth.
450
451 Context endpoint uses optional_token — auth check is visibility-based,
452 so a missing repo returns 404 before the auth check fires.
453 """
454 response = await client.get(
455 "/api/repos/non-existent-repo-id/context",
456 )
457 assert response.status_code == 404
458
459
460 # ---------------------------------------------------------------------------
461 # test_context_default_ref_resolves_to_latest_commit
462 # ---------------------------------------------------------------------------
463
464
465 async def test_context_default_ref_resolves_to_latest_commit(
466 client: AsyncClient,
467 auth_headers: StrDict,
468 db_session: AsyncSession,
469 ) -> None:
470 """?ref=HEAD (default) resolves to the latest commit and returns a valid ref in response."""
471 repo_id = await _create_repo(client, auth_headers)
472 await _seed_repo_with_commits(db_session, repo_id, branch_name="main")
473 await db_session.commit()
474
475 response = await client.get(
476 f"/api/repos/{repo_id}/context",
477 headers=auth_headers,
478 )
479 assert response.status_code == 200
480 body = response.json()
481
482 # ref should resolve to a branch name or commit id (not literally "HEAD")
483 assert body["ref"] != ""
484
485
486 # ---------------------------------------------------------------------------
487 # test_context_branch_ref_resolution
488 # ---------------------------------------------------------------------------
489
490
491 async def test_context_branch_ref_resolution(
492 client: AsyncClient,
493 auth_headers: StrDict,
494 db_session: AsyncSession,
495 ) -> None:
496 """?ref=<branch_name> resolves the branch head commit."""
497 repo_id = await _create_repo(client, auth_headers)
498 await _seed_repo_with_commits(db_session, repo_id, branch_name="main")
499 await db_session.commit()
500
501 response = await client.get(
502 f"/api/repos/{repo_id}/context?ref=main",
503 headers=auth_headers,
504 )
505 assert response.status_code == 200
506 body = response.json()
507 assert body["ref"] == "main"
508
509
510 # ---------------------------------------------------------------------------
511 # test_context_suggestions_generated
512 # ---------------------------------------------------------------------------
513
514
515 async def test_context_suggestions_generated(
516 client: AsyncClient,
517 auth_headers: StrDict,
518 db_session: AsyncSession,
519 ) -> None:
520 """Suggestions are generated and returned as a list of strings."""
521 repo_id = await _create_repo(client, auth_headers)
522 await _seed_repo_with_commits(db_session, repo_id)
523 await db_session.commit()
524
525 response = await client.get(
526 f"/api/repos/{repo_id}/context",
527 headers=auth_headers,
528 )
529 assert response.status_code == 200
530 suggestions = response.json()["suggestions"]
531
532 assert isinstance(suggestions, list)
533 assert all(isinstance(s, str) for s in suggestions)
534 # At least one suggestion since no key/tempo detected (stubs)
535 assert len(suggestions) >= 1
536
537
538 # ---------------------------------------------------------------------------
539 # test_context_open_issues_excluded_when_closed
540 # ---------------------------------------------------------------------------
541
542
543 async def test_context_open_issues_excluded_when_closed(
544 client: AsyncClient,
545 auth_headers: StrDict,
546 db_session: AsyncSession,
547 ) -> None:
548 """Closed issues do not appear in the open_issues section."""
549 repo_id = await _create_repo(client, auth_headers)
550 await _seed_repo_with_commits(db_session, repo_id)
551
552 _aid = compute_identity_id(b"session-agent")
553 _t1 = datetime.now(tz=timezone.utc)
554 _t2 = datetime(_t1.year, _t1.month, _t1.day, _t1.hour, _t1.minute, _t1.second + 1, tzinfo=timezone.utc)
555 closed_issue = MusehubIssue(
556 issue_id=compute_issue_id(repo_id, _aid, _t1.isoformat()),
557 repo_id=repo_id,
558 number=1,
559 title="Closed: fix the bridge",
560 body="Already fixed.",
561 state="closed",
562 labels=[],
563 )
564 open_issue = MusehubIssue(
565 issue_id=compute_issue_id(repo_id, _aid, _t2.isoformat()),
566 repo_id=repo_id,
567 number=2,
568 title="Add swing feel to verse",
569 body="",
570 state="open",
571 labels=["groove"],
572 )
573 db_session.add(closed_issue)
574 db_session.add(open_issue)
575 await db_session.commit()
576
577 response = await client.get(
578 f"/api/repos/{repo_id}/context",
579 headers=auth_headers,
580 )
581 assert response.status_code == 200
582 issues = response.json()["openIssues"]
583
584 assert len(issues) == 1
585 assert issues[0]["title"] == "Add swing feel to verse"
586 assert issues[0]["number"] == 2
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago