gabriel / musehub public
test_proposal_reimagination_phase6.py python
809 lines 34.6 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
1 """Phase 6 tests: MCP tool layer for proposal reimagination.
2
3 Covers:
4 - _proposal_data serialiser includes all new Phase 1-5 fields
5 - _simulation_data serialiser shape
6 - execute_create_proposal forwards new kwargs
7 - execute_get_proposal returns enriched proposal
8 - execute_run_simulation runs and persists result
9 - execute_get_simulation reads cached result, returns not_found when missing
10 - execute_list_simulations returns all cached simulations
11 - Dispatcher routing: musehub_create_proposal new fields, simulation tools
12 - Tool schema: new tools present in MUSEHUB_WRITE_TOOLS
13 """
14
15 import asyncio
16 import os
17 from datetime import datetime, timezone
18 from unittest.mock import AsyncMock, MagicMock, patch
19
20 import pytest
21
22 from muse.core.types import blob_id, fake_id, short_id
23 from musehub.types.json_types import JSONObject, JSONValue
24
25
26 # ── helpers ────────────────────────────────────────────────────────────────────
27
28 def _sid(label: str) -> str:
29 """Deterministic sha256-prefixed ID from a label string."""
30 return fake_id(label)
31
32
33 def _uid() -> str:
34 """Unique per-run short ID — use for repo/proposal slugs."""
35 return short_id(blob_id(os.urandom(16)), strip=True)
36
37
38 def _now() -> datetime:
39 return datetime.now(timezone.utc)
40
41
42 # ── _proposal_data serialiser ─────────────────────────────────────────────────
43
44 class TestProposalDataSerializer:
45 def _make_proposal_response(self, **overrides: JSONValue) -> "ProposalResponse":
46 from musehub.models.musehub import ProposalResponse
47 defaults = dict(
48 proposal_id=_sid("p1"),
49 title="Test proposal",
50 body="",
51 state="open",
52 from_branch="feat/x",
53 to_branch="dev",
54 author="gabriel",
55 proposal_number=1,
56 proposal_type="state_merge",
57 is_draft=False,
58 merge_strategy="overlay",
59 merge_conditions={},
60 selective_domains=[],
61 merge_commit_id=None,
62 created_at=_now(),
63 merged_at=None,
64 reviewer_count=0,
65 comment_count=0,
66 approval_count=0,
67 changes_requested_count=0,
68 merge_readiness=None,
69 dimensional_risk={},
70 head_commit_id=_sid("commit1"),
71 blocked_by=[],
72 blocks=[],
73 is_blocked=False,
74 latest_simulations={},
75 )
76 defaults.update(overrides)
77 return ProposalResponse(**defaults)
78
79 def test_core_fields_present(self) -> None:
80 from musehub.mcp.write_tools.proposals import _proposal_data
81 p = self._make_proposal_response()
82 data = _proposal_data(p)
83 for key in ("proposal_id", "title", "body", "state", "from_branch", "to_branch", "author"):
84 assert key in data
85
86 def test_new_fields_present(self) -> None:
87 from musehub.mcp.write_tools.proposals import _proposal_data
88 p = self._make_proposal_response(
89 proposal_type="midi_evolution",
90 is_draft=True,
91 merge_strategy="weave",
92 selective_domains=["audio"],
93 blocked_by=[1],
94 blocks=[3],
95 is_blocked=True,
96 latest_simulations={"conflict_scan": {"conflict_count": 0}},
97 )
98 data = _proposal_data(p)
99 assert data["proposal_type"] == "midi_evolution"
100 assert data["is_draft"] is True
101 assert data["merge_strategy"] == "weave"
102 assert "merge_conditions" in data
103 assert data["selective_domains"] == ["audio"]
104 assert data["blocked_by"] == [1]
105 assert data["blocks"] == [3]
106 assert data["is_blocked"] is True
107 assert "conflict_scan" in data["latest_simulations"]
108
109 def test_dates_iso_format(self) -> None:
110 from musehub.mcp.write_tools.proposals import _proposal_data
111 now = _now()
112 p = self._make_proposal_response(created_at=now, merged_at=now)
113 data = _proposal_data(p)
114 assert data["created_at"] == now.isoformat()
115 assert data["merged_at"] == now.isoformat()
116
117 def test_merged_at_none_handled(self) -> None:
118 from musehub.mcp.write_tools.proposals import _proposal_data
119 p = self._make_proposal_response(merged_at=None)
120 data = _proposal_data(p)
121 assert data["merged_at"] is None
122
123
124 # ── _simulation_data serialiser ────────────────────────────────────────────────
125
126 class TestSimulationDataSerializer:
127 def _make_sim(self, **overrides: JSONValue) -> "SimulationResponse":
128 from musehub.models.musehub import SimulationResponse
129 defaults = dict(
130 simulation_id=_sid("sim1"),
131 proposal_id=_sid("p1"),
132 simulation_type="conflict_scan",
133 result={"conflict_count": 3, "conflicting_files": ["a.py"]},
134 is_stale=False,
135 from_branch_commit_id=_sid("c1"),
136 duration_ms=42,
137 created_at=_now(),
138 expires_at=None,
139 )
140 defaults.update(overrides)
141 return SimulationResponse(**defaults)
142
143 def test_all_fields_present(self) -> None:
144 from musehub.mcp.write_tools.proposals import _simulation_data
145 sim = self._make_sim()
146 data = _simulation_data(sim)
147 for key in ("simulation_id", "proposal_id", "simulation_type", "result",
148 "is_stale", "from_branch_commit_id", "duration_ms", "created_at", "expires_at"):
149 assert key in data
150
151 def test_result_passthrough(self) -> None:
152 from musehub.mcp.write_tools.proposals import _simulation_data
153 result = {"conflict_count": 5, "risk_band": "high"}
154 data = _simulation_data(self._make_sim(result=result))
155 assert data["result"] == result
156
157 def test_is_stale_flag(self) -> None:
158 from musehub.mcp.write_tools.proposals import _simulation_data
159 data = _simulation_data(self._make_sim(is_stale=True))
160 assert data["is_stale"] is True
161
162 def test_expires_at_iso_when_set(self) -> None:
163 from musehub.mcp.write_tools.proposals import _simulation_data
164 ts = _now()
165 data = _simulation_data(self._make_sim(expires_at=ts))
166 assert data["expires_at"] == ts.isoformat()
167
168
169 # ── execute_create_proposal — new fields forwarded ────────────────────────────
170
171 class TestExecuteCreateProposalNewFields:
172 """Verify new Phase 1 kwargs are forwarded to musehub_proposals.create_proposal."""
173
174 @pytest.mark.asyncio
175 async def test_new_kwargs_forwarded(self) -> None:
176 from musehub.mcp.write_tools.proposals import execute_create_proposal
177
178 mock_proposal = MagicMock()
179 mock_proposal.proposal_id = _sid("p1")
180 mock_proposal.title = "T"
181 mock_proposal.body = ""
182 mock_proposal.state = "open"
183 mock_proposal.from_branch = "feat"
184 mock_proposal.to_branch = "dev"
185 mock_proposal.author = "gabriel"
186 mock_proposal.merge_commit_id = None
187 mock_proposal.created_at = None
188 mock_proposal.merged_at = None
189
190 mock_session = AsyncMock()
191 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
192 mock_session.__aexit__ = AsyncMock(return_value=False)
193
194 mock_repo = MagicMock(owner="gabriel", visibility="public")
195
196 with (
197 patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None),
198 patch("musehub.mcp.write_tools.proposals.AsyncSessionLocal", return_value=mock_session),
199 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_repo",
200 new_callable=AsyncMock, return_value=mock_repo),
201 patch("musehub.mcp.write_tools.proposals._require_public_or_write_access",
202 new_callable=AsyncMock, return_value=None),
203 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_identity_id_for_handle",
204 new_callable=AsyncMock, return_value=_sid("id1")),
205 patch("musehub.mcp.write_tools.proposals.musehub_proposals.create_proposal",
206 new_callable=AsyncMock, return_value=mock_proposal) as mock_create,
207 ):
208 result = await execute_create_proposal(
209 repo_id=_sid("repo1"),
210 title="T",
211 from_branch="feat",
212 to_branch="dev",
213 proposal_type="midi_evolution",
214 is_draft=True,
215 merge_strategy="weave",
216 merge_conditions={"require_approvals": 1},
217 selective_domains=["audio"],
218 depends_on=[_sid("p0")],
219 actor="gabriel",
220 )
221
222 assert result.ok is True
223 call_kwargs = mock_create.call_args.kwargs
224 assert call_kwargs["proposal_type"] == "midi_evolution"
225 assert call_kwargs["is_draft"] is True
226 assert call_kwargs["merge_strategy"] == "weave"
227 assert call_kwargs["merge_conditions"] == {"require_approvals": 1}
228 assert call_kwargs["selective_domains"] == ["audio"]
229 assert call_kwargs["depends_on"] == [_sid("p0")]
230
231 @pytest.mark.asyncio
232 async def test_no_actor_rejected(self) -> None:
233 from musehub.mcp.write_tools.proposals import execute_create_proposal
234 with patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None):
235 result = await execute_create_proposal(
236 repo_id=_sid("r"), title="T", from_branch="feat", to_branch="dev",
237 actor="",
238 )
239 # Will fail on branch == to_branch check first, but an empty actor proceeds to DB
240 # where it would fail auth — we just test no crash here with mocked DB unavailable
241 assert result is not None
242
243 @pytest.mark.asyncio
244 async def test_same_branch_rejected(self) -> None:
245 from musehub.mcp.write_tools.proposals import execute_create_proposal
246 with patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None):
247 result = await execute_create_proposal(
248 repo_id=_sid("r"), title="T", from_branch="dev", to_branch="dev",
249 actor="gabriel",
250 )
251 assert result.ok is False
252 assert result.error_code == "invalid_args"
253
254
255 # ── execute_get_proposal ───────────────────────────────────────────────────────
256
257 class TestExecuteGetProposal:
258 def _mock_proposal(self) -> None:
259 from musehub.models.musehub import ProposalResponse
260 return ProposalResponse(
261 proposal_id=_sid("p1"),
262 title="My proposal",
263 body="",
264 state="open",
265 from_branch="feat/x",
266 to_branch="dev",
267 author="gabriel",
268 proposal_number=1,
269 proposal_type="state_merge",
270 is_draft=False,
271 merge_strategy="overlay",
272 merge_conditions={},
273 selective_domains=[],
274 merge_commit_id=None,
275 created_at=_now(),
276 merged_at=None,
277 reviewer_count=0,
278 comment_count=0,
279 approval_count=0,
280 changes_requested_count=0,
281 merge_readiness=None,
282 dimensional_risk={},
283 head_commit_id=_sid("c1"),
284 blocked_by=[],
285 blocks=[],
286 is_blocked=False,
287 latest_simulations={"conflict_scan": {"conflict_count": 0}},
288 )
289
290 @pytest.mark.asyncio
291 async def test_returns_enriched_proposal(self) -> None:
292 from musehub.mcp.write_tools.proposals import execute_get_proposal
293 mock_session = AsyncMock()
294 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
295 mock_session.__aexit__ = AsyncMock(return_value=False)
296 mock_repo = MagicMock(owner="gabriel", visibility="public")
297 proposal = self._mock_proposal()
298
299 with (
300 patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None),
301 patch("musehub.mcp.write_tools.proposals.AsyncSessionLocal", return_value=mock_session),
302 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_repo",
303 new_callable=AsyncMock, return_value=mock_repo),
304 patch("musehub.mcp.write_tools.proposals.musehub_proposals.get_proposal",
305 new_callable=AsyncMock, return_value=proposal),
306 ):
307 result = await execute_get_proposal(
308 repo_id=_sid("repo"), proposal_id=_sid("p1"), actor="gabriel"
309 )
310
311 assert result.ok is True
312 assert result.data["proposal_id"] == _sid("p1")
313 assert "latest_simulations" in result.data
314 assert "conflict_scan" in result.data["latest_simulations"]
315
316 @pytest.mark.asyncio
317 async def test_not_found_when_proposal_missing(self) -> None:
318 from musehub.mcp.write_tools.proposals import execute_get_proposal
319 mock_session = AsyncMock()
320 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
321 mock_session.__aexit__ = AsyncMock(return_value=False)
322 mock_repo = MagicMock(owner="gabriel", visibility="public")
323
324 with (
325 patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None),
326 patch("musehub.mcp.write_tools.proposals.AsyncSessionLocal", return_value=mock_session),
327 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_repo",
328 new_callable=AsyncMock, return_value=mock_repo),
329 patch("musehub.mcp.write_tools.proposals.musehub_proposals.get_proposal",
330 new_callable=AsyncMock, return_value=None),
331 ):
332 result = await execute_get_proposal(
333 repo_id=_sid("repo"), proposal_id=_sid("p99"), actor="gabriel"
334 )
335
336 assert result.ok is False
337 assert result.error_code == "proposal_not_found"
338
339 @pytest.mark.asyncio
340 async def test_no_actor_rejected(self) -> None:
341 from musehub.mcp.write_tools.proposals import execute_get_proposal
342 with patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None):
343 result = await execute_get_proposal(
344 repo_id=_sid("repo"), proposal_id=_sid("p1"), actor=""
345 )
346 assert result.ok is False
347 assert result.error_code == "forbidden"
348
349
350 # ── execute_run_simulation ─────────────────────────────────────────────────────
351
352 class TestExecuteRunSimulation:
353 def _mock_sim(self, simulation_type: str = "conflict_scan") -> "SimulationResponse":
354 from musehub.models.musehub import SimulationResponse
355 return SimulationResponse(
356 simulation_id=_sid("sim1"),
357 proposal_id=_sid("p1"),
358 simulation_type=simulation_type,
359 result={"conflict_count": 0},
360 is_stale=False,
361 from_branch_commit_id=_sid("c1"),
362 duration_ms=10,
363 created_at=_now(),
364 expires_at=None,
365 )
366
367 @pytest.mark.asyncio
368 async def test_runs_and_returns_result(self) -> None:
369 from musehub.mcp.write_tools.proposals import execute_run_simulation
370 mock_session = AsyncMock()
371 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
372 mock_session.__aexit__ = AsyncMock(return_value=False)
373 mock_repo = MagicMock(owner="gabriel", visibility="public")
374 sim = self._mock_sim("risk_projection")
375
376 with (
377 patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None),
378 patch("musehub.mcp.write_tools.proposals.AsyncSessionLocal", return_value=mock_session),
379 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_repo",
380 new_callable=AsyncMock, return_value=mock_repo),
381 patch("musehub.mcp.write_tools.proposals.musehub_proposals.run_simulation",
382 new_callable=AsyncMock, return_value=sim),
383 ):
384 result = await execute_run_simulation(
385 repo_id=_sid("repo"),
386 proposal_id=_sid("p1"),
387 simulation_type="risk_projection",
388 actor="gabriel",
389 )
390
391 assert result.ok is True
392 assert result.data["simulation_type"] == "risk_projection"
393 assert "result" in result.data
394 assert result.data["is_stale"] is False
395
396 @pytest.mark.asyncio
397 async def test_invalid_simulation_type_rejected(self) -> None:
398 from musehub.mcp.write_tools.proposals import execute_run_simulation
399 with patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None):
400 result = await execute_run_simulation(
401 repo_id=_sid("repo"),
402 proposal_id=_sid("p1"),
403 simulation_type="bad_type",
404 actor="gabriel",
405 )
406 assert result.ok is False
407 assert result.error_code == "invalid_args"
408
409 @pytest.mark.asyncio
410 async def test_no_actor_rejected(self) -> None:
411 from musehub.mcp.write_tools.proposals import execute_run_simulation
412 with patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None):
413 result = await execute_run_simulation(
414 repo_id=_sid("repo"),
415 proposal_id=_sid("p1"),
416 simulation_type="conflict_scan",
417 actor="",
418 )
419 assert result.ok is False
420 assert result.error_code == "forbidden"
421
422 @pytest.mark.asyncio
423 @pytest.mark.parametrize("sim_type", ["conflict_scan", "risk_projection", "dependency_order"])
424 async def test_all_valid_simulation_types_accepted(self, sim_type: str) -> None:
425 from musehub.mcp.write_tools.proposals import execute_run_simulation
426 mock_session = AsyncMock()
427 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
428 mock_session.__aexit__ = AsyncMock(return_value=False)
429 mock_repo = MagicMock(owner="gabriel", visibility="public")
430
431 with (
432 patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None),
433 patch("musehub.mcp.write_tools.proposals.AsyncSessionLocal", return_value=mock_session),
434 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_repo",
435 new_callable=AsyncMock, return_value=mock_repo),
436 patch("musehub.mcp.write_tools.proposals.musehub_proposals.run_simulation",
437 new_callable=AsyncMock, return_value=self._mock_sim(sim_type)),
438 ):
439 result = await execute_run_simulation(
440 repo_id=_sid("repo"),
441 proposal_id=_sid("p1"),
442 simulation_type=sim_type,
443 actor="gabriel",
444 )
445 assert result.ok is True
446
447
448 # ── execute_get_simulation ─────────────────────────────────────────────────────
449
450 class TestExecuteGetSimulation:
451 def _mock_sim(self) -> "SimulationResponse":
452 from musehub.models.musehub import SimulationResponse
453 return SimulationResponse(
454 simulation_id=_sid("sim1"),
455 proposal_id=_sid("p1"),
456 simulation_type="conflict_scan",
457 result={"conflict_count": 2},
458 is_stale=True,
459 from_branch_commit_id=_sid("c_old"),
460 duration_ms=15,
461 created_at=_now(),
462 expires_at=None,
463 )
464
465 @pytest.mark.asyncio
466 async def test_returns_cached_result(self) -> None:
467 from musehub.mcp.write_tools.proposals import execute_get_simulation
468 mock_session = AsyncMock()
469 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
470 mock_session.__aexit__ = AsyncMock(return_value=False)
471 mock_repo = MagicMock(owner="gabriel", visibility="public")
472 sim = self._mock_sim()
473
474 with (
475 patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None),
476 patch("musehub.mcp.write_tools.proposals.AsyncSessionLocal", return_value=mock_session),
477 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_repo",
478 new_callable=AsyncMock, return_value=mock_repo),
479 patch("musehub.mcp.write_tools.proposals.musehub_proposals.get_simulation",
480 new_callable=AsyncMock, return_value=sim),
481 ):
482 result = await execute_get_simulation(
483 repo_id=_sid("repo"),
484 proposal_id=_sid("p1"),
485 simulation_type="conflict_scan",
486 actor="gabriel",
487 )
488
489 assert result.ok is True
490 assert result.data["is_stale"] is True
491 assert result.data["result"]["conflict_count"] == 2
492
493 @pytest.mark.asyncio
494 async def test_not_found_when_no_cached_simulation(self) -> None:
495 from musehub.mcp.write_tools.proposals import execute_get_simulation
496 mock_session = AsyncMock()
497 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
498 mock_session.__aexit__ = AsyncMock(return_value=False)
499 mock_repo = MagicMock(owner="gabriel", visibility="public")
500
501 with (
502 patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None),
503 patch("musehub.mcp.write_tools.proposals.AsyncSessionLocal", return_value=mock_session),
504 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_repo",
505 new_callable=AsyncMock, return_value=mock_repo),
506 patch("musehub.mcp.write_tools.proposals.musehub_proposals.get_simulation",
507 new_callable=AsyncMock, return_value=None),
508 ):
509 result = await execute_get_simulation(
510 repo_id=_sid("repo"),
511 proposal_id=_sid("p1"),
512 simulation_type="conflict_scan",
513 actor="gabriel",
514 )
515
516 assert result.ok is False
517 assert result.error_code == "not_found"
518 assert "musehub_run_proposal_simulation" in result.error_message
519
520 @pytest.mark.asyncio
521 async def test_invalid_type_rejected(self) -> None:
522 from musehub.mcp.write_tools.proposals import execute_get_simulation
523 with patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None):
524 result = await execute_get_simulation(
525 repo_id=_sid("repo"),
526 proposal_id=_sid("p1"),
527 simulation_type="nope",
528 actor="gabriel",
529 )
530 assert result.ok is False
531 assert result.error_code == "invalid_args"
532
533
534 # ── execute_list_simulations ───────────────────────────────────────────────────
535
536 class TestExecuteListSimulations:
537 def _make_sim_list(self) -> None:
538 from musehub.models.musehub import SimulationResponse, SimulationListResponse
539 sims = [
540 SimulationResponse(
541 simulation_id=_sid(f"sim{i}"),
542 proposal_id=_sid("p1"),
543 simulation_type=st,
544 result={},
545 is_stale=False,
546 from_branch_commit_id=_sid("c1"),
547 duration_ms=5,
548 created_at=_now(),
549 expires_at=None,
550 )
551 for i, st in enumerate(["conflict_scan", "risk_projection"])
552 ]
553 return SimulationListResponse(simulations=sims, total=2)
554
555 @pytest.mark.asyncio
556 async def test_returns_all_simulations(self) -> None:
557 from musehub.mcp.write_tools.proposals import execute_list_simulations
558 mock_session = AsyncMock()
559 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
560 mock_session.__aexit__ = AsyncMock(return_value=False)
561 mock_repo = MagicMock(owner="gabriel", visibility="public")
562 sim_list = self._make_sim_list()
563
564 with (
565 patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None),
566 patch("musehub.mcp.write_tools.proposals.AsyncSessionLocal", return_value=mock_session),
567 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_repo",
568 new_callable=AsyncMock, return_value=mock_repo),
569 patch("musehub.mcp.write_tools.proposals.musehub_proposals.list_simulations",
570 new_callable=AsyncMock, return_value=sim_list),
571 ):
572 result = await execute_list_simulations(
573 repo_id=_sid("repo"),
574 proposal_id=_sid("p1"),
575 actor="gabriel",
576 )
577
578 assert result.ok is True
579 assert result.data["total"] == 2
580 types = {s["simulation_type"] for s in result.data["simulations"]}
581 assert types == {"conflict_scan", "risk_projection"}
582
583 @pytest.mark.asyncio
584 async def test_empty_list_when_none_run(self) -> None:
585 from musehub.mcp.write_tools.proposals import execute_list_simulations
586 from musehub.models.musehub import SimulationListResponse
587 mock_session = AsyncMock()
588 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
589 mock_session.__aexit__ = AsyncMock(return_value=False)
590 mock_repo = MagicMock(owner="gabriel", visibility="public")
591
592 with (
593 patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None),
594 patch("musehub.mcp.write_tools.proposals.AsyncSessionLocal", return_value=mock_session),
595 patch("musehub.mcp.write_tools.proposals.musehub_repository.get_repo",
596 new_callable=AsyncMock, return_value=mock_repo),
597 patch("musehub.mcp.write_tools.proposals.musehub_proposals.list_simulations",
598 new_callable=AsyncMock, return_value=SimulationListResponse(simulations=[], total=0)),
599 ):
600 result = await execute_list_simulations(
601 repo_id=_sid("repo"),
602 proposal_id=_sid("p1"),
603 actor="gabriel",
604 )
605
606 assert result.ok is True
607 assert result.data["total"] == 0
608 assert result.data["simulations"] == []
609
610 @pytest.mark.asyncio
611 async def test_no_actor_rejected(self) -> None:
612 from musehub.mcp.write_tools.proposals import execute_list_simulations
613 with patch("musehub.mcp.write_tools.proposals._check_db_available", return_value=None):
614 result = await execute_list_simulations(
615 repo_id=_sid("repo"),
616 proposal_id=_sid("p1"),
617 actor="",
618 )
619 assert result.ok is False
620 assert result.error_code == "forbidden"
621
622
623 # ── Tool schema presence ───────────────────────────────────────────────────────
624
625 class TestToolSchemas:
626 def _get_tool_names(self) -> None:
627 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOLS
628 return {t["name"] for t in MUSEHUB_WRITE_TOOLS}
629
630 def test_get_proposal_in_write_tools(self) -> None:
631 assert "musehub_get_proposal" in self._get_tool_names()
632
633 def test_run_simulation_in_write_tools(self) -> None:
634 assert "musehub_run_proposal_simulation" in self._get_tool_names()
635
636 def test_get_simulation_in_write_tools(self) -> None:
637 assert "musehub_get_proposal_simulation" in self._get_tool_names()
638
639 def test_list_simulations_in_write_tools(self) -> None:
640 assert "musehub_list_proposal_simulations" in self._get_tool_names()
641
642 def test_create_proposal_has_new_fields(self) -> None:
643 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOLS
644 schema = next(t for t in MUSEHUB_WRITE_TOOLS if t["name"] == "musehub_create_proposal")
645 props = schema["inputSchema"]["properties"]
646 for field in ("proposal_type", "is_draft", "merge_strategy", "merge_conditions",
647 "selective_domains", "depends_on"):
648 assert field in props, f"Missing field {field!r} in musehub_create_proposal schema"
649 # Proposal type enum uses music-domain values
650 assert "state_merge" in props["proposal_type"]["enum"]
651 assert "midi_evolution" in props["proposal_type"]["enum"]
652
653 def test_run_simulation_schema_enum(self) -> None:
654 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOLS
655 schema = next(t for t in MUSEHUB_WRITE_TOOLS if t["name"] == "musehub_run_proposal_simulation")
656 sim_type_prop = schema["inputSchema"]["properties"]["simulation_type"]
657 assert set(sim_type_prop["enum"]) == {"conflict_scan", "risk_projection", "dependency_order"}
658
659 def test_simulation_tools_require_proposal_id(self) -> None:
660 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOLS
661 sim_tool_names = {
662 "musehub_run_proposal_simulation",
663 "musehub_get_proposal_simulation",
664 "musehub_list_proposal_simulations",
665 }
666 for tool in MUSEHUB_WRITE_TOOLS:
667 if tool["name"] in sim_tool_names:
668 assert "proposal_id" in tool["inputSchema"].get("required", []), (
669 f"{tool['name']} must require proposal_id"
670 )
671
672 def test_all_new_tools_in_tool_names_set(self) -> None:
673 from musehub.mcp.tools.musehub import MUSEHUB_TOOL_NAMES
674 for name in ("musehub_get_proposal", "musehub_run_proposal_simulation",
675 "musehub_get_proposal_simulation", "musehub_list_proposal_simulations"):
676 assert name in MUSEHUB_TOOL_NAMES
677
678
679 # ── Dispatcher routing ─────────────────────────────────────────────────────────
680
681 class TestDispatcherRouting:
682 """Verify the dispatcher correctly routes new tool calls to the right executors."""
683
684 def _make_dispatcher_call(self, tool_name: str, arguments: JSONObject) -> None:
685 """Simulate a dispatcher dispatch call synchronously."""
686 import asyncio
687 from musehub.mcp import dispatcher as disp
688
689 async def _run() -> None:
690 return await disp.dispatch(
691 name=tool_name,
692 arguments=arguments,
693 user_id="gabriel",
694 session_id=None,
695 request_context=None,
696 )
697 return asyncio.get_event_loop().run_until_complete(_run())
698
699 @pytest.mark.asyncio
700 async def test_dispatcher_routes_get_proposal(self) -> None:
701 from musehub.mcp import dispatcher as disp
702 mock_result = MagicMock()
703 mock_result.ok = True
704 mock_result.data = {"proposal_id": _sid("p1")}
705 mock_result.error_code = None
706 mock_result.error_message = None
707 mock_result.hint = None
708
709 with patch(
710 "musehub.mcp.write_tools.proposals.execute_get_proposal",
711 new_callable=AsyncMock,
712 return_value=mock_result,
713 ) as mock_exec:
714 await disp.dispatch_tool(
715 "musehub_get_proposal",
716 {"repo_id": _sid("repo"), "proposal_id": _sid("p1")},
717 user_id="gabriel",
718 )
719 mock_exec.assert_called_once()
720 kwargs = mock_exec.call_args.kwargs
721 assert kwargs["proposal_id"] == _sid("p1")
722 assert kwargs["actor"] == "gabriel"
723
724 @pytest.mark.asyncio
725 async def test_dispatcher_routes_run_simulation(self) -> None:
726 from musehub.mcp import dispatcher as disp
727 mock_result = MagicMock()
728 mock_result.ok = True
729 mock_result.data = {}
730 mock_result.error_code = None
731 mock_result.error_message = None
732 mock_result.hint = None
733
734 with patch(
735 "musehub.mcp.write_tools.proposals.execute_run_simulation",
736 new_callable=AsyncMock,
737 return_value=mock_result,
738 ) as mock_exec:
739 await disp.dispatch_tool(
740 "musehub_run_proposal_simulation",
741 {
742 "repo_id": _sid("repo"),
743 "proposal_id": _sid("p1"),
744 "simulation_type": "conflict_scan",
745 },
746 user_id="gabriel",
747 )
748 mock_exec.assert_called_once()
749 kwargs = mock_exec.call_args.kwargs
750 assert kwargs["simulation_type"] == "conflict_scan"
751
752 @pytest.mark.asyncio
753 async def test_dispatcher_routes_list_simulations(self) -> None:
754 from musehub.mcp import dispatcher as disp
755 mock_result = MagicMock()
756 mock_result.ok = True
757 mock_result.data = {"simulations": [], "total": 0}
758 mock_result.error_code = None
759 mock_result.error_message = None
760 mock_result.hint = None
761
762 with patch(
763 "musehub.mcp.write_tools.proposals.execute_list_simulations",
764 new_callable=AsyncMock,
765 return_value=mock_result,
766 ) as mock_exec:
767 await disp.dispatch_tool(
768 "musehub_list_proposal_simulations",
769 {"repo_id": _sid("repo"), "proposal_id": _sid("p1")},
770 user_id="gabriel",
771 )
772 mock_exec.assert_called_once()
773
774 @pytest.mark.asyncio
775 async def test_dispatcher_create_proposal_forwards_new_fields(self) -> None:
776 from musehub.mcp import dispatcher as disp
777 mock_result = MagicMock()
778 mock_result.ok = True
779 mock_result.data = {}
780 mock_result.error_code = None
781 mock_result.error_message = None
782 mock_result.hint = None
783
784 with patch(
785 "musehub.mcp.write_tools.proposals.execute_create_proposal",
786 new_callable=AsyncMock,
787 return_value=mock_result,
788 ) as mock_exec:
789 await disp.dispatch_tool(
790 "musehub_create_proposal",
791 {
792 "repo_id": _sid("repo"),
793 "title": "Test",
794 "from_branch": "feat/x",
795 "to_branch": "dev",
796 "proposal_type": "midi_evolution",
797 "is_draft": True,
798 "merge_strategy": "weave",
799 "selective_domains": ["audio", "midi"],
800 "depends_on": [_sid("p0")],
801 },
802 user_id="gabriel",
803 )
804 kwargs = mock_exec.call_args.kwargs
805 assert kwargs["proposal_type"] == "midi_evolution"
806 assert kwargs["is_draft"] is True
807 assert kwargs["merge_strategy"] == "weave"
808 assert kwargs["selective_domains"] == ["audio", "midi"]
809 assert kwargs["depends_on"] == [_sid("p0")]
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 10 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 12 days ago