agentvibes 5.11.2 → 5.12.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 (54) hide show
  1. package/.claude/config/audio-effects.cfg +1 -1
  2. package/.claude/config/audio-effects.cfg.bak-kokoro +7 -0
  3. package/.claude/github-star-reminder.txt +1 -1
  4. package/.claude/hooks/audio-processor.sh +1 -1
  5. package/.claude/hooks/background-music-manager.sh +2 -1
  6. package/.claude/hooks/bmad-speak-enhanced.sh +37 -22
  7. package/.claude/hooks/bmad-speak.sh +26 -8
  8. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +1 -1
  9. package/.claude/hooks/play-tts-kokoro.sh +9 -0
  10. package/.claude/hooks/play-tts-piper.sh +13 -1
  11. package/.claude/hooks/play-tts-ssh-remote.sh +117 -7
  12. package/.claude/hooks/play-tts-termux-ssh.sh +1 -1
  13. package/.claude/hooks/play-tts.sh +106 -13
  14. package/.claude/hooks-windows/background-music-manager.ps1 +2 -1
  15. package/.claude/hooks-windows/play-tts-kokoro.ps1 +14 -0
  16. package/.claude/hooks-windows/play-tts.ps1 +163 -30
  17. package/.claude/hooks-windows/tts-watcher.ps1 +55 -0
  18. package/.claude/hooks-windows/voice-manager-windows.ps1 +101 -1
  19. package/README.md +11 -6
  20. package/RELEASE_NOTES.md +67 -0
  21. package/bin/mcp-server.sh +6 -19
  22. package/bin/resolve-utterance.js +137 -0
  23. package/mcp-server/server.py +280 -99
  24. package/mcp-server/test_mcp_correctness.py +486 -0
  25. package/mcp-server/test_windows_script_parity.py +341 -336
  26. package/package.json +3 -2
  27. package/setup-windows.ps1 +867 -815
  28. package/src/console/app.js +14 -11
  29. package/src/console/footer-config.js +0 -4
  30. package/src/console/navigation.js +0 -1
  31. package/src/console/tabs/agents-tab.js +96 -11
  32. package/src/console/tabs/music-tab.js +108 -3
  33. package/src/console/tabs/placeholder-tab.js +0 -2
  34. package/src/console/tabs/settings-tab.js +41 -2
  35. package/src/console/tabs/setup-tab.js +34 -6
  36. package/src/console/tabs/voices-tab.js +19 -9
  37. package/src/console/widgets/track-picker.js +3 -3
  38. package/src/i18n/de.js +0 -1
  39. package/src/i18n/en.js +0 -1
  40. package/src/i18n/es.js +0 -1
  41. package/src/i18n/fr.js +0 -1
  42. package/src/i18n/hi.js +0 -1
  43. package/src/i18n/ja.js +0 -1
  44. package/src/i18n/ko.js +0 -1
  45. package/src/i18n/pt.js +0 -1
  46. package/src/i18n/zh-CN.js +0 -1
  47. package/src/installer.js +205 -15
  48. package/src/services/config-service.js +264 -264
  49. package/src/services/llm-provider-service.js +7 -7
  50. package/src/services/navigation-service.js +1 -1
  51. package/src/services/utterance-loader.js +280 -0
  52. package/src/services/utterance-resolver.js +526 -0
  53. package/templates/agentvibes-receiver.sh +74 -12
  54. package/voice-assignments.json +8245 -0
