diff --git a/.aipass/hooks.json b/.aipass/hooks.json index 2c946d3b..71f9fa42 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -10,7 +10,6 @@ }, "email_notification": { "enabled": true, - "audio": true, "handler": "aipass.hooks.apps.handlers.notification.email.handle", "matcher": "" }, @@ -29,7 +28,6 @@ "PreToolUse": { "tool_use_sound": { "enabled": true, - "audio": true, "handler": "aipass.hooks.apps.handlers.notification.tool_sound.handle", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task" }, @@ -76,7 +74,6 @@ "Stop": { "stop_sound": { "enabled": true, - "audio": true, "handler": "aipass.hooks.apps.handlers.notification.stop_sound.handle", "matcher": "" } @@ -85,7 +82,6 @@ "Notification": { "notification_sound": { "enabled": true, - "audio": true, "handler": "aipass.hooks.apps.handlers.notification.announce.handle", "matcher": "" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e638e0..1dd1d055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,12 +27,12 @@ versioned release with notes. can enable, disable, or customize individual hooks without touching provider-level settings. Previously hooks fired globally with no per-project control. -- **Audio feedback on hook events** using Piper TTS (muted by default). - Tool usage, response completion, notifications, email alerts, and - sub-agent activity each produce distinct spoken audio cues so operators - can monitor sessions without watching the terminal. Toggle with - `drone hook-sounds on|off`. Audio hooks are tagged `"audio": true` in - `.aipass/hooks.json` and the engine skips them when muted. +- **Audio feedback on hook events** using Piper TTS. All 14 handlers + produce distinct spoken audio cues so operators can monitor sessions + without watching the terminal. A shared sound module + (`hooks/apps/sound.py`) provides `speak()` and `play()` with built-in + mute support. Toggle with `drone @hooks hooksound on|off` — muting + silences all 14 handlers without skipping their functional logic. - **Hooks agent** — the 13th citizen in the AIPass registry, owning all hook infrastructure: the engine, bridge, handlers, and configuration schema. diff --git a/src/aipass/drone/README.md b/src/aipass/drone/README.md index 36923d64..df7f9c6a 100644 --- a/src/aipass/drone/README.md +++ b/src/aipass/drone/README.md @@ -72,7 +72,6 @@ drone list # List registered custom command shortcuts drone remove # Remove a custom command shortcut # Utilities -drone hook-sounds on|off # Toggle hook notification sounds drone --version # Show version (v1.1.0) drone --help # Show usage information ``` @@ -184,8 +183,8 @@ drone/ │ │ ├── merge_plugin.py # PR merge (--merge) + local sync │ │ ├── sync_plugin.py # Smart sync (fetch, divergence detect, rebase) │ │ └── fix_plugin.py # Auto-fix stuck rebase / detached HEAD -│ └── hook_sounds/ -│ └── hook_sounds_plugin.py # Toggle notification sounds on/off +│ └── hook_sounds/ # DISABLED — moved to hooks branch (drone @hooks hooksound on/off) +│ └── hook_sounds_plugin.py.disabled ├── docs/ # Public documentation ├── docs.local/ # Investigation reports and policies └── tests/ # 704 tests across 21 test files @@ -194,7 +193,7 @@ drone/ ### Routing Flow 1. **CLI input** → `drone.py:main()` -2. **Built-in commands** checked first: `systems`, `scan`, `activate`, `list`, `remove`, `hook-sounds` +2. **Built-in commands** checked first: `systems`, `scan`, `activate`, `list`, `remove` 3. **`@target` routing** → branch resolution via `AIPASS_REGISTRY.json` → subprocess dispatch 4. **Module fallback** → if branch not found but is a registered module, routes internally 5. **Bare module names** → auto-discovered from `apps/modules/*.py`, routed via `importlib` @@ -277,9 +276,9 @@ Auth-gated operations for system administration. `auth.py` walks CWD for `.trini | `sync_plugin` | `smart-sync` | Fetch + detect divergence + rebase | | `fix_plugin` | `fix` | Auto-fix stuck rebase / detached HEAD | -### hook_sounds +### hook_sounds (DISABLED) -Simple toggle for hook notification sounds. Creates/removes `/tmp/aipass-hooks-muted` flag file. +Moved to hooks branch as `drone @hooks hooksound on/off`. Plugin file renamed to `.disabled`. --- @@ -325,7 +324,7 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches | Git operations | `test_git_module.py`, `test_system_pr.py`, `test_devpulse_plugins.py`, `test_git_access.py` | ~150 | | Handlers | `test_executor.py`, `test_registry_handler.py`, `test_discovery.py` | ~99 | | Infrastructure | `test_generic_adapter.py`, `test_module_registry.py`, `test_config.py` | ~66 | -| Features | `test_commands.py`, `test_scan.py`, `test_hook_sounds.py`, `test_json_handler.py` | ~125 | +| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py` | ~125 | | Standards | `test_cli_routing.py`, `test_contracts.py`, `test_error_resilience.py`, `test_init_provisioning.py` | ~21 | Run tests: `cd src/aipass/drone && python -m pytest tests/ -q` diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index d01cbdd9..0343a515 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -85,7 +85,6 @@ def show_help() -> None: table.add_row("activate @target", "Register all commands from a branch") table.add_row("list", "List registered custom commands") table.add_row("remove ", "Remove a custom command") - table.add_row("hook-sounds on|off", "Toggle hook notification sounds") table.add_row("--help", "Show this help") table.add_row("--version", "Show version") @@ -551,14 +550,6 @@ def main() -> int: if command == "list": return _handle_list() - # hook-sounds — toggle hook notification sounds - if command == "hook-sounds": - from aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin import handle_command as hs_handle - - cmd = args[1] if len(args) > 1 else None - hs_handle(cmd) - return 0 - # remove — remove a custom command by name if command == "remove": if len(args) < 2: diff --git a/src/aipass/drone/apps/plugins/hook_sounds/__init__.py b/src/aipass/drone/apps/plugins/hook_sounds/__init__.py.disabled similarity index 100% rename from src/aipass/drone/apps/plugins/hook_sounds/__init__.py rename to src/aipass/drone/apps/plugins/hook_sounds/__init__.py.disabled diff --git a/src/aipass/drone/apps/plugins/hook_sounds/hook_sounds_plugin.py b/src/aipass/drone/apps/plugins/hook_sounds/hook_sounds_plugin.py.disabled similarity index 100% rename from src/aipass/drone/apps/plugins/hook_sounds/hook_sounds_plugin.py rename to src/aipass/drone/apps/plugins/hook_sounds/hook_sounds_plugin.py.disabled diff --git a/src/aipass/drone/tests/test_cli_routing.py b/src/aipass/drone/tests/test_cli_routing.py index 35de1945..83b9d861 100644 --- a/src/aipass/drone/tests/test_cli_routing.py +++ b/src/aipass/drone/tests/test_cli_routing.py @@ -265,36 +265,6 @@ class TestMainList: mock_list.assert_called_once() -class TestMainHookSounds: - """drone hook-sounds command.""" - - _HS = "aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin.handle_command" - - def test_hook_sounds_on(self) -> None: - """hook-sounds on delegates to plugin.""" - from aipass.drone.apps.drone import main - - with ( - patch.object(sys, "argv", ["drone", "hook-sounds", "on"]), - patch(self._HS) as mock_hs, - ): - result = main() - assert result == 0 - mock_hs.assert_called_once_with("on") - - def test_hook_sounds_no_arg(self) -> None: - """hook-sounds with no arg passes None.""" - from aipass.drone.apps.drone import main - - with ( - patch.object(sys, "argv", ["drone", "hook-sounds"]), - patch(self._HS) as mock_hs, - ): - result = main() - assert result == 0 - mock_hs.assert_called_once_with(None) - - class TestMainRemove: """drone remove command.""" diff --git a/src/aipass/drone/tests/test_hook_sounds.py b/src/aipass/drone/tests/test_hook_sounds.py.disabled similarity index 100% rename from src/aipass/drone/tests/test_hook_sounds.py rename to src/aipass/drone/tests/test_hook_sounds.py.disabled diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index 95a09dfa..cde331c0 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -21,7 +21,9 @@ Every hook event flows through one engine. Platform bridges normalize the event |---|---| | `drone @hooks status` | Show hook config for current project | | `drone @hooks log` | Tail recent hook activity (last 20 JSONL entries) | -| `drone @hooks test` | Run hook test suite (planned) | +| `drone @hooks hooksound` | Show current sound mute status | +| `drone @hooks hooksound off` | Mute all hook sounds | +| `drone @hooks hooksound on` | Unmute all hook sounds | | `drone @hooks --help` | Full help reference | | `drone @hooks --version` | Version info | @@ -32,8 +34,10 @@ src/aipass/hooks/ ├── .trinity/ # Identity & memory ├── apps/ │ ├── hooks.py # Entry point (drone @hooks) +│ ├── sound.py # Shared sound utilities (speak, play, mute) │ ├── modules/ -│ │ └── engine.py # Core dispatch — routes events to handlers +│ │ ├── engine.py # Core dispatch — routes events to handlers +│ │ └── hooksound.py # Sound control (drone @hooks hooksound on/off) │ ├── handlers/ │ │ ├── bridges/ # One per provider (thin normalization) │ │ │ └── claude.py # Claude Code bridge @@ -44,7 +48,7 @@ src/aipass/hooks/ │ └── config/ # hooks.json validation ├── logs/ │ └── engine.jsonl # JSONL diagnostics (every hook execution) -├── tests/ +├── tests/ # 236 tests └── STATUS.local.md ``` @@ -70,7 +74,7 @@ src/aipass/hooks/ All branches via hook dispatch. Every Claude Code session routes through the engine. -*Last Updated: 2026-05-18* +*Last Updated: 2026-05-22* --- diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py index df635acc..8dfe62be 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py @@ -14,35 +14,11 @@ import json import os import subprocess import sys -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] auto_fix: speak error: %s", exc) - - EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} STATE_FILE = Path(__file__).parent.parent.parent.parent.parent / ".diagnostics_state.json" SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"} @@ -345,7 +321,7 @@ def handle(hook_data: dict) -> dict: if ext in SKIP_EXTENSIONS: return {"stdout": "", "exit_code": 0} - _speak("auto fix diagnostics") + speak("auto fix diagnostics") errors: list[str] = [] diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py index deb56bb0..182fedc1 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py @@ -11,34 +11,8 @@ """Checks for dispatch commands and reminds the agent to arm the watchdog.""" import json -import subprocess -import tempfile -from pathlib import Path -from aipass.prax.apps.modules.logger import system_logger as logger - -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] auto_watchdog: speak error: %s", exc) +from aipass.hooks.apps.sound import speak def handle(hook_data: dict) -> dict: @@ -65,7 +39,7 @@ def handle(hook_data: dict) -> dict: if "dispatch wake" in command and "dispatch @" not in command: return {"stdout": "", "exit_code": 0} - _speak("auto watchdog") + speak("auto watchdog") result = { "additionalContext": ( diff --git a/src/aipass/hooks/apps/handlers/lifecycle/compact.py b/src/aipass/hooks/apps/handlers/lifecycle/compact.py index 0cc83ea4..4479f405 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/compact.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/compact.py @@ -13,34 +13,11 @@ import json import os import subprocess -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] compact: speak error: %s", exc) - def _find_branch_dir(cwd: str) -> Path | None: parts = Path(cwd).parts @@ -116,7 +93,7 @@ def _get_git_info() -> str | None: def handle(hook_data: dict) -> dict: """Inject live branch state for post-compact recovery.""" - _speak("pre compact") + speak("pre compact") try: cwd = hook_data.get("cwd", "") or str(Path.cwd()) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/rollover.py b/src/aipass/hooks/apps/handlers/lifecycle/rollover.py index be409362..185961e9 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/rollover.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/rollover.py @@ -13,34 +13,11 @@ import json import os import subprocess -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] rollover: speak error: %s", exc) - def _find_repo_root() -> Path | None: aipass_home = os.environ.get("AIPASS_HOME", "") @@ -154,7 +131,7 @@ def _run_rollover(repo_root: Path) -> tuple[bool, str]: def handle(hook_data: dict) -> dict: """Check memory files for overflow and trigger rollover if needed.""" - _speak("pre compact rollover") + speak("pre compact rollover") try: repo_root = _find_repo_root() diff --git a/src/aipass/hooks/apps/handlers/notification/announce.py b/src/aipass/hooks/apps/handlers/notification/announce.py index a7458b0a..2b68fa14 100644 --- a/src/aipass/hooks/apps/handlers/notification/announce.py +++ b/src/aipass/hooks/apps/handlers/notification/announce.py @@ -11,65 +11,14 @@ """Plays announcement tone + Piper voice ID on Notification events.""" import os -import subprocess -import tempfile from pathlib import Path -from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.hooks.apps.sound import speak AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", "")) SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds" SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav" -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _play(sound_path: Path) -> None: - """Play a WAV file via aplay (fire-and-forget).""" - if not sound_path.exists(): - logger.info("[HOOKS] announce: file not found: %s", sound_path) - return - try: - subprocess.Popen( - ["aplay", "-q", str(sound_path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except OSError as exc: - logger.info("[HOOKS] announce: playback error: %s", exc) - - -def _speak(text: str) -> None: - """Generate speech via Piper TTS and play it (fire-and-forget).""" - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - logger.info("[HOOKS] announce: piper not available") - return - - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - - piper_result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - - if piper_result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen( - ["aplay", "-q", wav_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except subprocess.TimeoutExpired: - logger.info("[HOOKS] announce: piper timed out") - except OSError as exc: - logger.info("[HOOKS] announce: speak error: %s", exc) - def handle(hook_data: dict) -> dict: """Play notification tone and speak hook name for identification. @@ -80,5 +29,5 @@ def handle(hook_data: dict) -> dict: Returns: Result dict with stdout (empty) and exit_code. """ - _speak("notification sound") + speak("notification sound") return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/notification/email.py b/src/aipass/hooks/apps/handlers/notification/email.py index 97e4042e..e2cda15e 100644 --- a/src/aipass/hooks/apps/handlers/notification/email.py +++ b/src/aipass/hooks/apps/handlers/notification/email.py @@ -11,46 +11,11 @@ """Checks branch inbox for unread emails and returns notification text.""" import json -import subprocess -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - """Generate speech via Piper TTS and play it (fire-and-forget).""" - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - logger.info("[HOOKS] email: piper not available") - return - - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - - piper_result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - - if piper_result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen( - ["aplay", "-q", wav_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except subprocess.TimeoutExpired: - logger.info("[HOOKS] email: piper timed out") - except OSError as exc: - logger.info("[HOOKS] email: speak error: %s", exc) - def _find_branch_root() -> Path | None: """Find the branch root by walking up from CWD looking for branch markers.""" @@ -132,7 +97,7 @@ def handle(hook_data: dict) -> dict: return {"stdout": "", "exit_code": 0} plural = "s" if new_count != 1 else "" - _speak(f"email notification: {new_count} new email{plural}") + speak(f"email notification: {new_count} new email{plural}") msg = f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view | close with: drone @ai_mail close " logger.info("[HOOKS] email: %d new email%s", new_count, plural) return {"stdout": msg, "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/notification/stop_sound.py b/src/aipass/hooks/apps/handlers/notification/stop_sound.py index 4bb3f75c..862ed10b 100644 --- a/src/aipass/hooks/apps/handlers/notification/stop_sound.py +++ b/src/aipass/hooks/apps/handlers/notification/stop_sound.py @@ -11,65 +11,14 @@ """Plays achievement bell when the AI finishes responding (Stop event).""" import os -import subprocess -import tempfile from pathlib import Path -from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.hooks.apps.sound import speak AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", "")) SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds" SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav" -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _play(sound_path: Path) -> None: - """Play a WAV file via aplay (fire-and-forget).""" - if not sound_path.exists(): - logger.info("[HOOKS] stop_sound: file not found: %s", sound_path) - return - try: - subprocess.Popen( - ["aplay", "-q", str(sound_path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except OSError as exc: - logger.info("[HOOKS] stop_sound: playback error: %s", exc) - - -def _speak(text: str) -> None: - """Generate speech via Piper TTS and play it (fire-and-forget).""" - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - logger.info("[HOOKS] stop_sound: piper not available") - return - - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - - piper_result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - - if piper_result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen( - ["aplay", "-q", wav_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except subprocess.TimeoutExpired: - logger.info("[HOOKS] stop_sound: piper timed out") - except OSError as exc: - logger.info("[HOOKS] stop_sound: speak error: %s", exc) - def handle(hook_data: dict) -> dict: """Play achievement bell and speak hook name on Stop event. @@ -83,5 +32,5 @@ def handle(hook_data: dict) -> dict: if hook_data.get("stop_hook_active", False): return {"stdout": "", "exit_code": 0} - _speak("stop sound") + speak("stop sound") return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/notification/tool_sound.py b/src/aipass/hooks/apps/handlers/notification/tool_sound.py index 716eba89..0c1680e0 100644 --- a/src/aipass/hooks/apps/handlers/notification/tool_sound.py +++ b/src/aipass/hooks/apps/handlers/notification/tool_sound.py @@ -10,45 +10,7 @@ """Announces hook name via Piper TTS when the AI uses tools (PreToolUse event).""" -import subprocess -import tempfile -from pathlib import Path - -from aipass.prax.apps.modules.logger import system_logger as logger - -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - """Generate speech via Piper TTS and play it (fire-and-forget).""" - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - logger.info("[HOOKS] tool_sound: piper not available") - return - - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - - piper_result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - - if piper_result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen( - ["aplay", "-q", wav_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except subprocess.TimeoutExpired: - logger.info("[HOOKS] tool_sound: piper timed out") - except OSError as exc: - logger.info("[HOOKS] tool_sound: playback error: %s", exc) +from aipass.hooks.apps.sound import speak def handle(hook_data: dict) -> dict: @@ -64,5 +26,5 @@ def handle(hook_data: dict) -> dict: if not tool_name: return {"stdout": "", "exit_code": 0} - _speak(f"tool sound: {tool_name}") + speak(f"tool sound: {tool_name}") return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/prompt/branch_loader.py b/src/aipass/hooks/apps/handlers/prompt/branch_loader.py index 485ff4e2..4d2a63c2 100644 --- a/src/aipass/hooks/apps/handlers/prompt/branch_loader.py +++ b/src/aipass/hooks/apps/handlers/prompt/branch_loader.py @@ -10,35 +10,11 @@ """Loads .aipass/aipass_local_prompt.md and private integration prompts for injection.""" -import subprocess -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] branch_loader: speak error: %s", exc) - def _find_branch_root(cwd: str) -> Path | None: """Walk up from CWD looking for .trinity/ or apps/ — stop at repo root.""" @@ -54,7 +30,7 @@ def _find_branch_root(cwd: str) -> Path | None: def handle(hook_data: dict) -> dict: """Load branch prompt and private integration prompts.""" - _speak("branch prompt") + speak("branch prompt") try: cwd = hook_data.get("cwd", "") or str(Path.cwd()) diff --git a/src/aipass/hooks/apps/handlers/prompt/global_loader.py b/src/aipass/hooks/apps/handlers/prompt/global_loader.py index 3c2e98a8..05150c6c 100644 --- a/src/aipass/hooks/apps/handlers/prompt/global_loader.py +++ b/src/aipass/hooks/apps/handlers/prompt/global_loader.py @@ -11,39 +11,15 @@ """Loads .aipass/aipass_global_prompt.md from AIPASS_HOME for prompt injection.""" import os -import subprocess -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] global_loader: speak error: %s", exc) - def handle(hook_data: dict) -> dict: """Load AIPass global prompt from AIPASS_HOME.""" - _speak("global prompt") + speak("global prompt") try: aipass_home = os.environ.get("AIPASS_HOME", "") diff --git a/src/aipass/hooks/apps/handlers/prompt/identity.py b/src/aipass/hooks/apps/handlers/prompt/identity.py index 1e4eceb9..463c41db 100644 --- a/src/aipass/hooks/apps/handlers/prompt/identity.py +++ b/src/aipass/hooks/apps/handlers/prompt/identity.py @@ -11,35 +11,11 @@ """Reads .trinity/passport.json and outputs formatted identity for prompt injection.""" import json -import subprocess -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] identity: speak error: %s", exc) - def _find_passport(cwd: str) -> Path | None: """Walk up from CWD looking for .trinity/passport.json.""" @@ -93,7 +69,7 @@ def _format_identity(data: dict) -> str: def handle(hook_data: dict) -> dict: """Inject branch identity from passport.json into prompt context.""" - _speak("identity") + speak("identity") try: cwd = hook_data.get("cwd", "") or str(Path.cwd()) diff --git a/src/aipass/hooks/apps/handlers/security/edit_gate.py b/src/aipass/hooks/apps/handlers/security/edit_gate.py index e2aaf64e..560e3a21 100644 --- a/src/aipass/hooks/apps/handlers/security/edit_gate.py +++ b/src/aipass/hooks/apps/handlers/security/edit_gate.py @@ -12,35 +12,11 @@ import json import os -import subprocess -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] edit_gate: speak error: %s", exc) - STATE_FILE = Path(__file__).parent.parent.parent.parent.parent / ".diagnostics_state.json" EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} @@ -64,7 +40,7 @@ def handle(hook_data: dict) -> dict: Returns: Result dict with stdout (block JSON or empty) and exit_code. """ - _speak("edit gate") + speak("edit gate") try: tool_name = hook_data.get("tool_name", "") diff --git a/src/aipass/hooks/apps/handlers/security/git_gate.py b/src/aipass/hooks/apps/handlers/security/git_gate.py index 5263823d..f4f9a74b 100644 --- a/src/aipass/hooks/apps/handlers/security/git_gate.py +++ b/src/aipass/hooks/apps/handlers/security/git_gate.py @@ -13,35 +13,11 @@ import json import os import re -import subprocess -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - - -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] git_gate: speak error: %s", exc) - RAW_GIT_RE = re.compile(r"(? dict: Returns: Result dict with stdout (block JSON or empty) and exit_code. """ - _speak("git gate") + speak("git gate") try: tool_name = hook_data.get("tool_name", "") diff --git a/src/aipass/hooks/apps/handlers/security/subagent_gate.py b/src/aipass/hooks/apps/handlers/security/subagent_gate.py index d4113bc7..06d28b8f 100644 --- a/src/aipass/hooks/apps/handlers/security/subagent_gate.py +++ b/src/aipass/hooks/apps/handlers/security/subagent_gate.py @@ -13,37 +13,14 @@ import json import os import subprocess -import tempfile from pathlib import Path +from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger -PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" -PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" - _ALLOW = {"stdout": "", "exit_code": 0} -def _speak(text: str) -> None: - if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): - return - try: - wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, mode="wb") - wav_path = wav_file.name - wav_file.close() - result = subprocess.run( - [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], - input=text, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and Path(wav_path).exists(): - subprocess.Popen(["aplay", "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.info("[HOOKS] subagent_gate: speak error: %s", exc) - - def _block(reason: str) -> dict: return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} @@ -160,7 +137,7 @@ def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None: def handle(hook_data: dict) -> dict: """Check modified files against seedgo standards on subagent stop.""" - _speak("subagent stop gate") + speak("subagent stop gate") try: cwd = hook_data.get("cwd", "") or os.getcwd() diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py index c18b3288..003dab0f 100644 --- a/src/aipass/hooks/apps/modules/engine.py +++ b/src/aipass/hooks/apps/modules/engine.py @@ -108,18 +108,12 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str: outputs = [] total_start = time.monotonic() - muted = Path("/tmp/aipass-hooks-muted").exists() - for hook_name, hook_def in event_hooks.items(): if not hook_def.get("enabled", True): logger.info("[HOOKS] %s.%s skipped (disabled)", event_type, hook_name) _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"}) continue - if muted and hook_def.get("audio"): - _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_muted"}) - continue - handler = hook_def.get("handler", "") command = hook_def.get("command", "") matcher = hook_def.get("matcher", "") diff --git a/src/aipass/hooks/apps/modules/hooksound.py b/src/aipass/hooks/apps/modules/hooksound.py new file mode 100644 index 00000000..c65ec44f --- /dev/null +++ b/src/aipass/hooks/apps/modules/hooksound.py @@ -0,0 +1,59 @@ +# =================== AIPass ==================== +# Name: hooksound.py +# Version: 1.0.0 +# Description: Hook sound control — mute/unmute all hook audio +# Branch: hooks +# Layer: apps/modules +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Hook sound control — mute and unmute all hook audio via drone @hooks hooksound.""" + +from aipass.cli.apps.modules import err_console +from aipass.hooks.apps.sound import MUTE_FLAG, is_muted + +CONSOLE = err_console + + +def print_introspection(): + """Print module structure for drone routing.""" + status = "MUTED" if is_muted() else "ACTIVE" + CONSOLE.print(f"[bold cyan]hooksound[/bold cyan] — Hook sound control ({status})") + + +def handle_command(command: str, args: list) -> bool: + """Route hooksound commands from drone @hooks.""" + if command == "hooksound": + sub = args[0] if args else None + + if sub in ("--help", "-h", "help"): + CONSOLE.print("[bold cyan]hooksound[/bold cyan] — Mute/unmute all hook audio") + CONSOLE.print() + CONSOLE.print(" drone @hooks hooksound Show current status") + CONSOLE.print(" drone @hooks hooksound on Unmute all hook sounds") + CONSOLE.print(" drone @hooks hooksound off Mute all hook sounds") + return True + + if sub == "off": + MUTE_FLAG.touch() + CONSOLE.print("[yellow]Hook sounds MUTED[/yellow]") + return True + + if sub == "on": + if MUTE_FLAG.exists(): + MUTE_FLAG.unlink() + CONSOLE.print("[green]Hook sounds ACTIVE[/green]") + return True + + if sub is None: + if is_muted(): + CONSOLE.print("[yellow]Hook sounds: MUTED[/yellow]") + CONSOLE.print(f" Flag: {MUTE_FLAG}") + CONSOLE.print(" Run: drone @hooks hooksound on") + else: + CONSOLE.print("[green]Hook sounds: ACTIVE[/green]") + CONSOLE.print(" Run: drone @hooks hooksound off") + return True + + return False diff --git a/src/aipass/hooks/apps/sound.py b/src/aipass/hooks/apps/sound.py new file mode 100644 index 00000000..26e88c06 --- /dev/null +++ b/src/aipass/hooks/apps/sound.py @@ -0,0 +1,85 @@ +# =================== AIPass ==================== +# Name: sound.py +# Version: 1.0.0 +# Description: Shared sound utilities — Piper TTS and WAV playback with mute support +# Branch: hooks +# Layer: apps +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Shared sound functions for hook handlers. Checks mute flag before playing.""" + +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import err_console + +CONSOLE = err_console +MUTE_FLAG = Path("/tmp/aipass-hooks-muted") +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def print_introspection(): + """Print module structure for drone routing.""" + CONSOLE.print("[bold cyan]sound[/bold cyan] — Shared sound utilities (speak, play, mute)") + + +def is_muted() -> bool: + """Check whether hook sounds are currently muted.""" + return MUTE_FLAG.exists() + + +def speak(text: str) -> None: + """Generate speech via Piper TTS and play it. Skips if muted.""" + if is_muted(): + return + + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + return + + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + + piper_result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + + if piper_result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen( + ["aplay", "-q", wav_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.TimeoutExpired: + logger.info("[HOOKS] speak: piper timed out") + except OSError as exc: + logger.info("[HOOKS] speak: playback error: %s", exc) + + +def play(sound_path: Path) -> None: + """Play a WAV file via aplay. Skips if muted.""" + if is_muted(): + return + + if not sound_path.exists(): + logger.info("[HOOKS] play: file not found: %s", sound_path) + return + + try: + subprocess.Popen( + ["aplay", "-q", str(sound_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError as exc: + logger.info("[HOOKS] play: playback error: %s", exc) diff --git a/src/aipass/hooks/tests/test_announce.py b/src/aipass/hooks/tests/test_announce.py index ae739358..3e921d86 100644 --- a/src/aipass/hooks/tests/test_announce.py +++ b/src/aipass/hooks/tests/test_announce.py @@ -1,15 +1,15 @@ # =================== AIPass ==================== # Name: test_announce.py -# Version: 1.1.0 +# Version: 1.2.0 # Description: Tests for announce notification handler # Branch: hooks # Created: 2026-05-20 -# Modified: 2026-05-20 +# Modified: 2026-05-22 # ============================================= """Tests for handlers/notification/announce.py.""" -from unittest.mock import patch, MagicMock +from unittest.mock import patch class TestAnnounceHandler: @@ -18,10 +18,7 @@ class TestAnnounceHandler: def test_handle_returns_result_dict(self): from aipass.hooks.apps.handlers.notification.announce import handle - with ( - patch("aipass.hooks.apps.handlers.notification.announce._play"), - patch("aipass.hooks.apps.handlers.notification.announce._speak"), - ): + with patch("aipass.hooks.apps.handlers.notification.announce.speak"): result = handle({}) assert isinstance(result, dict) @@ -31,138 +28,7 @@ class TestAnnounceHandler: def test_handle_speaks_notification_sound(self): from aipass.hooks.apps.handlers.notification.announce import handle - with ( - patch("aipass.hooks.apps.handlers.notification.announce._play"), - patch("aipass.hooks.apps.handlers.notification.announce._speak") as mock_speak, - ): + with patch("aipass.hooks.apps.handlers.notification.announce.speak") as mock_speak: handle({}) mock_speak.assert_called_once_with("notification sound") - - def test_handle_does_not_play_wav(self): - from aipass.hooks.apps.handlers.notification.announce import handle - - with ( - patch("aipass.hooks.apps.handlers.notification.announce._play") as mock_play, - patch("aipass.hooks.apps.handlers.notification.announce._speak"), - ): - handle({}) - - mock_play.assert_not_called() - - -class TestPlayFunction: - """WAV playback tests.""" - - def test_play_calls_aplay(self): - from aipass.hooks.apps.handlers.notification.announce import _play - - mock_path = MagicMock() - mock_path.exists.return_value = True - - with patch("aipass.hooks.apps.handlers.notification.announce.subprocess.Popen") as mock_popen: - _play(mock_path) - - mock_popen.assert_called_once() - args = mock_popen.call_args[0][0] - assert args[0] == "aplay" - assert args[1] == "-q" - - def test_play_skips_when_file_missing(self): - from aipass.hooks.apps.handlers.notification.announce import _play - - mock_path = MagicMock() - mock_path.exists.return_value = False - - with patch("aipass.hooks.apps.handlers.notification.announce.subprocess.Popen") as mock_popen: - _play(mock_path) - - mock_popen.assert_not_called() - - def test_play_graceful_on_os_error(self): - from aipass.hooks.apps.handlers.notification.announce import _play - - mock_path = MagicMock() - mock_path.exists.return_value = True - - with patch( - "aipass.hooks.apps.handlers.notification.announce.subprocess.Popen", - side_effect=OSError("broken"), - ): - _play(mock_path) - - -class TestSpeakFunction: - """Piper TTS tests.""" - - def test_speak_calls_piper_then_aplay(self): - from aipass.hooks.apps.handlers.notification.announce import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.announce.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.announce.subprocess") as mock_sub, - patch("aipass.hooks.apps.handlers.notification.announce.tempfile") as mock_tmp, - patch("aipass.hooks.apps.handlers.notification.announce.Path") as mock_path, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - mock_sub.run.return_value = MagicMock(returncode=0) - mock_path.return_value.exists.return_value = True - - _speak("test text") - - mock_sub.run.assert_called_once() - mock_sub.Popen.assert_called_once() - - def test_speak_skips_when_piper_missing(self): - from aipass.hooks.apps.handlers.notification.announce import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.announce.subprocess") as mock_sub, - ): - mock_piper_bin.exists.return_value = False - _speak("test") - - mock_sub.run.assert_not_called() - - def test_speak_graceful_on_timeout(self): - import subprocess as real_sub - from aipass.hooks.apps.handlers.notification.announce import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.announce.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.announce.subprocess") as mock_sub, - patch("aipass.hooks.apps.handlers.notification.announce.tempfile") as mock_tmp, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5) - mock_sub.TimeoutExpired = real_sub.TimeoutExpired - - _speak("test") - - def test_speak_graceful_on_os_error(self): - from aipass.hooks.apps.handlers.notification.announce import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.announce.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.announce.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.announce.subprocess.run", side_effect=OSError("broken")), - patch("aipass.hooks.apps.handlers.notification.announce.tempfile") as mock_tmp, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - - _speak("test") diff --git a/src/aipass/hooks/tests/test_auto_fix.py b/src/aipass/hooks/tests/test_auto_fix.py index aa59be21..ed9ec706 100644 --- a/src/aipass/hooks/tests/test_auto_fix.py +++ b/src/aipass/hooks/tests/test_auto_fix.py @@ -61,14 +61,14 @@ class TestAutoFixSkips: def test_skip_unknown_extension(self): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle - with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak"): result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/file.xyz"}}) assert result["stdout"] == "" assert result["exit_code"] == 0 class TestAutofixPython: - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @@ -81,7 +81,7 @@ class TestAutofixPython: parsed = json.loads(result["stdout"]) assert parsed["systemMessage"] == "[diagnostics] ok" - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @@ -97,7 +97,7 @@ class TestAutofixPython: assert "SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"] assert "1 error(s)" in parsed["systemMessage"] - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @@ -110,7 +110,7 @@ class TestAutofixPython: parsed = json.loads(result["stdout"]) assert "LINT" in parsed["hookSpecificOutput"]["additionalContext"] - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) @@ -126,7 +126,7 @@ class TestAutofixPython: parsed = json.loads(result["stdout"]) assert "TYPE: L42" in parsed["hookSpecificOutput"]["additionalContext"] - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) @@ -144,7 +144,7 @@ class TestAutofixPython: class TestAutoFixStateFile: - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured") @@ -171,7 +171,7 @@ class TestAutoFixStateFile: if state_path.exists(): state_path.unlink() - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @@ -194,7 +194,7 @@ class TestAutoFixStateFile: class TestAutoFixJson: - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") def test_json_valid(self, mock_speak, tmp_path): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle @@ -205,7 +205,7 @@ class TestAutoFixJson: parsed = json.loads(result["stdout"]) assert parsed["systemMessage"] == "[diagnostics] ok" - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") def test_json_invalid_syntax(self, mock_speak, tmp_path): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle @@ -216,7 +216,7 @@ class TestAutoFixJson: parsed = json.loads(result["stdout"]) assert "JSON SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"] - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._speak") + @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") def test_json_corruption_detected(self, mock_speak, tmp_path): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle @@ -228,51 +228,6 @@ class TestAutoFixJson: assert "EMOJI CORRUPTION" in parsed["hookSpecificOutput"]["additionalContext"] -class TestAutoFixPiper: - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.PIPER_VOICE") - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.PIPER_BIN") - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) - @patch("subprocess.run") - @patch("subprocess.Popen") - def test_piper_fires_on_edit( - self, - mock_popen, - mock_run, - mock_py, - mock_ruff_s, - mock_pyright, - mock_seedgo, - mock_piper_bin, - mock_piper_voice, - ): - from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle - - mock_piper_bin.exists.return_value = True - mock_piper_voice.exists.return_value = True - mock_run_result = MagicMock() - mock_run_result.returncode = 0 - mock_run.return_value = mock_run_result - - with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.Path") as mock_path_cls: - mock_path_cls.return_value.suffix.lower.return_value = ".py" - mock_path_cls.return_value.name = "test.py" - mock_path_cls.return_value.exists.return_value = True - - handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/test.py"}}) - - assert mock_run.called - - def test_piper_skips_when_unavailable(self): - from aipass.hooks.apps.handlers.lifecycle.auto_fix import _speak - - with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.PIPER_BIN") as mock_bin: - mock_bin.exists.return_value = False - _speak("test") - - class TestAutoFixSubprocessChecks: @patch("subprocess.run") def test_check_syntax_error(self, mock_run): diff --git a/src/aipass/hooks/tests/test_branch_loader.py b/src/aipass/hooks/tests/test_branch_loader.py index b48c1ccf..cc219d64 100644 --- a/src/aipass/hooks/tests/test_branch_loader.py +++ b/src/aipass/hooks/tests/test_branch_loader.py @@ -24,7 +24,7 @@ class TestBranchLoaderHandler: prompt = aipass_dir / "aipass_local_prompt.md" prompt.write_text("# Test Branch\nSome instructions", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 @@ -41,7 +41,7 @@ class TestBranchLoaderHandler: private = integration / "private_prompt.md" private.write_text("# Private Integration\nSecret stuff", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): result = handle({"cwd": str(tmp_path)}) assert "Private Integration" in result["stdout"] @@ -58,7 +58,7 @@ class TestBranchLoaderHandler: integration.mkdir(parents=True) (integration / "private_prompt.md").write_text("Compass prompt", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): result = handle({"cwd": str(tmp_path)}) assert "Branch prompt" in result["stdout"] @@ -67,7 +67,7 @@ class TestBranchLoaderHandler: def test_returns_empty_when_no_branch_root(self, tmp_path): from aipass.hooks.apps.handlers.prompt.branch_loader import handle - with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): result = handle({"cwd": str(tmp_path)}) assert result["stdout"] == "" @@ -79,7 +79,7 @@ class TestBranchLoaderHandler: nested = tmp_path / "some" / "deep" / "path" nested.mkdir(parents=True) - with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): result = handle({"cwd": str(nested)}) assert result["stdout"] == "" @@ -95,7 +95,7 @@ class TestBranchLoaderHandler: nested = tmp_path / "apps" / "handlers" / "security" nested.mkdir(parents=True) - with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): result = handle({"cwd": str(nested)}) assert "Found it" in result["stdout"] @@ -103,7 +103,7 @@ class TestBranchLoaderHandler: def test_empty_hook_data(self): from aipass.hooks.apps.handlers.prompt.branch_loader import handle - with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): result = handle({}) @@ -116,7 +116,7 @@ class TestBranchLoaderHandler: trinity = tmp_path / ".trinity" trinity.mkdir() - with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): result = handle({"cwd": str(tmp_path)}) assert result["stdout"] == "" @@ -130,7 +130,7 @@ class TestBranchLoaderHandler: aipass_dir.mkdir() (aipass_dir / "aipass_local_prompt.md").write_text("content", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.branch_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): result = handle({"cwd": str(tmp_path)}) assert "Source:" in result["stdout"] diff --git a/src/aipass/hooks/tests/test_compact.py b/src/aipass/hooks/tests/test_compact.py index bb595717..bc7e05d0 100644 --- a/src/aipass/hooks/tests/test_compact.py +++ b/src/aipass/hooks/tests/test_compact.py @@ -32,7 +32,7 @@ class TestCompactHandler: status = tmp_path / "STATUS.local.md" status.write_text("# Status\nCurrent work here", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"): result = handle({"cwd": str(tmp_path)}) @@ -45,7 +45,7 @@ class TestCompactHandler: def test_returns_recovery_when_no_branch_dir(self): from aipass.hooks.apps.handlers.lifecycle.compact import handle - with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): result = handle({"cwd": "/tmp/nonexistent"}) @@ -58,7 +58,7 @@ class TestCompactHandler: trinity = tmp_path / ".trinity" trinity.mkdir() - with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "dispatched"}): result = handle({"cwd": str(tmp_path)}) @@ -71,7 +71,7 @@ class TestCompactHandler: trinity = tmp_path / ".trinity" trinity.mkdir() - with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): result = handle({"cwd": str(tmp_path)}) @@ -80,7 +80,7 @@ class TestCompactHandler: def test_empty_hook_data(self): from aipass.hooks.apps.handlers.lifecycle.compact import handle - with patch("aipass.hooks.apps.handlers.lifecycle.compact._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): with patch("pathlib.Path.cwd", return_value=MagicMock(parts=("/", "tmp"))): result = handle({}) diff --git a/src/aipass/hooks/tests/test_email.py b/src/aipass/hooks/tests/test_email.py index ec0670db..c2fb2804 100644 --- a/src/aipass/hooks/tests/test_email.py +++ b/src/aipass/hooks/tests/test_email.py @@ -1,17 +1,17 @@ # =================== AIPass ==================== # Name: test_email.py -# Version: 1.1.0 +# Version: 1.2.0 # Description: Tests for email notification handler # Branch: hooks # Created: 2026-05-21 -# Modified: 2026-05-21 +# Modified: 2026-05-22 # ============================================= """Tests for handlers/notification/email.py.""" import json from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import patch class TestEmailHandler: @@ -46,7 +46,7 @@ class TestEmailHandler: "aipass.hooks.apps.handlers.notification.email._find_branch_root", return_value=tmp_path, ), - patch("aipass.hooks.apps.handlers.notification.email._speak"), + patch("aipass.hooks.apps.handlers.notification.email.speak"), ): result = handle({}) @@ -70,7 +70,7 @@ class TestEmailHandler: "aipass.hooks.apps.handlers.notification.email._find_branch_root", return_value=tmp_path, ), - patch("aipass.hooks.apps.handlers.notification.email._speak") as mock_speak, + patch("aipass.hooks.apps.handlers.notification.email.speak") as mock_speak, ): handle({}) @@ -92,7 +92,7 @@ class TestEmailHandler: "aipass.hooks.apps.handlers.notification.email._find_branch_root", return_value=tmp_path, ), - patch("aipass.hooks.apps.handlers.notification.email._speak") as mock_speak, + patch("aipass.hooks.apps.handlers.notification.email.speak") as mock_speak, ): handle({}) @@ -114,7 +114,7 @@ class TestEmailHandler: "aipass.hooks.apps.handlers.notification.email._find_branch_root", return_value=tmp_path, ), - patch("aipass.hooks.apps.handlers.notification.email._speak"), + patch("aipass.hooks.apps.handlers.notification.email.speak"), ): result = handle({}) @@ -145,7 +145,7 @@ class TestEmailHandler: "aipass.hooks.apps.handlers.notification.email._find_branch_root", return_value=tmp_path, ), - patch("aipass.hooks.apps.handlers.notification.email._speak"), + patch("aipass.hooks.apps.handlers.notification.email.speak"), ): result = handle({}) @@ -302,79 +302,3 @@ class TestFindBranchRoot: result = _find_branch_root() assert result is None - - -class TestSpeakFunction: - """Piper TTS tests.""" - - def test_speak_calls_piper_then_aplay(self): - from aipass.hooks.apps.handlers.notification.email import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.email.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.email.subprocess") as mock_sub, - patch("aipass.hooks.apps.handlers.notification.email.tempfile") as mock_tmp, - patch("aipass.hooks.apps.handlers.notification.email.Path") as mock_path, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - mock_sub.run.return_value = MagicMock(returncode=0) - mock_path.return_value.exists.return_value = True - - _speak("test text") - - mock_sub.run.assert_called_once() - mock_sub.Popen.assert_called_once() - - def test_speak_skips_when_piper_missing(self): - from aipass.hooks.apps.handlers.notification.email import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.email.subprocess") as mock_sub, - ): - mock_piper_bin.exists.return_value = False - _speak("test") - - mock_sub.run.assert_not_called() - - def test_speak_graceful_on_timeout(self): - import subprocess as real_sub - from aipass.hooks.apps.handlers.notification.email import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.email.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.email.subprocess") as mock_sub, - patch("aipass.hooks.apps.handlers.notification.email.tempfile") as mock_tmp, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5) - mock_sub.TimeoutExpired = real_sub.TimeoutExpired - - _speak("test") - - def test_speak_graceful_on_os_error(self): - from aipass.hooks.apps.handlers.notification.email import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.email.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.email.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.email.subprocess.run", side_effect=OSError("broken")), - patch("aipass.hooks.apps.handlers.notification.email.tempfile") as mock_tmp, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - - _speak("test") diff --git a/src/aipass/hooks/tests/test_global_loader.py b/src/aipass/hooks/tests/test_global_loader.py index 4378e119..3d43068b 100644 --- a/src/aipass/hooks/tests/test_global_loader.py +++ b/src/aipass/hooks/tests/test_global_loader.py @@ -21,7 +21,7 @@ class TestGlobalLoaderHandler: prompt = aipass_dir / "aipass_global_prompt.md" prompt.write_text("# AIPass Global\nContext here", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): result = handle({}) @@ -32,7 +32,7 @@ class TestGlobalLoaderHandler: def test_returns_empty_when_no_aipass_home(self): from aipass.hooks.apps.handlers.prompt.global_loader import handle - with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): with patch.dict("os.environ", {}, clear=True): result = handle({}) @@ -42,7 +42,7 @@ class TestGlobalLoaderHandler: def test_returns_empty_when_file_missing(self, tmp_path): from aipass.hooks.apps.handlers.prompt.global_loader import handle - with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): result = handle({}) @@ -56,7 +56,7 @@ class TestGlobalLoaderHandler: aipass_dir.mkdir() (aipass_dir / "aipass_global_prompt.md").write_text("content", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.global_loader._speak"): + with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): result = handle({}) diff --git a/src/aipass/hooks/tests/test_hooksound.py b/src/aipass/hooks/tests/test_hooksound.py new file mode 100644 index 00000000..32cc7dbd --- /dev/null +++ b/src/aipass/hooks/tests/test_hooksound.py @@ -0,0 +1,94 @@ +# =================== AIPass ==================== +# Name: test_hooksound.py +# Version: 1.0.0 +# Description: Tests for hooksound module (drone @hooks hooksound) +# Branch: hooks +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Tests for modules/hooksound.py — mute/unmute hook audio.""" + +from unittest.mock import patch + + +class TestHandleCommand: + """Command routing tests.""" + + def test_returns_false_for_unknown_command(self): + from aipass.hooks.apps.modules.hooksound import handle_command + + assert handle_command("unknown", []) is False + + def test_routes_hooksound_command(self): + from aipass.hooks.apps.modules.hooksound import handle_command + + with patch("aipass.hooks.apps.modules.hooksound.is_muted", return_value=False): + assert handle_command("hooksound", []) is True + + def test_off_creates_mute_flag(self): + from aipass.hooks.apps.modules.hooksound import handle_command + + with patch("aipass.hooks.apps.modules.hooksound.MUTE_FLAG") as mock_flag: + result = handle_command("hooksound", ["off"]) + + assert result is True + mock_flag.touch.assert_called_once() + + def test_on_removes_mute_flag(self): + from aipass.hooks.apps.modules.hooksound import handle_command + + with patch("aipass.hooks.apps.modules.hooksound.MUTE_FLAG") as mock_flag: + mock_flag.exists.return_value = True + result = handle_command("hooksound", ["on"]) + + assert result is True + mock_flag.unlink.assert_called_once() + + def test_on_noop_when_not_muted(self): + from aipass.hooks.apps.modules.hooksound import handle_command + + with patch("aipass.hooks.apps.modules.hooksound.MUTE_FLAG") as mock_flag: + mock_flag.exists.return_value = False + result = handle_command("hooksound", ["on"]) + + assert result is True + mock_flag.unlink.assert_not_called() + + def test_status_shows_muted(self): + from aipass.hooks.apps.modules.hooksound import handle_command + + with patch("aipass.hooks.apps.modules.hooksound.is_muted", return_value=True): + assert handle_command("hooksound", []) is True + + def test_status_shows_active(self): + from aipass.hooks.apps.modules.hooksound import handle_command + + with patch("aipass.hooks.apps.modules.hooksound.is_muted", return_value=False): + assert handle_command("hooksound", []) is True + + def test_help_flag(self): + from aipass.hooks.apps.modules.hooksound import handle_command + + assert handle_command("hooksound", ["--help"]) is True + + def test_help_word(self): + from aipass.hooks.apps.modules.hooksound import handle_command + + assert handle_command("hooksound", ["help"]) is True + + +class TestPrintIntrospection: + """Module introspection tests.""" + + def test_prints_without_error(self): + from aipass.hooks.apps.modules.hooksound import print_introspection + + with patch("aipass.hooks.apps.modules.hooksound.is_muted", return_value=False): + print_introspection() + + def test_shows_muted_status(self): + from aipass.hooks.apps.modules.hooksound import print_introspection + + with patch("aipass.hooks.apps.modules.hooksound.is_muted", return_value=True): + print_introspection() diff --git a/src/aipass/hooks/tests/test_identity.py b/src/aipass/hooks/tests/test_identity.py index b3fdc848..58aab74a 100644 --- a/src/aipass/hooks/tests/test_identity.py +++ b/src/aipass/hooks/tests/test_identity.py @@ -11,7 +11,7 @@ import json from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import patch SAMPLE_PASSPORT = { @@ -40,7 +40,7 @@ class TestIdentityHandler: passport = trinity / "passport.json" passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 @@ -51,7 +51,7 @@ class TestIdentityHandler: def test_returns_empty_when_no_passport(self, tmp_path): from aipass.hooks.apps.handlers.prompt.identity import handle - with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 @@ -67,7 +67,7 @@ class TestIdentityHandler: nested = tmp_path / "apps" / "handlers" nested.mkdir(parents=True) - with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): result = handle({"cwd": str(nested)}) assert "devpulse Identity" in result["stdout"] @@ -80,7 +80,7 @@ class TestIdentityHandler: passport = trinity / "passport.json" passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): result = handle({"cwd": str(tmp_path)}) out = result["stdout"] @@ -100,7 +100,7 @@ class TestIdentityHandler: passport = trinity / "passport.json" passport.write_text(json.dumps({"branch_info": {"branch_name": "test"}, "identity": {}}), encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 @@ -109,7 +109,7 @@ class TestIdentityHandler: def test_empty_hook_data(self): from aipass.hooks.apps.handlers.prompt.identity import handle - with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): result = handle({}) @@ -124,28 +124,8 @@ class TestIdentityHandler: passport = trinity / "passport.json" passport.write_text("{broken json", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.identity._speak"): + with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 assert result["stdout"] == "" - - @patch("subprocess.Popen") - @patch("subprocess.run") - def test_piper_fires(self, mock_run, mock_popen): - from aipass.hooks.apps.handlers.prompt.identity import handle - - mock_run.return_value = MagicMock(returncode=0) - - with patch.object(Path, "exists", return_value=True): - handle({"cwd": "/tmp/nonexistent"}) - - assert mock_run.called or mock_popen.called - - def test_piper_skips_when_not_available(self): - from aipass.hooks.apps.handlers.prompt.identity import handle - - with patch("aipass.hooks.apps.handlers.prompt.identity.PIPER_BIN", Path("/nonexistent/piper")): - result = handle({"cwd": "/tmp/nonexistent"}) - - assert result["exit_code"] == 0 diff --git a/src/aipass/hooks/tests/test_rollover.py b/src/aipass/hooks/tests/test_rollover.py index e16c3edf..88aef64b 100644 --- a/src/aipass/hooks/tests/test_rollover.py +++ b/src/aipass/hooks/tests/test_rollover.py @@ -17,7 +17,7 @@ class TestRolloverHandler: def test_no_repo_root_returns_empty(self): from aipass.hooks.apps.handlers.lifecycle.rollover import handle - with patch("aipass.hooks.apps.handlers.lifecycle.rollover._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"): with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=None): result = handle({}) @@ -27,7 +27,7 @@ class TestRolloverHandler: def test_no_overdue_returns_empty(self): from aipass.hooks.apps.handlers.lifecycle.rollover import handle - with patch("aipass.hooks.apps.handlers.lifecycle.rollover._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"): with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()): with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", return_value=[]): result = handle({}) @@ -38,7 +38,7 @@ class TestRolloverHandler: def test_overdue_triggers_rollover(self): from aipass.hooks.apps.handlers.lifecycle.rollover import handle - with patch("aipass.hooks.apps.handlers.lifecycle.rollover._speak"): + with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"): with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()): with patch( "aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", diff --git a/src/aipass/hooks/tests/test_sound.py b/src/aipass/hooks/tests/test_sound.py new file mode 100644 index 00000000..d9dd6be3 --- /dev/null +++ b/src/aipass/hooks/tests/test_sound.py @@ -0,0 +1,177 @@ +# =================== AIPass ==================== +# Name: test_sound.py +# Version: 1.0.0 +# Description: Tests for shared sound module +# Branch: hooks +# Created: 2026-05-22 +# Modified: 2026-05-22 +# ============================================= + +"""Tests for apps/sound.py — shared speak/play with mute support.""" + +from pathlib import Path +from unittest.mock import patch, MagicMock + + +class TestIsMuted: + """Mute flag detection.""" + + def test_not_muted_when_flag_missing(self): + from aipass.hooks.apps.sound import is_muted + + with patch("aipass.hooks.apps.sound.MUTE_FLAG") as mock_flag: + mock_flag.exists.return_value = False + assert is_muted() is False + + def test_muted_when_flag_exists(self): + from aipass.hooks.apps.sound import is_muted + + with patch("aipass.hooks.apps.sound.MUTE_FLAG") as mock_flag: + mock_flag.exists.return_value = True + assert is_muted() is True + + +class TestSpeak: + """Piper TTS with mute support.""" + + def test_speak_calls_piper_when_not_muted(self): + from aipass.hooks.apps.sound import speak + + with ( + patch("aipass.hooks.apps.sound.is_muted", return_value=False), + patch("aipass.hooks.apps.sound.PIPER_BIN") as mock_bin, + patch("aipass.hooks.apps.sound.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.sound.subprocess") as mock_sub, + patch("aipass.hooks.apps.sound.tempfile") as mock_tmp, + patch("aipass.hooks.apps.sound.Path") as mock_path, + ): + mock_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.return_value = MagicMock(returncode=0) + mock_path.return_value.exists.return_value = True + + speak("test text") + + mock_sub.run.assert_called_once() + mock_sub.Popen.assert_called_once() + + def test_speak_skips_when_muted(self): + from aipass.hooks.apps.sound import speak + + with ( + patch("aipass.hooks.apps.sound.is_muted", return_value=True), + patch("aipass.hooks.apps.sound.subprocess") as mock_sub, + ): + speak("test") + + mock_sub.run.assert_not_called() + + def test_speak_skips_when_piper_missing(self): + from aipass.hooks.apps.sound import speak + + with ( + patch("aipass.hooks.apps.sound.is_muted", return_value=False), + patch("aipass.hooks.apps.sound.PIPER_BIN") as mock_bin, + patch("aipass.hooks.apps.sound.subprocess") as mock_sub, + ): + mock_bin.exists.return_value = False + speak("test") + + mock_sub.run.assert_not_called() + + def test_speak_graceful_on_timeout(self): + import subprocess as real_sub + from aipass.hooks.apps.sound import speak + + with ( + patch("aipass.hooks.apps.sound.is_muted", return_value=False), + patch("aipass.hooks.apps.sound.PIPER_BIN") as mock_bin, + patch("aipass.hooks.apps.sound.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.sound.subprocess") as mock_sub, + patch("aipass.hooks.apps.sound.tempfile") as mock_tmp, + ): + mock_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5) + mock_sub.TimeoutExpired = real_sub.TimeoutExpired + + speak("test") + + def test_speak_graceful_on_os_error(self): + from aipass.hooks.apps.sound import speak + + with ( + patch("aipass.hooks.apps.sound.is_muted", return_value=False), + patch("aipass.hooks.apps.sound.PIPER_BIN") as mock_bin, + patch("aipass.hooks.apps.sound.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.sound.subprocess.run", side_effect=OSError("broken")), + patch("aipass.hooks.apps.sound.tempfile") as mock_tmp, + ): + mock_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + + speak("test") + + +class TestPlay: + """WAV playback with mute support.""" + + def test_play_calls_aplay_when_not_muted(self): + from aipass.hooks.apps.sound import play + + with ( + patch("aipass.hooks.apps.sound.is_muted", return_value=False), + patch("aipass.hooks.apps.sound.subprocess") as mock_sub, + ): + mock_path = MagicMock() + mock_path.exists.return_value = True + + play(mock_path) + + mock_sub.Popen.assert_called_once() + + def test_play_skips_when_muted(self): + from aipass.hooks.apps.sound import play + + with ( + patch("aipass.hooks.apps.sound.is_muted", return_value=True), + patch("aipass.hooks.apps.sound.subprocess") as mock_sub, + ): + play(Path("/tmp/sound.wav")) + + mock_sub.Popen.assert_not_called() + + def test_play_skips_when_file_missing(self): + from aipass.hooks.apps.sound import play + + with ( + patch("aipass.hooks.apps.sound.is_muted", return_value=False), + patch("aipass.hooks.apps.sound.subprocess") as mock_sub, + ): + mock_path = MagicMock() + mock_path.exists.return_value = False + + play(mock_path) + + mock_sub.Popen.assert_not_called() + + def test_play_graceful_on_os_error(self): + from aipass.hooks.apps.sound import play + + with ( + patch("aipass.hooks.apps.sound.is_muted", return_value=False), + patch("aipass.hooks.apps.sound.subprocess.Popen", side_effect=OSError("no aplay")), + ): + mock_path = MagicMock() + mock_path.exists.return_value = True + + play(mock_path) diff --git a/src/aipass/hooks/tests/test_stop_sound.py b/src/aipass/hooks/tests/test_stop_sound.py index a69c0481..a0a1d6b0 100644 --- a/src/aipass/hooks/tests/test_stop_sound.py +++ b/src/aipass/hooks/tests/test_stop_sound.py @@ -1,15 +1,15 @@ # =================== AIPass ==================== # Name: test_stop_sound.py -# Version: 1.1.0 +# Version: 1.2.0 # Description: Tests for stop_sound notification handler # Branch: hooks # Created: 2026-05-20 -# Modified: 2026-05-20 +# Modified: 2026-05-22 # ============================================= """Tests for handlers/notification/stop_sound.py.""" -from unittest.mock import patch, MagicMock +from unittest.mock import patch class TestStopSoundHandler: @@ -18,10 +18,7 @@ class TestStopSoundHandler: def test_handle_returns_result_dict(self): from aipass.hooks.apps.handlers.notification.stop_sound import handle - with ( - patch("aipass.hooks.apps.handlers.notification.stop_sound._play"), - patch("aipass.hooks.apps.handlers.notification.stop_sound._speak"), - ): + with patch("aipass.hooks.apps.handlers.notification.stop_sound.speak"): result = handle({}) assert isinstance(result, dict) @@ -31,147 +28,16 @@ class TestStopSoundHandler: def test_handle_speaks_stop_sound(self): from aipass.hooks.apps.handlers.notification.stop_sound import handle - with ( - patch("aipass.hooks.apps.handlers.notification.stop_sound._play"), - patch("aipass.hooks.apps.handlers.notification.stop_sound._speak") as mock_speak, - ): + with patch("aipass.hooks.apps.handlers.notification.stop_sound.speak") as mock_speak: handle({}) mock_speak.assert_called_once_with("stop sound") - def test_handle_does_not_play_wav(self): - from aipass.hooks.apps.handlers.notification.stop_sound import handle - - with ( - patch("aipass.hooks.apps.handlers.notification.stop_sound._play") as mock_play, - patch("aipass.hooks.apps.handlers.notification.stop_sound._speak"), - ): - handle({}) - - mock_play.assert_not_called() - def test_handle_skips_when_stop_hook_active(self): from aipass.hooks.apps.handlers.notification.stop_sound import handle - with patch("aipass.hooks.apps.handlers.notification.stop_sound._speak") as mock_speak: + with patch("aipass.hooks.apps.handlers.notification.stop_sound.speak") as mock_speak: result = handle({"stop_hook_active": True}) mock_speak.assert_not_called() assert result["exit_code"] == 0 - - -class TestPlayFunction: - """WAV playback tests.""" - - def test_play_calls_aplay(self): - from aipass.hooks.apps.handlers.notification.stop_sound import _play - - mock_path = MagicMock() - mock_path.exists.return_value = True - - with patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess.Popen") as mock_popen: - _play(mock_path) - - mock_popen.assert_called_once() - args = mock_popen.call_args[0][0] - assert args[0] == "aplay" - assert args[1] == "-q" - - def test_play_skips_when_file_missing(self): - from aipass.hooks.apps.handlers.notification.stop_sound import _play - - mock_path = MagicMock() - mock_path.exists.return_value = False - - with patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess.Popen") as mock_popen: - _play(mock_path) - - mock_popen.assert_not_called() - - def test_play_graceful_on_os_error(self): - from aipass.hooks.apps.handlers.notification.stop_sound import _play - - mock_path = MagicMock() - mock_path.exists.return_value = True - - with patch( - "aipass.hooks.apps.handlers.notification.stop_sound.subprocess.Popen", - side_effect=OSError("broken"), - ): - _play(mock_path) - - -class TestSpeakFunction: - """Piper TTS tests.""" - - def test_speak_calls_piper_then_aplay(self): - from aipass.hooks.apps.handlers.notification.stop_sound import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess") as mock_sub, - patch("aipass.hooks.apps.handlers.notification.stop_sound.tempfile") as mock_tmp, - patch("aipass.hooks.apps.handlers.notification.stop_sound.Path") as mock_path, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - mock_sub.run.return_value = MagicMock(returncode=0) - mock_path.return_value.exists.return_value = True - - _speak("test text") - - mock_sub.run.assert_called_once() - mock_sub.Popen.assert_called_once() - - def test_speak_skips_when_piper_missing(self): - from aipass.hooks.apps.handlers.notification.stop_sound import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess") as mock_sub, - ): - mock_piper_bin.exists.return_value = False - _speak("test") - - mock_sub.run.assert_not_called() - - def test_speak_graceful_on_timeout(self): - import subprocess as real_sub - from aipass.hooks.apps.handlers.notification.stop_sound import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess") as mock_sub, - patch("aipass.hooks.apps.handlers.notification.stop_sound.tempfile") as mock_tmp, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5) - mock_sub.TimeoutExpired = real_sub.TimeoutExpired - - _speak("test") - - def test_speak_graceful_on_os_error(self): - from aipass.hooks.apps.handlers.notification.stop_sound import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.stop_sound.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.stop_sound.subprocess.run", side_effect=OSError("broken")), - patch("aipass.hooks.apps.handlers.notification.stop_sound.tempfile") as mock_tmp, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - - _speak("test") diff --git a/src/aipass/hooks/tests/test_subagent_gate.py b/src/aipass/hooks/tests/test_subagent_gate.py index 4cbcf400..e5c83899 100644 --- a/src/aipass/hooks/tests/test_subagent_gate.py +++ b/src/aipass/hooks/tests/test_subagent_gate.py @@ -18,14 +18,14 @@ from aipass.hooks.apps.handlers.security.subagent_gate import handle class TestSubagentGateHandler: def test_no_repo_root_allows(self): with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): - with patch("aipass.hooks.apps.handlers.security.subagent_gate._speak"): + with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"): result = handle({"cwd": "/tmp/nowhere"}) assert result["exit_code"] == 0 assert result["stdout"] == "" def test_no_modified_files_allows(self): with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): - with patch("aipass.hooks.apps.handlers.security.subagent_gate._speak"): + with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"): result = handle({"cwd": "/tmp/somewhere"}) assert result["exit_code"] == 0 assert result["stdout"] == "" @@ -34,7 +34,7 @@ class TestSubagentGateHandler: @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") def test_modified_files_no_violations_allows(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): from pathlib import Path @@ -48,7 +48,7 @@ class TestSubagentGateHandler: @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist") @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") def test_violations_blocks(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): from pathlib import Path @@ -64,7 +64,7 @@ class TestSubagentGateHandler: assert "bad.py" in parsed["reason"] @patch("subprocess.run") - @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") def test_skip_claude_hooks_from_modified_files(self, mock_speak, mock_run, tmp_path): src = tmp_path / "src" / "aipass" / "hooks" @@ -91,7 +91,7 @@ class TestSubagentGateHandler: @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") def test_readme_accountability_advisory(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): from pathlib import Path @@ -107,38 +107,16 @@ class TestSubagentGateHandler: assert parsed["decision"] == "allow" assert "README" in parsed["reason"] - @patch("subprocess.Popen") - @patch("subprocess.run") - def test_piper_fires_when_available(self, mock_run, mock_popen): - from pathlib import Path - from aipass.hooks.apps.handlers.security.subagent_gate import _speak - - mock_run.return_value = MagicMock(returncode=0) - with patch("aipass.hooks.apps.handlers.security.subagent_gate.PIPER_BIN", Path("/fake/piper")): - with patch("aipass.hooks.apps.handlers.security.subagent_gate.PIPER_VOICE", Path("/fake/voice.onnx")): - with patch("pathlib.Path.exists", return_value=True): - _speak("test") - mock_popen.assert_called_once() - - @patch("subprocess.Popen") - def test_piper_skips_when_not_available(self, mock_popen): - from pathlib import Path - from aipass.hooks.apps.handlers.security.subagent_gate import _speak - - with patch("aipass.hooks.apps.handlers.security.subagent_gate.PIPER_BIN", Path("/nonexistent/piper")): - _speak("test") - mock_popen.assert_not_called() - def test_empty_hook_data_allows(self): with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): - with patch("aipass.hooks.apps.handlers.security.subagent_gate._speak"): + with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"): result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate._speak") + @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") def test_exception_in_get_modified_allows(self, mock_speak, mock_root, mock_modified): from pathlib import Path diff --git a/src/aipass/hooks/tests/test_tool_sound.py b/src/aipass/hooks/tests/test_tool_sound.py index 5602856f..0e9cb1a7 100644 --- a/src/aipass/hooks/tests/test_tool_sound.py +++ b/src/aipass/hooks/tests/test_tool_sound.py @@ -1,15 +1,15 @@ # =================== AIPass ==================== # Name: test_tool_sound.py -# Version: 1.1.0 +# Version: 1.2.0 # Description: Tests for tool_sound notification handler # Branch: hooks # Created: 2026-05-19 -# Modified: 2026-05-19 +# Modified: 2026-05-22 # ============================================= """Tests for handlers/notification/tool_sound.py.""" -from unittest.mock import patch, MagicMock +from unittest.mock import patch class TestToolSoundHandler: @@ -18,7 +18,7 @@ class TestToolSoundHandler: def test_handle_returns_result_dict(self): from aipass.hooks.apps.handlers.notification.tool_sound import handle - with patch("aipass.hooks.apps.handlers.notification.tool_sound._speak"): + with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak"): result = handle({"tool_name": "Bash"}) assert isinstance(result, dict) @@ -30,7 +30,7 @@ class TestToolSoundHandler: def test_speaks_tool_name(self): from aipass.hooks.apps.handlers.notification.tool_sound import handle - with patch("aipass.hooks.apps.handlers.notification.tool_sound._speak") as mock_speak: + with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak") as mock_speak: handle({"tool_name": "Edit"}) mock_speak.assert_called_once_with("tool sound: Edit") @@ -38,7 +38,7 @@ class TestToolSoundHandler: def test_no_speak_when_no_tool_name(self): from aipass.hooks.apps.handlers.notification.tool_sound import handle - with patch("aipass.hooks.apps.handlers.notification.tool_sound._speak") as mock_speak: + with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak") as mock_speak: handle({}) mock_speak.assert_not_called() @@ -46,83 +46,7 @@ class TestToolSoundHandler: def test_no_speak_when_empty_tool_name(self): from aipass.hooks.apps.handlers.notification.tool_sound import handle - with patch("aipass.hooks.apps.handlers.notification.tool_sound._speak") as mock_speak: + with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak") as mock_speak: handle({"tool_name": ""}) mock_speak.assert_not_called() - - -class TestSpeakFunction: - """Piper TTS integration tests.""" - - def test_speak_calls_piper_then_aplay(self): - from aipass.hooks.apps.handlers.notification.tool_sound import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.tool_sound.subprocess") as mock_sub, - patch("aipass.hooks.apps.handlers.notification.tool_sound.tempfile") as mock_tmp, - patch("aipass.hooks.apps.handlers.notification.tool_sound.Path") as mock_path, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - mock_sub.run.return_value = MagicMock(returncode=0) - mock_path.return_value.exists.return_value = True - - _speak("test text") - - mock_sub.run.assert_called_once() - mock_sub.Popen.assert_called_once() - - def test_speak_skips_when_piper_missing(self): - from aipass.hooks.apps.handlers.notification.tool_sound import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.tool_sound.subprocess") as mock_sub, - ): - mock_piper_bin.exists.return_value = False - _speak("test") - - mock_sub.run.assert_not_called() - - def test_speak_graceful_on_timeout(self): - import subprocess as real_sub - from aipass.hooks.apps.handlers.notification.tool_sound import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.tool_sound.subprocess") as mock_sub, - patch("aipass.hooks.apps.handlers.notification.tool_sound.tempfile") as mock_tmp, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5) - mock_sub.TimeoutExpired = real_sub.TimeoutExpired - - _speak("test") - - def test_speak_graceful_on_os_error(self): - from aipass.hooks.apps.handlers.notification.tool_sound import _speak - - with ( - patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_BIN") as mock_piper_bin, - patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_VOICE") as mock_voice, - patch("aipass.hooks.apps.handlers.notification.tool_sound.subprocess.run", side_effect=OSError("broken")), - patch("aipass.hooks.apps.handlers.notification.tool_sound.tempfile") as mock_tmp, - ): - mock_piper_bin.exists.return_value = True - mock_voice.exists.return_value = True - mock_file = MagicMock() - mock_file.name = "/tmp/test.wav" - mock_tmp.NamedTemporaryFile.return_value = mock_file - - _speak("test")