agentvibes 5.13.1 → 5.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude/audio/ui/CREDITS.txt +16 -16
  2. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  3. package/.claude/commands/agent-vibes-rdp.md +24 -24
  4. package/.claude/config/audio-effects.cfg.sample +52 -52
  5. package/.claude/docs/TERMUX_SETUP.md +408 -408
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/kokoro-server.py +219 -219
  8. package/.claude/hooks/kokoro-tts.py +141 -141
  9. package/.claude/hooks/party-stage-roster.py +328 -328
  10. package/.claude/hooks/play-tts-kokoro.sh +7 -2
  11. package/.claude/hooks/play-tts-ssh-remote.sh +38 -3
  12. package/.claude/hooks/requirements.txt +6 -6
  13. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  14. package/.claude/hooks/translator.py +237 -237
  15. package/.claude/hooks-windows/kokoro-server.py +219 -219
  16. package/.claude/hooks-windows/kokoro-tts.py +107 -107
  17. package/.claude/hooks-windows/play-tts.ps1 +17 -0
  18. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  19. package/.claude/verbosity.txt +1 -1
  20. package/.clawdbot/README.md +105 -105
  21. package/CLAUDE.md +84 -176
  22. package/README.md +27 -2
  23. package/RELEASE_NOTES.md +68 -0
  24. package/WINDOWS-SETUP.md +208 -208
  25. package/bin/agent-vibes +39 -39
  26. package/bin/mcp-server.js +121 -121
  27. package/bin/test-bmad-pr +78 -78
  28. package/mcp-server/QUICK_START.md +203 -203
  29. package/mcp-server/README.md +345 -345
  30. package/mcp-server/examples/claude_desktop_config.json +11 -11
  31. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  32. package/mcp-server/examples/custom_instructions.md +169 -169
  33. package/mcp-server/install-deps.js +12 -2
  34. package/mcp-server/server.py +2111 -2085
  35. package/mcp-server/test_mcp_correctness.py +504 -504
  36. package/mcp-server/test_windows_script_parity.py +339 -339
  37. package/package.json +6 -3
  38. package/setup-ssh-receiver.ps1 +259 -0
  39. package/setup-windows.ps1 +39 -29
  40. package/src/cli/list-personalities.js +110 -110
  41. package/src/commands/bmad-voices.js +394 -394
  42. package/src/console/brand-colors.js +13 -13
  43. package/src/console/constants/personalities.js +44 -44
  44. package/src/console/tabs/agents-tab.js +14 -4
  45. package/src/console/tabs/help-tab.js +314 -314
  46. package/src/console/tabs/readme-tab.js +272 -272
  47. package/src/console/tabs/settings-tab.js +31 -6
  48. package/src/console/tabs/setup-tab.js +154 -32
  49. package/src/console/tabs/voices-tab.js +48 -6
  50. package/src/console/widgets/destroy-list.js +25 -25
  51. package/src/console/widgets/notice.js +55 -55
  52. package/src/installer/language-screen.js +31 -31
  53. package/src/installer/music-file-input.js +304 -304
  54. package/src/installer.js +91 -35
  55. package/src/services/language-service.js +47 -47
  56. package/src/services/llm-provider-service.js +4 -1
  57. package/src/services/provider-voice-catalog.js +9 -5
  58. package/src/utils/audio-format-validator.js +277 -277
  59. package/src/utils/dependency-checker.js +469 -469
  60. package/src/utils/file-ownership-verifier.js +358 -358
  61. package/src/utils/music-file-validator.js +285 -285
  62. package/src/utils/preview-list-prompt.js +144 -144
  63. package/src/utils/secure-music-storage.js +412 -412
  64. package/templates/agentvibes-receiver.ps1 +311 -0
  65. package/templates/agentvibes-receiver.sh +297 -297
  66. package/voice-assignments.json +8244 -8244
  67. package/.agentvibes/config.json +0 -17
  68. package/.agentvibes/install-manifest.json +0 -362
  69. package/.claude/config/audio-effects.cfg +0 -7
  70. package/.claude/config/audio-effects.cfg.bak-kokoro +0 -7
  71. package/.claude/config/background-music-enabled.txt +0 -1
  72. package/.claude/config/background-music-position.txt +0 -27
  73. package/.claude/config/background-music-volume.txt +0 -1
  74. package/.claude/config/background-music.cfg +0 -1
  75. package/.claude/config/background-music.txt +0 -1
  76. package/.claude/config/language.txt +0 -1
  77. package/.claude/config/personality.txt +0 -1
  78. package/.claude/config/reverb-level.txt +0 -1
  79. package/.claude/config/tts-speech-rate.txt +0 -1
  80. package/.claude/config/tts-verbosity.txt +0 -1
  81. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
  82. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