@@ -0,0 +1,486 @@
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_learn_mode", (True,), "Learn mode enabled"),
126
+ ("set_verbosity", ("high",), "Verbosity set to: high (project-local)"),
127
+ ("replay_audio", (1,), "Replaying audio #1: tts-foo.wav"),
128
+ ],
129
+ )
130
+ def test_windows_style_plain_text_success_is_not_treated_as_failure(method_name, call_args, plain_stdout):
131
+ """set_language / set_learn_mode / set_verbosity / replay_audio must all
132
+ report success from returncode==0 alone, even when stdout carries none of
133
+ the emoji markers the old code sniffed for (✓ / ✅ / 🔊)."""
134
+ server = AgentVibesServer()
135
+
136
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
137
+ return ScriptResult(0, plain_stdout, "")
138
+ server._run_script = fake_run_script
139
+
140
+ method = getattr(server, method_name)
141
+ result = asyncio.run(method(*call_args))
142
+
143
+ assert not result.startswith("❌"), f"{method_name} expected success, got: {result}"
144
+
145
+
146
+ def test_set_speed_succeeds_on_plain_text_and_stubs_demo_playback():
147
+ server = AgentVibesServer()
148
+
149
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
150
+ return ScriptResult(0, "Speech speed set for main voice", "")
151
+ server._run_script = fake_run_script
152
+
153
+ async def fake_tts(*_args, **_kwargs):
154
+ return "✅ Spoke: ok"
155
+ server.text_to_speech = fake_tts
156
+
157
+ result = asyncio.run(server.set_speed("2x"))
158
+
159
+ assert not result.startswith("❌"), result
160
+
161
+
162
+ def test_get_speed_and_get_verbosity_surface_script_failure():
163
+ server = AgentVibesServer()
164
+
165
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
166
+ return ScriptResult(1, "", "speed-manager.sh: command not found")
167
+ server._run_script = fake_run_script
168
+
169
+ result = asyncio.run(server.get_speed())
170
+ assert result.startswith("❌")
171
+ assert "command not found" in result
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # H3: stdin=DEVNULL + timeout on every spawn
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def test_run_script_enforces_timeout_instead_of_hanging(tmp_path):
179
+ """A script that never exits (simulating a hung/interactive prompt) must
180
+ be killed and reported as a failure within the requested timeout — not
181
+ hang the calling coroutine (and therefore the MCP server) forever."""
182
+ server = AgentVibesServer()
183
+ server.hooks_dir = tmp_path
184
+
185
+ if server.is_windows:
186
+ script = tmp_path / "slow.ps1"
187
+ script.write_text("Start-Sleep -Seconds 5\nWrite-Host 'done'\n")
188
+ script_name = "slow.ps1"
189
+ else:
190
+ script = tmp_path / "slow.sh"
191
+ script.write_text("#!/usr/bin/env bash\nsleep 5\necho done\n")
192
+ script.chmod(0o755)
193
+ script_name = "slow.sh"
194
+
195
+ start = time.monotonic()
196
+ result = asyncio.run(server._run_script(script_name, [], timeout=1.0))
197
+ elapsed = time.monotonic() - start
198
+
199
+ assert not result.ok
200
+ assert "timed out" in result.stderr.lower()
201
+ assert elapsed < 4.0, f"_run_script did not honor the timeout (took {elapsed:.1f}s)"
202
+
203
+
204
+ def test_run_script_stdin_is_devnull_so_reads_get_eof_not_a_hang(tmp_path):
205
+ """download-extra-voices.sh-style scripts that try to read a Y/n
206
+ confirmation must see EOF immediately (stdin=DEVNULL) rather than
207
+ inheriting the MCP JSON-RPC stdio stream and blocking on it."""
208
+ server = AgentVibesServer()
209
+ server.hooks_dir = tmp_path
210
+
211
+ if server.is_windows:
212
+ script = tmp_path / "reads_stdin.ps1"
213
+ script.write_text(
214
+ "$line = [Console]::In.ReadLine()\n"
215
+ "Write-Host \"got:[$line]\"\n"
216
+ )
217
+ script_name = "reads_stdin.ps1"
218
+ else:
219
+ script = tmp_path / "reads_stdin.sh"
220
+ script.write_text("#!/usr/bin/env bash\nread -r line\necho \"got:[$line]\"\n")
221
+ script.chmod(0o755)
222
+ script_name = "reads_stdin.sh"
223
+
224
+ # If stdin were inherited from a live, never-closing pipe this would hang;
225
+ # bounding it with wait_for proves _run_script itself returns promptly.
226
+ result = asyncio.run(asyncio.wait_for(server._run_script(script_name, [], timeout=10.0), timeout=15.0))
227
+ assert result.ok, f"expected clean EOF-driven exit, got: {result}"
228
+
229
+
230
+ def test_text_to_speech_subprocess_spawn_passes_stdin_devnull(monkeypatch):
231
+ """text_to_speech's own TTS subprocess spawn (separate from _run_script)
232
+ must also pass stdin=DEVNULL."""
233
+ server = AgentVibesServer()
234
+ captured_kwargs = {}
235
+
236
+ async def fake_create_subprocess_exec(*args, **kwargs):
237
+ captured_kwargs.update(kwargs)
238
+ return _FakeProc()
239
+
240
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
241
+
242
+ asyncio.run(server.text_to_speech("hello"))
243
+
244
+ assert captured_kwargs.get("stdin") == asyncio.subprocess.DEVNULL
245
+
246
+
247
+ def test_text_to_speech_with_voice_declares_user_explicit_provenance(monkeypatch):
248
+ """Adversarial-review fix (F-1 wiring): when the MCP caller asks for a
249
+ specific voice, the spawned player must receive AGENTVIBES_VOICE_SOURCE=
250
+ user-explicit so the resolver treats it as a genuine explicit pick and never
251
+ demotes it to a per-LLM/default row. Without a voice, it must NOT be set."""
252
+ server = AgentVibesServer()
253
+ captured = {}
254
+
255
+ async def fake_create_subprocess_exec(*args, **kwargs):
256
+ captured["env"] = kwargs.get("env")
257
+ return _FakeProc()
258
+
259
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
260
+
261
+ asyncio.run(server.text_to_speech("hello", voice="Aria"))
262
+ assert captured["env"].get("AGENTVIBES_VOICE_SOURCE") == "user-explicit"
263
+
264
+ captured.clear()
265
+ asyncio.run(server.text_to_speech("hello")) # no voice → provenance not forced
266
+ assert captured["env"].get("AGENTVIBES_VOICE_SOURCE") is None
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # download_extra_voices(auto_yes=False) must never reach the interactive prompt
271
+ # ---------------------------------------------------------------------------
272
+
273
+ def test_download_extra_voices_without_auto_yes_never_spawns_the_script():
274
+ server = AgentVibesServer()
275
+ calls = []
276
+
277
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
278
+ calls.append((script_name, args, timeout))
279
+ return ScriptResult(0, "should not be reached", "")
280
+ server._run_script = fake_run_script
281
+
282
+ result = asyncio.run(server.download_extra_voices(auto_yes=False))
283
+
284
+ assert calls == [], "download-extra-voices.sh must never be spawned without explicit auto_yes=True"
285
+ assert not result.startswith("✅")
286
+ assert "confirmation" in result.lower() or "auto_yes" in result.lower()
287
+
288
+
289
+ def test_download_extra_voices_with_auto_yes_runs_script_with_yes_flag():
290
+ server = AgentVibesServer()
291
+ calls = []
292
+
293
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
294
+ calls.append((script_name, args, timeout))
295
+ return ScriptResult(0, "Successfully downloaded 3 voices", "")
296
+ server._run_script = fake_run_script
297
+
298
+ result = asyncio.run(server.download_extra_voices(auto_yes=True))
299
+
300
+ assert len(calls) == 1
301
+ script_name, args, timeout = calls[0]
302
+ assert script_name == "download-extra-voices.sh"
303
+ assert args == ["--yes"]
304
+ assert not result.startswith("❌")
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # #4: per-call personality/language mutation — concurrency + non-destructive restore
309
+ # ---------------------------------------------------------------------------
310
+
311
+ def test_concurrent_personality_overrides_are_serialized_by_lock(tmp_path, monkeypatch):
312
+ """Two concurrent text_to_speech(personality=...) calls must not have
313
+ their "set the temporary personality" critical sections overlap. This
314
+ measures actual concurrent presence (via a shared counter held open by
315
+ an artificial sleep) rather than relying on incidental asyncio
316
+ scheduling order, so it deterministically fails without the lock and
317
+ passes with it."""
318
+ server = AgentVibesServer()
319
+ server._get_config_dir = lambda: tmp_path
320
+
321
+ active = []
322
+ max_concurrent = {"value": 0}
323
+
324
+ async def fake_get_personality():
325
+ return "normal"
326
+ server._get_personality = fake_get_personality
327
+
328
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
329
+ if script_name == server.PERSONALITY_MANAGER_SCRIPT and args[0] == "set" and args[1] != "normal":
330
+ active.append(1)
331
+ max_concurrent["value"] = max(max_concurrent["value"], len(active))
332
+ # Hold the critical section open long enough that, without the
333
+ # lock, the other concurrent call would enter it too.
334
+ await asyncio.sleep(0.05)
335
+ active.pop()
336
+ return ScriptResult(0, "ok", "")
337
+ server._run_script = fake_run_script
338
+
339
+ async def fake_create_subprocess_exec(*_args, **_kwargs):
340
+ return _FakeProc(sleep=0.01)
341
+
342
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
343
+
344
+ async def run_both():
345
+ await asyncio.gather(
346
+ server.text_to_speech("hello", personality="flirty"),
347
+ server.text_to_speech("world", personality="sarcastic"),
348
+ )
349
+
350
+ asyncio.run(run_both())
351
+
352
+ assert max_concurrent["value"] == 1, (
353
+ "concurrent text_to_speech(personality=...) calls overlapped their "
354
+ "'set personality' critical sections — self._override_lock is not "
355
+ "serializing them"
356
+ )
357
+
358
+
359
+ def test_personality_restore_deletes_file_when_none_existed_before(tmp_path, monkeypatch):
360
+ """Non-destructive-config rule: a temporary per-call personality override
361
+ must not leave tts-personality.txt behind if no such file existed before
362
+ the call (personality-manager.sh's `set` has no delete-on-default
363
+ behavior, unlike language-manager.sh's `set english`)."""
364
+ server = AgentVibesServer()
365
+ server._get_config_dir = lambda: tmp_path
366
+
367
+ async def fake_get_personality():
368
+ return "normal"
369
+ server._get_personality = fake_get_personality
370
+
371
+ run_script_calls = []
372
+
373
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
374
+ run_script_calls.append((script_name, tuple(args)))
375
+ return ScriptResult(0, "ok", "")
376
+ server._run_script = fake_run_script
377
+
378
+ async def fake_create_subprocess_exec(*_args, **_kwargs):
379
+ return _FakeProc()
380
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
381
+
382
+ personality_file = tmp_path / "tts-personality.txt"
383
+ assert not personality_file.exists()
384
+
385
+ asyncio.run(server.text_to_speech("hi", personality="flirty"))
386
+
387
+ assert not personality_file.exists(), (
388
+ "restoring a per-call personality override must delete the file when "
389
+ "none existed before, not write 'normal' into a file that never existed"
390
+ )
391
+ set_calls = [c for c in run_script_calls if c[0] == server.PERSONALITY_MANAGER_SCRIPT]
392
+ assert set_calls == [(server.PERSONALITY_MANAGER_SCRIPT, ("set", "flirty"))]
393
+
394
+
395
+ def test_personality_restore_calls_script_when_file_existed_before(tmp_path, monkeypatch):
396
+ server = AgentVibesServer()
397
+ server._get_config_dir = lambda: tmp_path
398
+ (tmp_path / "tts-personality.txt").write_text("grumpy")
399
+
400
+ async def fake_get_personality():
401
+ return "grumpy"
402
+ server._get_personality = fake_get_personality
403
+
404
+ run_script_calls = []
405
+
406
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
407
+ run_script_calls.append((script_name, tuple(args)))
408
+ return ScriptResult(0, "ok", "")
409
+ server._run_script = fake_run_script
410
+
411
+ async def fake_create_subprocess_exec(*_args, **_kwargs):
412
+ return _FakeProc()
413
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
414
+
415
+ asyncio.run(server.text_to_speech("hi", personality="flirty"))
416
+
417
+ set_calls = [c for c in run_script_calls if c[0] == server.PERSONALITY_MANAGER_SCRIPT]
418
+ assert set_calls == [
419
+ (server.PERSONALITY_MANAGER_SCRIPT, ("set", "flirty")),
420
+ (server.PERSONALITY_MANAGER_SCRIPT, ("set", "grumpy")),
421
+ ]
422
+ assert (tmp_path / "tts-personality.txt").read_text() == "grumpy"
423
+
424
+
425
+ def test_personality_restore_reads_original_from_config_dir_not_get_personality(tmp_path, monkeypatch):
426
+ """Code-review regression: the original value to restore must be read from
427
+ the SAME file the manager writes to (the config dir), NOT _get_personality()
428
+ — which reads a different set of dirs (package, then global ~/.claude) and,
429
+ from inside a host project, returns the wrong value. Restoring that wrong
430
+ value would overwrite the project's real personality (Non-Destructive Rule).
431
+
432
+ Here the project config file says 'cheerful' but _get_personality() returns
433
+ a divergent 'normal' (simulating the package/global lookup). Restore MUST
434
+ put back 'cheerful' (the real project value), never 'normal'.
435
+ """
436
+ server = AgentVibesServer()
437
+ server._get_config_dir = lambda: tmp_path
438
+ (tmp_path / "tts-personality.txt").write_text("cheerful")
439
+
440
+ async def divergent_get_personality():
441
+ return "normal" # what the OLD code would have (wrongly) restored
442
+ server._get_personality = divergent_get_personality
443
+
444
+ run_script_calls = []
445
+
446
+ async def fake_run_script(script_name, args, timeout=DEFAULT_SCRIPT_TIMEOUT):
447
+ run_script_calls.append((script_name, tuple(args)))
448
+ # emulate personality-manager writing the value into the config dir
449
+ if script_name == server.PERSONALITY_MANAGER_SCRIPT and args[:1] == ["set"]:
450
+ (tmp_path / "tts-personality.txt").write_text(args[1])
451
+ return ScriptResult(0, "ok", "")
452
+ server._run_script = fake_run_script
453
+
454
+ async def fake_create_subprocess_exec(*_args, **_kwargs):
455
+ return _FakeProc()
456
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
457
+
458
+ asyncio.run(server.text_to_speech("hi", personality="pirate"))
459
+
460
+ set_calls = [c for c in run_script_calls if c[0] == server.PERSONALITY_MANAGER_SCRIPT]
461
+ assert set_calls == [
462
+ (server.PERSONALITY_MANAGER_SCRIPT, ("set", "pirate")),
463
+ (server.PERSONALITY_MANAGER_SCRIPT, ("set", "cheerful")),
464
+ ], "restore must use the config-dir value 'cheerful', not _get_personality()'s 'normal'"
465
+ assert (tmp_path / "tts-personality.txt").read_text() == "cheerful"
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # #6: background-music default volume description is 20%, not 30%
470
+ # ---------------------------------------------------------------------------
471
+
472
+ def test_tool_descriptions_state_20_percent_default_not_30():
473
+ tools = asyncio.run(list_tools())
474
+ by_name = {t.name: t for t in tools}
475
+
476
+ enable_desc = by_name["enable_background_music"].description
477
+ assert "20%" in enable_desc
478
+ assert "30%" not in enable_desc
479
+
480
+ volume_desc = by_name["set_background_music_volume"].inputSchema["properties"]["volume"]["description"]
481
+ assert "0.20" in volume_desc
482
+ assert "0.30" not in volume_desc
483
+
484
+
485
+ if __name__ == "__main__":
486
+ sys.exit(pytest.main([__file__, "-v"]))