agentvibes 5.11.2 → 5.12.0-alpha.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.
- package/.agentvibes/config.json +1 -15
- package/.claude/activation-instructions +54 -54
- package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
- package/.claude/commands/agent-vibes-rdp.md +24 -24
- package/.claude/config/audio-effects.cfg +5 -5
- package/.claude/config/background-music-enabled.txt +1 -1
- package/.claude/docs/TERMUX_SETUP.md +408 -408
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/audio-processor.sh +1 -1
- package/.claude/hooks/background-music-manager.sh +2 -1
- package/.claude/hooks/bmad-speak-enhanced.sh +37 -22
- package/.claude/hooks/bmad-speak.sh +26 -8
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +1 -1
- package/.claude/hooks/play-tts-kokoro.sh +9 -0
- package/.claude/hooks/play-tts-piper.sh +13 -1
- package/.claude/hooks/play-tts-ssh-remote.sh +78 -5
- package/.claude/hooks/play-tts-termux-ssh.sh +1 -1
- package/.claude/hooks/play-tts.sh +85 -4
- package/.claude/hooks-windows/background-music-manager.ps1 +2 -1
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +14 -0
- package/.claude/hooks-windows/play-tts.ps1 +163 -30
- package/.claude/hooks-windows/voice-manager-windows.ps1 +101 -1
- package/.clawdbot/README.md +105 -105
- package/LICENSE +190 -190
- package/RELEASE_NOTES.md +29 -0
- package/WINDOWS-SETUP.md +208 -208
- package/bin/agent-vibes +39 -39
- package/bin/mcp-server.js +121 -121
- package/bin/mcp-server.sh +6 -19
- package/bin/resolve-utterance.js +137 -0
- package/bin/test-bmad-pr +78 -78
- package/mcp-server/QUICK_START.md +203 -203
- package/mcp-server/README.md +345 -345
- package/mcp-server/examples/claude_desktop_config.json +11 -11
- package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
- package/mcp-server/examples/custom_instructions.md +169 -169
- package/mcp-server/pyproject.toml +52 -52
- package/mcp-server/requirements.txt +2 -2
- package/mcp-server/server.py +280 -99
- package/mcp-server/test_mcp_correctness.py +486 -0
- package/mcp-server/test_server.py +395 -395
- package/mcp-server/test_windows_script_parity.py +341 -336
- package/package.json +3 -2
- package/setup-windows.ps1 +867 -815
- package/src/cli/list-personalities.js +110 -110
- package/src/cli/list-voices.js +114 -114
- package/src/commands/bmad-voices.js +394 -394
- package/src/console/app.js +13 -2
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/tabs/agents-tab.js +82 -9
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/music-tab.js +7 -1
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/settings-tab.js +29 -1
- package/src/console/tabs/setup-tab.js +4 -4
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/console/widgets/track-picker.js +3 -3
- package/src/i18n/de.js +0 -1
- package/src/i18n/en.js +0 -1
- package/src/i18n/es.js +0 -1
- package/src/i18n/fr.js +0 -1
- package/src/i18n/hi.js +0 -1
- package/src/i18n/ja.js +0 -1
- package/src/i18n/ko.js +0 -1
- package/src/i18n/pt.js +0 -1
- package/src/i18n/zh-CN.js +0 -1
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +205 -15
- package/src/services/config-service.js +264 -264
- package/src/services/language-service.js +47 -47
- package/src/services/llm-provider-service.js +7 -7
- package/src/services/utterance-loader.js +280 -0
- package/src/services/utterance-resolver.js +526 -0
- package/src/utils/audio-format-validator.js +277 -277
- package/src/utils/dependency-checker.js +469 -469
- package/src/utils/file-ownership-verifier.js +358 -358
- package/src/utils/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +144 -144
- package/src/utils/secure-music-storage.js +412 -412
- package/templates/agentvibes-receiver.sh +74 -12
- package/voice-assignments.json +8245 -0
- package/.agentvibes/install-manifest.json +0 -342
- package/.claude/config/background-music-position.txt +0 -27
- package/.claude/config/background-music-volume.txt +0 -1
- package/.claude/config/background-music.cfg +0 -1
- package/.claude/config/background-music.txt +0 -1
- package/.claude/config/language.txt +0 -1
- package/.claude/config/personality.txt +0 -1
- package/.claude/config/tts-speech-rate.txt +0 -1
- package/.claude/config/tts-verbosity.txt +0 -1
- package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
- package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
- package/.claude/piper-voices-dir.txt +0 -1
- package/.mcp.json +0 -22
|
@@ -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"]))
|