@@ -1,504 +1,504 @@
1
- #!/usr/bin/env python3
2
- """
3
- File: mcp-server/test_mcp_correctness.py
4
-
5
- AgentVibes - Finally, your AI Agents can Talk Back! Text-to-Speech WITH personality for AI Assistants!
6
- Website: https://agentvibes.org
7
- Repository: https://github.com/paulpreibisch/AgentVibes
8
-
9
- Co-created by Paul Preibisch with Claude AI
10
- Copyright (c) 2025 Paul Preibisch
11
-
12
- Licensed under the Apache License, Version 2.0 (the "License");
13
- you may not use this file except in compliance with the License.
14
- You may obtain a copy of the License at
15
-
16
- http://www.apache.org/licenses/LICENSE-2.0
17
-
18
- Unless required by applicable law or agreed to in writing, software
19
- distributed under the License is distributed on an "AS IS" BASIS,
20
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
- See the License for the specific language governing permissions and
22
- limitations under the License.
23
-
24
- ---
25
-
26
- @fileoverview Regression tests for Story 8.2 (MCP Server Windows Correctness)
27
- @context Proves _run_script branches on exit code (not emoji sniffing), enforces
28
- stdin=DEVNULL + timeout, guards download_extra_voices(auto_yes=False),
29
- serializes the personality/language override critical section, restores
30
- personality non-destructively, and documents the 20% bg-music default.
31
- @architecture pytest unit tests that monkeypatch _run_script / asyncio.create_subprocess_exec
32
- so no real hook scripts or audio playback are invoked.
33
- @related mcp-server/server.py, docs/implementation-artifacts/8-2-mcp-windows-correctness.md
34
- """
35
-
36
- import asyncio
37
- import sys
38
- import time
39
- from pathlib import Path
40
-
41
- import pytest
42
-
43
- sys.path.insert(0, str(Path(__file__).parent))
44
-
45
- from server import AgentVibesServer, ScriptResult, DEFAULT_SCRIPT_TIMEOUT, list_tools
46
-
47
-
48
- class _FakeProc:
49
- """Stand-in for asyncio.subprocess.Process used by text_to_speech's TTS spawn."""
50
-
51
- def __init__(self, on_communicate=None, sleep=0.0, stdout=b"Saved to: fake.wav\n", stderr=b""):
52
- self.returncode = 0
53
- self._on_communicate = on_communicate
54
- self._sleep = sleep
55
- self._stdout = stdout
56
- self._stderr = stderr
57
-
58
- async def communicate(self):
59
- if self._on_communicate:
60
- self._on_communicate()
61
- if self._sleep:
62
- await asyncio.sleep(self._sleep)
63
- return (self._stdout, self._stderr)
64
-
65
- def kill(self):
66
- pass
67
-
68
- async def wait(self):
69
- return 0
70
-
71
-
72
- # ---------------------------------------------------------------------------
73
- # ScriptResult
74
- # ---------------------------------------------------------------------------
75
-
76
- def test_script_result_ok_reflects_returncode():
77
- assert ScriptResult(0, "fine", "").ok is True
78
- assert ScriptResult(1, "", "boom").ok is False
79
- assert ScriptResult(-1, "", "timed out").ok is False
80
-
81
-
82
- def test_script_result_error_detail_prefers_stderr():
83
- assert ScriptResult(1, "stdout text", "stderr text").error_detail == "stderr text"
84
- assert ScriptResult(1, "stdout text", "").error_detail == "stdout text"
85
- assert "exit code" in ScriptResult(2, "", "").error_detail
86
-
87
-
88
- # ---------------------------------------------------------------------------
89
- # H1: exit-code branching, not emoji sniffing
90
- # ---------------------------------------------------------------------------
91
-
92
- def test_set_personality_succeeds_on_plain_text_windows_style_output():
93
- """Windows personality-manager.ps1 prints plain text with no 🎭 marker.
94
- Before the fix, `"🎭" in result` made this report ❌ Failed even on a
95
- successful exit — this is the core Windows false-failure bug (H1)."""
96
- server = AgentVibesServer()
97
-
98
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
99
- return ScriptResult(0, "Personality set to: happy", "")
100
- server._run_script = fake_run_script
101
-
102
- result = asyncio.run(server.set_personality("happy"))
103
-
104
- assert not result.startswith("❌"), f"expected success, got: {result}"
105
- assert "happy" in result
106
-
107
-
108
- def test_set_personality_fails_on_nonzero_exit_despite_matching_marker_text():
109
- """Inverse case: text containing the old marker must not mask a real failure."""
110
- server = AgentVibesServer()
111
-
112
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
113
- return ScriptResult(1, "", "🎭 this looks like success text but exit code says otherwise")
114
- server._run_script = fake_run_script
115
-
116
- result = asyncio.run(server.set_personality("happy"))
117
-
118
- assert result.startswith("❌")
119
-
120
-
121
- @pytest.mark.parametrize(
122
- "method_name,call_args,plain_stdout",
123
- [
124
- ("set_language", ("spanish",), "Language set to: spanish"),
125
- ("set_verbosity", ("high",), "Verbosity set to: high (project-local)"),
126
- ("replay_audio", (1,), "Replaying audio #1: tts-foo.wav"),
127
- ],
128
- )
129
- def test_windows_style_plain_text_success_is_not_treated_as_failure(method_name, call_args, plain_stdout):
130
- """set_language / set_verbosity / replay_audio must all
131
- report success from returncode==0 alone, even when stdout carries none of
132
- the emoji markers the old code sniffed for (✓ / ✅ / 🔊)."""
133
- server = AgentVibesServer()
134
-
135
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
136
- return ScriptResult(0, plain_stdout, "")
137
- server._run_script = fake_run_script
138
-
139
- method = getattr(server, method_name)
140
- result = asyncio.run(method(*call_args))
141
-
142
- assert not result.startswith("❌"), f"{method_name} expected success, got: {result}"
143
-
144
-
145
- def test_set_speed_succeeds_on_plain_text_and_stubs_demo_playback():
146
- server = AgentVibesServer()
147
-
148
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
149
- return ScriptResult(0, "Speech speed set for main voice", "")
150
- server._run_script = fake_run_script
151
-
152
- async def fake_tts(*_args, **_kwargs):
153
- return "✅ Spoke: ok"
154
- server.text_to_speech = fake_tts
155
-
156
- result = asyncio.run(server.set_speed("2x"))
157
-
158
- assert not result.startswith("❌"), result
159
-
160
-
161
- def test_get_speed_and_get_verbosity_surface_script_failure():
162
- server = AgentVibesServer()
163
-
164
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
165
- return ScriptResult(1, "", "speed-manager.sh: command not found")
166
- server._run_script = fake_run_script
167
-
168
- result = asyncio.run(server.get_speed())
169
- assert result.startswith("❌")
170
- assert "command not found" in result
171
-
172
-
173
- # ---------------------------------------------------------------------------
174
- # H3: stdin=DEVNULL + timeout on every spawn
175
- # ---------------------------------------------------------------------------
176
-
177
- def test_run_script_enforces_timeout_instead_of_hanging(tmp_path):
178
- """A script that never exits (simulating a hung/interactive prompt) must
179
- be killed and reported as a failure within the requested timeout — not
180
- hang the calling coroutine (and therefore the MCP server) forever."""
181
- server = AgentVibesServer()
182
- server.hooks_dir = tmp_path
183
-
184
- if server.is_windows:
185
- script = tmp_path / "slow.ps1"
186
- script.write_text("Start-Sleep -Seconds 5\nWrite-Host 'done'\n")
187
- script_name = "slow.ps1"
188
- else:
189
- script = tmp_path / "slow.sh"
190
- script.write_text("#!/usr/bin/env bash\nsleep 5\necho done\n")
191
- script.chmod(0o755)
192
- script_name = "slow.sh"
193
-
194
- start = time.monotonic()
195
- result = asyncio.run(server._run_script(script_name, [], timeout=1.0))
196
- elapsed = time.monotonic() - start
197
-
198
- assert not result.ok
199
- assert "timed out" in result.stderr.lower()
200
- assert elapsed < 4.0, f"_run_script did not honor the timeout (took {elapsed:.1f}s)"
201
-
202
-
203
- def test_run_script_stdin_is_devnull_so_reads_get_eof_not_a_hang(tmp_path):
204
- """download-extra-voices.sh-style scripts that try to read a Y/n
205
- confirmation must see EOF immediately (stdin=DEVNULL) rather than
206
- inheriting the MCP JSON-RPC stdio stream and blocking on it."""
207
- server = AgentVibesServer()
208
- server.hooks_dir = tmp_path
209
-
210
- if server.is_windows:
211
- script = tmp_path / "reads_stdin.ps1"
212
- script.write_text(
213
- "$line = [Console]::In.ReadLine()\n"
214
- "Write-Host \"got:[$line]\"\n"
215
- )
216
- script_name = "reads_stdin.ps1"
217
- else:
218
- script = tmp_path / "reads_stdin.sh"
219
- script.write_text("#!/usr/bin/env bash\nread -r line\necho \"got:[$line]\"\n")
220
- script.chmod(0o755)
221
- script_name = "reads_stdin.sh"
222
-
223
- # If stdin were inherited from a live, never-closing pipe this would hang;
224
- # bounding it with wait_for proves _run_script itself returns promptly.
225
- result = asyncio.run(asyncio.wait_for(server._run_script(script_name, [], timeout=10.0), timeout=15.0))
226
- assert result.ok, f"expected clean EOF-driven exit, got: {result}"
227
-
228
-
229
- def test_text_to_speech_subprocess_spawn_passes_stdin_devnull(monkeypatch):
230
- """text_to_speech's own TTS subprocess spawn (separate from _run_script)
231
- must also pass stdin=DEVNULL."""
232
- server = AgentVibesServer()
233
- captured_kwargs = {}
234
-
235
- async def fake_create_subprocess_exec(*args, **kwargs):
236
- captured_kwargs.update(kwargs)
237
- return _FakeProc()
238
-
239
- monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
240
-
241
- asyncio.run(server.text_to_speech("hello"))
242
-
243
- assert captured_kwargs.get("stdin") == asyncio.subprocess.DEVNULL
244
-
245
-
246
- def test_text_to_speech_with_voice_declares_user_explicit_provenance(monkeypatch):
247
- """Adversarial-review fix (F-1 wiring): when the MCP caller asks for a
248
- specific voice, the spawned player must receive AGENTVIBES_VOICE_SOURCE=
249
- user-explicit so the resolver treats it as a genuine explicit pick and never
250
- demotes it to a per-LLM/default row. Without a voice, it must NOT be set."""
251
- server = AgentVibesServer()
252
- captured = {}
253
-
254
- async def fake_create_subprocess_exec(*args, **kwargs):
255
- captured["env"] = kwargs.get("env")
256
- return _FakeProc()
257
-
258
- monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
259
-
260
- asyncio.run(server.text_to_speech("hello", voice="Aria"))
261
- assert captured["env"].get("AGENTVIBES_VOICE_SOURCE") == "user-explicit"
262
-
263
- captured.clear()
264
- asyncio.run(server.text_to_speech("hello")) # no voice → provenance not forced
265
- assert captured["env"].get("AGENTVIBES_VOICE_SOURCE") is None
266
-
267
-
268
- # ---------------------------------------------------------------------------
269
- # download_extra_voices(auto_yes=False) must never reach the interactive prompt
270
- # ---------------------------------------------------------------------------
271
-
272
- def test_download_extra_voices_without_auto_yes_never_spawns_the_script():
273
- server = AgentVibesServer()
274
- calls = []
275
-
276
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
277
- calls.append((script_name, args, timeout))
278
- return ScriptResult(0, "should not be reached", "")
279
- server._run_script = fake_run_script
280
-
281
- result = asyncio.run(server.download_extra_voices(auto_yes=False))
282
-
283
- assert calls == [], "download-extra-voices.sh must never be spawned without explicit auto_yes=True"
284
- assert not result.startswith("✅")
285
- assert "confirmation" in result.lower() or "auto_yes" in result.lower()
286
-
287
-
288
- def test_download_extra_voices_with_auto_yes_runs_script_with_yes_flag():
289
- server = AgentVibesServer()
290
- calls = []
291
-
292
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
293
- calls.append((script_name, args, timeout))
294
- return ScriptResult(0, "Successfully downloaded 3 voices", "")
295
- server._run_script = fake_run_script
296
-
297
- result = asyncio.run(server.download_extra_voices(auto_yes=True))
298
-
299
- assert len(calls) == 1
300
- script_name, args, timeout = calls[0]
301
- assert script_name == "download-extra-voices.sh"
302
- assert args == ["--yes"]
303
- assert not result.startswith("❌")
304
-
305
-
306
- # ---------------------------------------------------------------------------
307
- # #4: per-call personality/language mutation — concurrency + non-destructive restore
308
- # ---------------------------------------------------------------------------
309
-
310
- def test_concurrent_personality_overrides_are_serialized_by_lock(tmp_path, monkeypatch):
311
- """Two concurrent text_to_speech(personality=...) calls must not have
312
- their "set the temporary personality" critical sections overlap. This
313
- measures actual concurrent presence (via a shared counter held open by
314
- an artificial sleep) rather than relying on incidental asyncio
315
- scheduling order, so it deterministically fails without the lock and
316
- passes with it."""
317
- server = AgentVibesServer()
318
- server._get_config_dir = lambda: tmp_path
319
-
320
- active = []
321
- max_concurrent = {"value": 0}
322
-
323
- async def fake_get_personality():
324
- return "normal"
325
- server._get_personality = fake_get_personality
326
-
327
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
328
- if script_name == server.PERSONALITY_MANAGER_SCRIPT and args[0] == "set" and args[1] != "normal":
329
- active.append(1)
330
- max_concurrent["value"] = max(max_concurrent["value"], len(active))
331
- # Hold the critical section open long enough that, without the
332
- # lock, the other concurrent call would enter it too.
333
- await asyncio.sleep(0.05)
334
- active.pop()
335
- return ScriptResult(0, "ok", "")
336
- server._run_script = fake_run_script
337
-
338
- async def fake_create_subprocess_exec(*_args, **_kwargs):
339
- return _FakeProc(sleep=0.01)
340
-
341
- monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
342
-
343
- async def run_both():
344
- await asyncio.gather(
345
- server.text_to_speech("hello", personality="flirty"),
346
- server.text_to_speech("world", personality="sarcastic"),
347
- )
348
-
349
- asyncio.run(run_both())
350
-
351
- assert max_concurrent["value"] == 1, (
352
- "concurrent text_to_speech(personality=...) calls overlapped their "
353
- "'set personality' critical sections — self._override_lock is not "
354
- "serializing them"
355
- )
356
-
357
-
358
- def test_personality_restore_deletes_file_when_none_existed_before(tmp_path, monkeypatch):
359
- """Non-destructive-config rule: a temporary per-call personality override
360
- must not leave tts-personality.txt behind if no such file existed before
361
- the call (personality-manager.sh's `set` has no delete-on-default
362
- behavior, unlike language-manager.sh's `set english`)."""
363
- server = AgentVibesServer()
364
- server._get_config_dir = lambda: tmp_path
365
-
366
- async def fake_get_personality():
367
- return "normal"
368
- server._get_personality = fake_get_personality
369
-
370
- run_script_calls = []
371
-
372
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
373
- run_script_calls.append((script_name, tuple(args)))
374
- return ScriptResult(0, "ok", "")
375
- server._run_script = fake_run_script
376
-
377
- async def fake_create_subprocess_exec(*_args, **_kwargs):
378
- return _FakeProc()
379
- monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
380
-
381
- personality_file = tmp_path / "tts-personality.txt"
382
- assert not personality_file.exists()
383
-
384
- asyncio.run(server.text_to_speech("hi", personality="flirty"))
385
-
386
- assert not personality_file.exists(), (
387
- "restoring a per-call personality override must delete the file when "
388
- "none existed before, not write 'normal' into a file that never existed"
389
- )
390
- set_calls = [c for c in run_script_calls if c[0] == server.PERSONALITY_MANAGER_SCRIPT]
391
- assert set_calls == [(server.PERSONALITY_MANAGER_SCRIPT, ("set", "flirty"))]
392
-
393
-
394
- def test_personality_restore_calls_script_when_file_existed_before(tmp_path, monkeypatch):
395
- server = AgentVibesServer()
396
- server._get_config_dir = lambda: tmp_path
397
- (tmp_path / "tts-personality.txt").write_text("grumpy")
398
-
399
- async def fake_get_personality():
400
- return "grumpy"
401
- server._get_personality = fake_get_personality
402
-
403
- run_script_calls = []
404
-
405
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
406
- run_script_calls.append((script_name, tuple(args)))
407
- return ScriptResult(0, "ok", "")
408
- server._run_script = fake_run_script
409
-
410
- async def fake_create_subprocess_exec(*_args, **_kwargs):
411
- return _FakeProc()
412
- monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
413
-
414
- asyncio.run(server.text_to_speech("hi", personality="flirty"))
415
-
416
- set_calls = [c for c in run_script_calls if c[0] == server.PERSONALITY_MANAGER_SCRIPT]
417
- assert set_calls == [
418
- (server.PERSONALITY_MANAGER_SCRIPT, ("set", "flirty")),
419
- (server.PERSONALITY_MANAGER_SCRIPT, ("set", "grumpy")),
420
- ]
421
- assert (tmp_path / "tts-personality.txt").read_text() == "grumpy"
422
-
423
-
424
- def test_personality_restore_reads_original_from_config_dir_not_get_personality(tmp_path, monkeypatch):
425
- """Code-review regression: the original value to restore must be read from
426
- the SAME file the manager writes to (the config dir), NOT _get_personality()
427
- — which reads a different set of dirs (package, then global ~/.claude) and,
428
- from inside a host project, returns the wrong value. Restoring that wrong
429
- value would overwrite the project's real personality (Non-Destructive Rule).
430
-
431
- Here the project config file says 'cheerful' but _get_personality() returns
432
- a divergent 'normal' (simulating the package/global lookup). Restore MUST
433
- put back 'cheerful' (the real project value), never 'normal'.
434
- """
435
- server = AgentVibesServer()
436
- server._get_config_dir = lambda: tmp_path
437
- (tmp_path / "tts-personality.txt").write_text("cheerful")
438
-
439
- async def divergent_get_personality():
440
- return "normal" # what the OLD code would have (wrongly) restored
441
- server._get_personality = divergent_get_personality
442
-
443
- run_script_calls = []
444
-
445
- async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
446
- run_script_calls.append((script_name, tuple(args)))
447
- # emulate personality-manager writing the value into the config dir
448
- if script_name == server.PERSONALITY_MANAGER_SCRIPT and args[:1] == ["set"]:
449
- (tmp_path / "tts-personality.txt").write_text(args[1])
450
- return ScriptResult(0, "ok", "")
451
- server._run_script = fake_run_script
452
-
453
- async def fake_create_subprocess_exec(*_args, **_kwargs):
454
- return _FakeProc()
455
- monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
456
-
457
- asyncio.run(server.text_to_speech("hi", personality="pirate"))
458
-
459
- set_calls = [c for c in run_script_calls if c[0] == server.PERSONALITY_MANAGER_SCRIPT]
460
- assert set_calls == [
461
- (server.PERSONALITY_MANAGER_SCRIPT, ("set", "pirate")),
462
- (server.PERSONALITY_MANAGER_SCRIPT, ("set", "cheerful")),
463
- ], "restore must use the config-dir value 'cheerful', not _get_personality()'s 'normal'"
464
- assert (tmp_path / "tts-personality.txt").read_text() == "cheerful"
465
-
466
-
467
- # ---------------------------------------------------------------------------
468
- # #6: background-music default volume description is 20%, not 30%
469
- # ---------------------------------------------------------------------------
470
-
471
- def test_tool_descriptions_state_20_percent_default_not_30():
472
- tools = asyncio.run(list_tools())
473
- by_name = {t.name: t for t in tools}
474
-
475
- enable_desc = by_name["enable_background_music"].description
476
- assert "20%" in enable_desc
477
- assert "30%" not in enable_desc
478
-
479
- volume_desc = by_name["set_background_music_volume"].inputSchema["properties"]["volume"]["description"]
480
- assert "0.20" in volume_desc
481
- assert "0.30" not in volume_desc
482
-
483
-
484
- def test_valid_providers_derives_from_catalog_and_excludes_elevenlabs_on_windows():
485
- """AVI-S9.5: _valid_providers() derives the per-platform allowlist from
486
- provider-catalog.json (or the embedded fallback ≡ catalog). ElevenLabs has
487
- no Windows runtime (no play-tts-elevenlabs.ps1), so it must be ABSENT from
488
- the Windows allowlist and PRESENT on non-Windows — the exact bug class the
489
- Provider Catalog closes."""
490
- server = AgentVibesServer()
491
-
492
- server.is_windows = True
493
- win = server._valid_providers()
494
- assert "elevenlabs" not in win, f"elevenlabs must not be a Windows provider: {win}"
495
- assert "kokoro" in win and "soprano" in win
496
-
497
- server.is_windows = False
498
- non_win = server._valid_providers()
499
- assert "elevenlabs" in non_win, f"elevenlabs must be a non-Windows provider: {non_win}"
500
- assert "kokoro" in non_win and "soprano" in non_win
501
-
502
-
503
- if __name__ == "__main__":
504
- sys.exit(pytest.main([__file__, "-v"]))
1
+ #!/usr/bin/env python3
2
+ """
3
+ File: mcp-server/test_mcp_correctness.py
4
+
5
+ AgentVibes - Finally, your AI Agents can Talk Back! Text-to-Speech WITH personality for AI Assistants!
6
+ Website: https://agentvibes.org
7
+ Repository: https://github.com/paulpreibisch/AgentVibes
8
+
9
+ Co-created by Paul Preibisch with Claude AI
10
+ Copyright (c) 2025 Paul Preibisch
11
+
12
+ Licensed under the Apache License, Version 2.0 (the "License");
13
+ you may not use this file except in compliance with the License.
14
+ You may obtain a copy of the License at
15
+
16
+ http://www.apache.org/licenses/LICENSE-2.0
17
+
18
+ Unless required by applicable law or agreed to in writing, software
19
+ distributed under the License is distributed on an "AS IS" BASIS,
20
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ See the License for the specific language governing permissions and
22
+ limitations under the License.
23
+
24
+ ---
25
+
26
+ @fileoverview Regression tests for Story 8.2 (MCP Server Windows Correctness)
27
+ @context Proves _run_script branches on exit code (not emoji sniffing), enforces
28
+ stdin=DEVNULL + timeout, guards download_extra_voices(auto_yes=False),
29
+ serializes the personality/language override critical section, restores
30
+ personality non-destructively, and documents the 20% bg-music default.
31
+ @architecture pytest unit tests that monkeypatch _run_script / asyncio.create_subprocess_exec
32
+ so no real hook scripts or audio playback are invoked.
33
+ @related mcp-server/server.py, docs/implementation-artifacts/8-2-mcp-windows-correctness.md
34
+ """
35
+
36
+ import asyncio
37
+ import sys
38
+ import time
39
+ from pathlib import Path
40
+
41
+ import pytest
42
+
43
+ sys.path.insert(0, str(Path(__file__).parent))
44
+
45
+ from server import AgentVibesServer, ScriptResult, DEFAULT_SCRIPT_TIMEOUT, list_tools
46
+
47
+
48
+ class _FakeProc:
49
+ """Stand-in for asyncio.subprocess.Process used by text_to_speech's TTS spawn."""
50
+
51
+ def __init__(self, on_communicate=None, sleep=0.0, stdout=b"Saved to: fake.wav\n", stderr=b""):
52
+ self.returncode = 0
53
+ self._on_communicate = on_communicate
54
+ self._sleep = sleep
55
+ self._stdout = stdout
56
+ self._stderr = stderr
57
+
58
+ async def communicate(self):
59
+ if self._on_communicate:
60
+ self._on_communicate()
61
+ if self._sleep:
62
+ await asyncio.sleep(self._sleep)
63
+ return (self._stdout, self._stderr)
64
+
65
+ def kill(self):
66
+ pass
67
+
68
+ async def wait(self):
69
+ return 0
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # ScriptResult
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def test_script_result_ok_reflects_returncode():
77
+ assert ScriptResult(0, "fine", "").ok is True
78
+ assert ScriptResult(1, "", "boom").ok is False
79
+ assert ScriptResult(-1, "", "timed out").ok is False
80
+
81
+
82
+ def test_script_result_error_detail_prefers_stderr():
83
+ assert ScriptResult(1, "stdout text", "stderr text").error_detail == "stderr text"
84
+ assert ScriptResult(1, "stdout text", "").error_detail == "stdout text"
85
+ assert "exit code" in ScriptResult(2, "", "").error_detail
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # H1: exit-code branching, not emoji sniffing
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def test_set_personality_succeeds_on_plain_text_windows_style_output():
93
+ """Windows personality-manager.ps1 prints plain text with no 🎭 marker.
94
+ Before the fix, `"🎭" in result` made this report ❌ Failed even on a
95
+ successful exit — this is the core Windows false-failure bug (H1)."""
96
+ server = AgentVibesServer()
97
+
98
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
99
+ return ScriptResult(0, "Personality set to: happy", "")
100
+ server._run_script = fake_run_script
101
+
102
+ result = asyncio.run(server.set_personality("happy"))
103
+
104
+ assert not result.startswith("❌"), f"expected success, got: {result}"
105
+ assert "happy" in result
106
+
107
+
108
+ def test_set_personality_fails_on_nonzero_exit_despite_matching_marker_text():
109
+ """Inverse case: text containing the old marker must not mask a real failure."""
110
+ server = AgentVibesServer()
111
+
112
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
113
+ return ScriptResult(1, "", "🎭 this looks like success text but exit code says otherwise")
114
+ server._run_script = fake_run_script
115
+
116
+ result = asyncio.run(server.set_personality("happy"))
117
+
118
+ assert result.startswith("❌")
119
+
120
+
121
+ @pytest.mark.parametrize(
122
+ "method_name,call_args,plain_stdout",
123
+ [
124
+ ("set_language", ("spanish",), "Language set to: spanish"),
125
+ ("set_verbosity", ("high",), "Verbosity set to: high (project-local)"),
126
+ ("replay_audio", (1,), "Replaying audio #1: tts-foo.wav"),
127
+ ],
128
+ )
129
+ def test_windows_style_plain_text_success_is_not_treated_as_failure(method_name, call_args, plain_stdout):
130
+ """set_language / set_verbosity / replay_audio must all
131
+ report success from returncode==0 alone, even when stdout carries none of
132
+ the emoji markers the old code sniffed for (✓ / ✅ / 🔊)."""
133
+ server = AgentVibesServer()
134
+
135
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
136
+ return ScriptResult(0, plain_stdout, "")
137
+ server._run_script = fake_run_script
138
+
139
+ method = getattr(server, method_name)
140
+ result = asyncio.run(method(*call_args))
141
+
142
+ assert not result.startswith("❌"), f"{method_name} expected success, got: {result}"
143
+
144
+
145
+ def test_set_speed_succeeds_on_plain_text_and_stubs_demo_playback():
146
+ server = AgentVibesServer()
147
+
148
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
149
+ return ScriptResult(0, "Speech speed set for main voice", "")
150
+ server._run_script = fake_run_script
151
+
152
+ async def fake_tts(*_args, **_kwargs):
153
+ return "✅ Spoke: ok"
154
+ server.text_to_speech = fake_tts
155
+
156
+ result = asyncio.run(server.set_speed("2x"))
157
+
158
+ assert not result.startswith("❌"), result
159
+
160
+
161
+ def test_get_speed_and_get_verbosity_surface_script_failure():
162
+ server = AgentVibesServer()
163
+
164
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
165
+ return ScriptResult(1, "", "speed-manager.sh: command not found")
166
+ server._run_script = fake_run_script
167
+
168
+ result = asyncio.run(server.get_speed())
169
+ assert result.startswith("❌")
170
+ assert "command not found" in result
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # H3: stdin=DEVNULL + timeout on every spawn
175
+ # ---------------------------------------------------------------------------
176
+
177
+ def test_run_script_enforces_timeout_instead_of_hanging(tmp_path):
178
+ """A script that never exits (simulating a hung/interactive prompt) must
179
+ be killed and reported as a failure within the requested timeout — not
180
+ hang the calling coroutine (and therefore the MCP server) forever."""
181
+ server = AgentVibesServer()
182
+ server.hooks_dir = tmp_path
183
+
184
+ if server.is_windows:
185
+ script = tmp_path / "slow.ps1"
186
+ script.write_text("Start-Sleep -Seconds 5\nWrite-Host 'done'\n")
187
+ script_name = "slow.ps1"
188
+ else:
189
+ script = tmp_path / "slow.sh"
190
+ script.write_text("#!/usr/bin/env bash\nsleep 5\necho done\n")
191
+ script.chmod(0o755)
192
+ script_name = "slow.sh"
193
+
194
+ start = time.monotonic()
195
+ result = asyncio.run(server._run_script(script_name, [], timeout=1.0))
196
+ elapsed = time.monotonic() - start
197
+
198
+ assert not result.ok
199
+ assert "timed out" in result.stderr.lower()
200
+ assert elapsed < 4.0, f"_run_script did not honor the timeout (took {elapsed:.1f}s)"
201
+
202
+
203
+ def test_run_script_stdin_is_devnull_so_reads_get_eof_not_a_hang(tmp_path):
204
+ """download-extra-voices.sh-style scripts that try to read a Y/n
205
+ confirmation must see EOF immediately (stdin=DEVNULL) rather than
206
+ inheriting the MCP JSON-RPC stdio stream and blocking on it."""
207
+ server = AgentVibesServer()
208
+ server.hooks_dir = tmp_path
209
+
210
+ if server.is_windows:
211
+ script = tmp_path / "reads_stdin.ps1"
212
+ script.write_text(
213
+ "$line = [Console]::In.ReadLine()\n"
214
+ "Write-Host \"got:[$line]\"\n"
215
+ )
216
+ script_name = "reads_stdin.ps1"
217
+ else:
218
+ script = tmp_path / "reads_stdin.sh"
219
+ script.write_text("#!/usr/bin/env bash\nread -r line\necho \"got:[$line]\"\n")
220
+ script.chmod(0o755)
221
+ script_name = "reads_stdin.sh"
222
+
223
+ # If stdin were inherited from a live, never-closing pipe this would hang;
224
+ # bounding it with wait_for proves _run_script itself returns promptly.
225
+ result = asyncio.run(asyncio.wait_for(server._run_script(script_name, [], timeout=10.0), timeout=15.0))
226
+ assert result.ok, f"expected clean EOF-driven exit, got: {result}"
227
+
228
+
229
+ def test_text_to_speech_subprocess_spawn_passes_stdin_devnull(monkeypatch):
230
+ """text_to_speech's own TTS subprocess spawn (separate from _run_script)
231
+ must also pass stdin=DEVNULL."""
232
+ server = AgentVibesServer()
233
+ captured_kwargs = {}
234
+
235
+ async def fake_create_subprocess_exec(*args, **kwargs):
236
+ captured_kwargs.update(kwargs)
237
+ return _FakeProc()
238
+
239
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
240
+
241
+ asyncio.run(server.text_to_speech("hello"))
242
+
243
+ assert captured_kwargs.get("stdin") == asyncio.subprocess.DEVNULL
244
+
245
+
246
+ def test_text_to_speech_with_voice_declares_user_explicit_provenance(monkeypatch):
247
+ """Adversarial-review fix (F-1 wiring): when the MCP caller asks for a
248
+ specific voice, the spawned player must receive AGENTVIBES_VOICE_SOURCE=
249
+ user-explicit so the resolver treats it as a genuine explicit pick and never
250
+ demotes it to a per-LLM/default row. Without a voice, it must NOT be set."""
251
+ server = AgentVibesServer()
252
+ captured = {}
253
+
254
+ async def fake_create_subprocess_exec(*args, **kwargs):
255
+ captured["env"] = kwargs.get("env")
256
+ return _FakeProc()
257
+
258
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
259
+
260
+ asyncio.run(server.text_to_speech("hello", voice="Aria"))
261
+ assert captured["env"].get("AGENTVIBES_VOICE_SOURCE") == "user-explicit"
262
+
263
+ captured.clear()
264
+ asyncio.run(server.text_to_speech("hello")) # no voice → provenance not forced
265
+ assert captured["env"].get("AGENTVIBES_VOICE_SOURCE") is None
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # download_extra_voices(auto_yes=False) must never reach the interactive prompt
270
+ # ---------------------------------------------------------------------------
271
+
272
+ def test_download_extra_voices_without_auto_yes_never_spawns_the_script():
273
+ server = AgentVibesServer()
274
+ calls = []
275
+
276
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
277
+ calls.append((script_name, args, timeout))
278
+ return ScriptResult(0, "should not be reached", "")
279
+ server._run_script = fake_run_script
280
+
281
+ result = asyncio.run(server.download_extra_voices(auto_yes=False))
282
+
283
+ assert calls == [], "download-extra-voices.sh must never be spawned without explicit auto_yes=True"
284
+ assert not result.startswith("✅")
285
+ assert "confirmation" in result.lower() or "auto_yes" in result.lower()
286
+
287
+
288
+ def test_download_extra_voices_with_auto_yes_runs_script_with_yes_flag():
289
+ server = AgentVibesServer()
290
+ calls = []
291
+
292
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
293
+ calls.append((script_name, args, timeout))
294
+ return ScriptResult(0, "Successfully downloaded 3 voices", "")
295
+ server._run_script = fake_run_script
296
+
297
+ result = asyncio.run(server.download_extra_voices(auto_yes=True))
298
+
299
+ assert len(calls) == 1
300
+ script_name, args, timeout = calls[0]
301
+ assert script_name == "download-extra-voices.sh"
302
+ assert args == ["--yes"]
303
+ assert not result.startswith("❌")
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # #4: per-call personality/language mutation — concurrency + non-destructive restore
308
+ # ---------------------------------------------------------------------------
309
+
310
+ def test_concurrent_personality_overrides_are_serialized_by_lock(tmp_path, monkeypatch):
311
+ """Two concurrent text_to_speech(personality=...) calls must not have
312
+ their "set the temporary personality" critical sections overlap. This
313
+ measures actual concurrent presence (via a shared counter held open by
314
+ an artificial sleep) rather than relying on incidental asyncio
315
+ scheduling order, so it deterministically fails without the lock and
316
+ passes with it."""
317
+ server = AgentVibesServer()
318
+ server._get_config_dir = lambda: tmp_path
319
+
320
+ active = []
321
+ max_concurrent = {"value": 0}
322
+
323
+ async def fake_get_personality():
324
+ return "normal"
325
+ server._get_personality = fake_get_personality
326
+
327
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
328
+ if script_name == server.PERSONALITY_MANAGER_SCRIPT and args[0] == "set" and args[1] != "normal":
329
+ active.append(1)
330
+ max_concurrent["value"] = max(max_concurrent["value"], len(active))
331
+ # Hold the critical section open long enough that, without the
332
+ # lock, the other concurrent call would enter it too.
333
+ await asyncio.sleep(0.05)
334
+ active.pop()
335
+ return ScriptResult(0, "ok", "")
336
+ server._run_script = fake_run_script
337
+
338
+ async def fake_create_subprocess_exec(*_args, **_kwargs):
339
+ return _FakeProc(sleep=0.01)
340
+
341
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
342
+
343
+ async def run_both():
344
+ await asyncio.gather(
345
+ server.text_to_speech("hello", personality="flirty"),
346
+ server.text_to_speech("world", personality="sarcastic"),
347
+ )
348
+
349
+ asyncio.run(run_both())
350
+
351
+ assert max_concurrent["value"] == 1, (
352
+ "concurrent text_to_speech(personality=...) calls overlapped their "
353
+ "'set personality' critical sections — self._override_lock is not "
354
+ "serializing them"
355
+ )
356
+
357
+
358
+ def test_personality_restore_deletes_file_when_none_existed_before(tmp_path, monkeypatch):
359
+ """Non-destructive-config rule: a temporary per-call personality override
360
+ must not leave tts-personality.txt behind if no such file existed before
361
+ the call (personality-manager.sh's `set` has no delete-on-default
362
+ behavior, unlike language-manager.sh's `set english`)."""
363
+ server = AgentVibesServer()
364
+ server._get_config_dir = lambda: tmp_path
365
+
366
+ async def fake_get_personality():
367
+ return "normal"
368
+ server._get_personality = fake_get_personality
369
+
370
+ run_script_calls = []
371
+
372
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
373
+ run_script_calls.append((script_name, tuple(args)))
374
+ return ScriptResult(0, "ok", "")
375
+ server._run_script = fake_run_script
376
+
377
+ async def fake_create_subprocess_exec(*_args, **_kwargs):
378
+ return _FakeProc()
379
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
380
+
381
+ personality_file = tmp_path / "tts-personality.txt"
382
+ assert not personality_file.exists()
383
+
384
+ asyncio.run(server.text_to_speech("hi", personality="flirty"))
385
+
386
+ assert not personality_file.exists(), (
387
+ "restoring a per-call personality override must delete the file when "
388
+ "none existed before, not write 'normal' into a file that never existed"
389
+ )
390
+ set_calls = [c for c in run_script_calls if c[0] == server.PERSONALITY_MANAGER_SCRIPT]
391
+ assert set_calls == [(server.PERSONALITY_MANAGER_SCRIPT, ("set", "flirty"))]
392
+
393
+
394
+ def test_personality_restore_calls_script_when_file_existed_before(tmp_path, monkeypatch):
395
+ server = AgentVibesServer()
396
+ server._get_config_dir = lambda: tmp_path
397
+ (tmp_path / "tts-personality.txt").write_text("grumpy")
398
+
399
+ async def fake_get_personality():
400
+ return "grumpy"
401
+ server._get_personality = fake_get_personality
402
+
403
+ run_script_calls = []
404
+
405
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
406
+ run_script_calls.append((script_name, tuple(args)))
407
+ return ScriptResult(0, "ok", "")
408
+ server._run_script = fake_run_script
409
+
410
+ async def fake_create_subprocess_exec(*_args, **_kwargs):
411
+ return _FakeProc()
412
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
413
+
414
+ asyncio.run(server.text_to_speech("hi", personality="flirty"))
415
+
416
+ set_calls = [c for c in run_script_calls if c[0] == server.PERSONALITY_MANAGER_SCRIPT]
417
+ assert set_calls == [
418
+ (server.PERSONALITY_MANAGER_SCRIPT, ("set", "flirty")),
419
+ (server.PERSONALITY_MANAGER_SCRIPT, ("set", "grumpy")),
420
+ ]
421
+ assert (tmp_path / "tts-personality.txt").read_text() == "grumpy"
422
+
423
+
424
+ def test_personality_restore_reads_original_from_config_dir_not_get_personality(tmp_path, monkeypatch):
425
+ """Code-review regression: the original value to restore must be read from
426
+ the SAME file the manager writes to (the config dir), NOT _get_personality()
427
+ — which reads a different set of dirs (package, then global ~/.claude) and,
428
+ from inside a host project, returns the wrong value. Restoring that wrong
429
+ value would overwrite the project's real personality (Non-Destructive Rule).
430
+
431
+ Here the project config file says 'cheerful' but _get_personality() returns
432
+ a divergent 'normal' (simulating the package/global lookup). Restore MUST
433
+ put back 'cheerful' (the real project value), never 'normal'.
434
+ """
435
+ server = AgentVibesServer()
436
+ server._get_config_dir = lambda: tmp_path
437
+ (tmp_path / "tts-personality.txt").write_text("cheerful")
438
+
439
+ async def divergent_get_personality():
440
+ return "normal" # what the OLD code would have (wrongly) restored
441
+ server._get_personality = divergent_get_personality
442
+
443
+ run_script_calls = []
444
+
445
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
446
+ run_script_calls.append((script_name, tuple(args)))
447
+ # emulate personality-manager writing the value into the config dir
448
+ if script_name == server.PERSONALITY_MANAGER_SCRIPT and args[:1] == ["set"]:
449
+ (tmp_path / "tts-personality.txt").write_text(args[1])
450
+ return ScriptResult(0, "ok", "")
451
+ server._run_script = fake_run_script
452
+
453
+ async def fake_create_subprocess_exec(*_args, **_kwargs):
454
+ return _FakeProc()
455
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
456
+
457
+ asyncio.run(server.text_to_speech("hi", personality="pirate"))
458
+
459
+ set_calls = [c for c in run_script_calls if c[0] == server.PERSONALITY_MANAGER_SCRIPT]
460
+ assert set_calls == [
461
+ (server.PERSONALITY_MANAGER_SCRIPT, ("set", "pirate")),
462
+ (server.PERSONALITY_MANAGER_SCRIPT, ("set", "cheerful")),
463
+ ], "restore must use the config-dir value 'cheerful', not _get_personality()'s 'normal'"
464
+ assert (tmp_path / "tts-personality.txt").read_text() == "cheerful"
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # #6: background-music default volume description is 20%, not 30%
469
+ # ---------------------------------------------------------------------------
470
+
471
+ def test_tool_descriptions_state_20_percent_default_not_30():
472
+ tools = asyncio.run(list_tools())
473
+ by_name = {t.name: t for t in tools}
474
+
475
+ enable_desc = by_name["enable_background_music"].description
476
+ assert "20%" in enable_desc
477
+ assert "30%" not in enable_desc
478
+
479
+ volume_desc = by_name["set_background_music_volume"].inputSchema["properties"]["volume"]["description"]
480
+ assert "0.20" in volume_desc
481
+ assert "0.30" not in volume_desc
482
+
483
+
484
+ def test_valid_providers_derives_from_catalog_and_excludes_elevenlabs_on_windows():
485
+ """AVI-S9.5: _valid_providers() derives the per-platform allowlist from
486
+ provider-catalog.json (or the embedded fallback ≡ catalog). ElevenLabs has
487
+ no Windows runtime (no play-tts-elevenlabs.ps1), so it must be ABSENT from
488
+ the Windows allowlist and PRESENT on non-Windows — the exact bug class the
489
+ Provider Catalog closes."""
490
+ server = AgentVibesServer()
491
+
492
+ server.is_windows = True
493
+ win = server._valid_providers()
494
+ assert "elevenlabs" not in win, f"elevenlabs must not be a Windows provider: {win}"
495
+ assert "kokoro" in win and "soprano" in win
496
+
497
+ server.is_windows = False
498
+ non_win = server._valid_providers()
499
+ assert "elevenlabs" in non_win, f"elevenlabs must be a non-Windows provider: {non_win}"
500
+ assert "kokoro" in non_win and "soprano" in non_win
501
+
502
+
503
+ if __name__ == "__main__":
504
+ sys.exit(pytest.main([__file__, "-v"]))