feat(hooks): sound layer across the hook fleet + temporal grounding handler — sounds mirror the log (2465 green, audit 100, compass #157); new prompt/temporal.py injects live local date/time every turn, host-tz, wired both wires (hooks.json + provider manifest). Built by @hooks
This commit is contained in:
@@ -61,6 +61,11 @@
|
||||
"matcher": "",
|
||||
"timeout": 30
|
||||
},
|
||||
"temporal": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.temporal.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"user_message_relay": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
|
||||
|
||||
@@ -11,6 +11,14 @@ PyPI version — not the changelog header.
|
||||
|
||||
## [2026-07-21]
|
||||
|
||||
**feat(hooks)** — hook sound layer + temporal grounding. Sounds now mirror
|
||||
the log across the hook fleet (prompt, lifecycle, notification, security
|
||||
handlers) — audible liveness for the whole layer, verified live (2465 green,
|
||||
audit 100). New `prompt/temporal.py`: tiny always-on UserPromptSubmit handler
|
||||
injecting one line of local date/time/weekday/part-of-day every turn — live
|
||||
clock each fire, host timezone via `astimezone()` (clones see their own local
|
||||
time). Wired on both wires (`.aipass/hooks.json` + provider manifest).
|
||||
|
||||
**feat(aipass)** — `aipass adopt` + shared scaffold refactor: adopt turns an
|
||||
existing `projects/` directory into a full AIPass project (registry, resident
|
||||
agent, `.aipass`/`.claude` scaffold) — every write additive, nothing existing
|
||||
|
||||
@@ -9,7 +9,7 @@ HOOKS -- hook infrastructure owner. Single engine dispatches all hooks across pl
|
||||
## What I Do
|
||||
|
||||
- Own the hook engine -- receives events from platform bridges, routes to handlers, logs everything
|
||||
- Maintain 26 native handlers across 4 categories (prompt, security, lifecycle, notification)
|
||||
- Maintain 27 native handlers across 4 categories (prompt, security, lifecycle, notification)
|
||||
- Bridge platforms -- thin normalization layer per provider (Claude today, Codex planned)
|
||||
- Per-project config -- `.aipass/hooks.json` controls what fires per project
|
||||
- Log everything -- prax integration + JSONL diagnostics for every hook execution
|
||||
@@ -49,6 +49,7 @@ apps/
|
||||
compass_recall.py # Governance recall injection
|
||||
feedback_pulse.py # 10-turn cadence feedback nudge (disabled default)
|
||||
context_gauge.py # Live transcript-fill nudge toward /prep
|
||||
temporal.py # Weekday/date/time/tz/part-of-day, every turn
|
||||
persistent_alert.py # Advisory banners for .aipass/alerts.json
|
||||
security/ # Enforcement hooks
|
||||
presence_gate.py # Session presence gate (UserPromptSubmit + Stop release)
|
||||
@@ -77,14 +78,14 @@ apps/
|
||||
diagnostics.py # JSONL diagnostics config
|
||||
logs/
|
||||
engine.jsonl # JSONL diagnostics (every hook execution)
|
||||
tests/ # 42 test files, 1206 tests
|
||||
tests/ # 43 test files, 1249 tests
|
||||
```
|
||||
|
||||
## Handler Categories
|
||||
|
||||
| Category | Count | Handlers |
|
||||
|----------|-------|----------|
|
||||
| prompt | 8 | branch_loader, tier0_kernel, navmap, identity, compass_recall, feedback_pulse, context_gauge, persistent_alert |
|
||||
| prompt | 9 | branch_loader, tier0_kernel, navmap, identity, compass_recall, feedback_pulse, context_gauge, temporal, persistent_alert |
|
||||
| security | 6 | presence_gate, edit_gate, git_gate, rm_gate, registry_gate, subagent_gate |
|
||||
| lifecycle | 7 | auto_fix, auto_watchdog, auto_process, compact, rollover, pre_compact_prep, session_start |
|
||||
| notification | 5 | announce, email, stop_sound, tool_sound, telegram_response |
|
||||
|
||||
@@ -131,6 +131,11 @@
|
||||
"standard": "json_structure",
|
||||
"reason": "No JSON operations - reads markdown file (.aipass/tier1_navmap.md) and outputs text."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/prompt/temporal.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "No JSON operations - reads the system clock and outputs text. Wired in UserPromptSubmit.temporal (FPLAN-0348)."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/edit_gate.py",
|
||||
"standard": "dead_code",
|
||||
@@ -1263,11 +1268,22 @@
|
||||
"file": "apps/handlers/prompt/context_gauge.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Delegates to context_window module for transcript/settings reads — no direct JSON file ops needing json_handler. Same pattern as session_start.py delegating to cadence."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/pre_compact_prep.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Line 75: lazy cross-branch import of aipass.flow.apps.handlers.plan.get_open_plans — needed for the open-plan count in the AUTO-COMPACT SNAPSHOT. No module-layer equivalent exists to switch to: flow's own modules (close_plan.py) import this same handler directly rather than re-exporting it, and list_plans.py's module is a CLI display orchestrator, not a programmatic data source. Read-only cross-branch primitive read, same authorized pattern as ai_mail's dispatch_monitor.py broker-client import. Call is wrapped in try/except and degrades to None on any failure — never corrupts the snapshot."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/prompt/compass_recall.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Lines 49, 75, 84: lazy cross-branch imports of aipass.memory.apps.modules.governance and aipass.devpulse.apps.modules.compass. Surfacing other branches' rated decisions at prompt time IS this handler's job — not orchestration bleed but the designed integration point, same authorized cross-branch primitive pattern as ai_mail's dispatch_monitor.py. Whole handler wrapped in try/except and never blocks the prompt on failure."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
"removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.",
|
||||
"dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings — handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns.",
|
||||
"dplan_0253_2026-07-20": "Added json_structure bypasses for context_window.py, pre_compact_prep.py, context_gauge.py — same external-state-read profile as existing compact.py/cc_sessions.py/session_start.py bypasses."
|
||||
"dplan_0253_2026-07-20": "Added json_structure bypasses for context_window.py, pre_compact_prep.py, context_gauge.py — same external-state-read profile as existing compact.py/cc_sessions.py/session_start.py bypasses.",
|
||||
"dplan_0253_audit_catch_2026-07-21": "Seedgo's handlers checker went AST-based and caught two lazy cross-branch imports missed by the old text-matching version: pre_compact_prep.py (flow.get_open_plans) and compass_recall.py (memory.governance, devpulse.compass). Both bypassed rather than restructured — no clean module-layer alternative exists for the former, and the latter's whole purpose is surfacing cross-branch data at prompt time. Same authorized-primitive precedent as ai_mail dispatch_monitor.py."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ src/aipass/hooks/
|
||||
│ │ │ ├── identity.py # Injects passport identity block
|
||||
│ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable)
|
||||
│ │ │ ├── context_gauge.py # Nudges /prep before auto-compact fires (80%/95% of window)
|
||||
│ │ │ ├── temporal.py # Injects weekday/date/time/tz/part-of-day, every turn
|
||||
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
|
||||
│ │ ├── security/ # Enforcement hooks
|
||||
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
|
||||
@@ -114,7 +115,7 @@ src/aipass/hooks/
|
||||
│ └── diagnostics.py # JSONL logging for hook execution
|
||||
├── logs/
|
||||
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
|
||||
└── tests/ # 1206 tests across 42 test files
|
||||
└── tests/ # 1249 tests across 43 test files
|
||||
```
|
||||
|
||||
## How It Works
|
||||
@@ -135,7 +136,7 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
|
||||
|
||||
| Event | Hooks | Description |
|
||||
|---|---|---|
|
||||
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, context_gauge, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + context gauge + auto-process + TG mirror |
|
||||
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, context_gauge, temporal, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + context gauge + temporal + auto-process + TG mirror |
|
||||
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound |
|
||||
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
|
||||
| SubagentStop | subagent_gate | Seedgo validation |
|
||||
|
||||
@@ -67,7 +67,7 @@ def handle(hook_data: dict) -> dict:
|
||||
logger.info("[HOOKS] auto_process: no-op (nothing to process)")
|
||||
|
||||
_mark_session_ran()
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
return {"stdout": "", "exit_code": 0, "sound": "auto process"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("[HOOKS] auto_process: error: %s", exc)
|
||||
|
||||
@@ -239,7 +239,7 @@ def handle(hook_data: dict) -> dict:
|
||||
stamped = _stamp_session_entry(branch_dir, snapshot, cap)
|
||||
logger.info("[HOOKS] pre_compact_prep: snapshot stamped=%s branch=%s", stamped, branch_dir.name)
|
||||
|
||||
return {"stdout": snapshot, "exit_code": 0}
|
||||
return {"stdout": snapshot, "exit_code": 0, "sound": "pre compact prep"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] pre_compact_prep: unexpected error: %s", exc)
|
||||
|
||||
@@ -37,5 +37,6 @@ def handle(hook_data: dict) -> dict:
|
||||
logger.info("[HOOKS] session_start: cadence reset (source=%s)", source)
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] session_start: cadence reset failed: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
return {"stdout": "", "exit_code": 0, "sound": "cadence reset"}
|
||||
|
||||
@@ -599,7 +599,7 @@ def handle(hook_data: dict) -> dict:
|
||||
else:
|
||||
logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry")
|
||||
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
return {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
||||
|
||||
|
||||
def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None:
|
||||
|
||||
@@ -110,7 +110,7 @@ def handle(hook_data: dict) -> dict:
|
||||
mark_surfaced([c["id"] for c in approved])
|
||||
json_handler.log_operation("compass_recall", {"count": len(approved)})
|
||||
|
||||
return {"stdout": "\n".join(lines), "exit_code": 0}
|
||||
return {"stdout": "\n".join(lines), "exit_code": 0, "sound": "compass recall"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] compass_recall_unreachable: %s", exc)
|
||||
|
||||
@@ -84,6 +84,7 @@ def handle(hook_data: dict) -> dict:
|
||||
"AND wrap up the current work item. Auto-compact is imminent."
|
||||
),
|
||||
"exit_code": 0,
|
||||
"sound": "context gauge",
|
||||
}
|
||||
|
||||
if pct >= _NUDGE_THRESHOLD_PCT and not _already_fired(session_id, "80"):
|
||||
@@ -95,6 +96,7 @@ def handle(hook_data: dict) -> dict:
|
||||
"before auto-compact takes the choice away."
|
||||
),
|
||||
"exit_code": 0,
|
||||
"sound": "context gauge",
|
||||
}
|
||||
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
@@ -87,6 +87,7 @@ def handle(hook_data: dict) -> dict:
|
||||
|
||||
cwd = hook_data.get("cwd", "")
|
||||
if _is_disabled(cwd or None):
|
||||
logger.info("[HOOKS] feedback_pulse: skipped (disabled via .aipass/feedback_off sentinel)")
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
json_handler.log_operation("feedback_pulse", {"turn": turn})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: temporal.py
|
||||
# Version: 1.0.0
|
||||
# Description: Every-turn local date/time/weekday/part-of-day injection (UserPromptSubmit)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/prompt
|
||||
# Created: 2026-07-21
|
||||
# Modified: 2026-07-21
|
||||
# =============================================
|
||||
|
||||
"""Tiny always-on temporal grounding line — no cadence gating, fires every
|
||||
turn. Reads the live clock each fire (no caching) and injects one short line:
|
||||
weekday, date, 24h time, tz abbreviation, part of day. Timezone comes from
|
||||
the host system (astimezone()) — never hardcoded, so clones running outside
|
||||
Vancouver still show their own local time. Keep it to one line — every-turn
|
||||
cost matters."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
def _part_of_day(hour: int) -> str:
|
||||
"""Bucket an hour (0-23) into morning/afternoon/evening/night."""
|
||||
if 5 <= hour < 12:
|
||||
return "morning"
|
||||
if 12 <= hour < 17:
|
||||
return "afternoon"
|
||||
if 17 <= hour < 22:
|
||||
return "evening"
|
||||
return "night"
|
||||
|
||||
|
||||
def _format_line(now: datetime) -> str:
|
||||
weekday = now.strftime("%a")
|
||||
date = now.strftime("%Y-%m-%d")
|
||||
time_str = now.strftime("%H:%M")
|
||||
tz = now.strftime("%Z")
|
||||
part = _part_of_day(now.hour)
|
||||
clock = f"{weekday} {date} {time_str} {tz}" if tz else f"{weekday} {date} {time_str}"
|
||||
return f"Temporal: {clock} ({part})"
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Inject the current local temporal line — every turn, no cadence."""
|
||||
try:
|
||||
line = _format_line(datetime.now().astimezone())
|
||||
return {"stdout": line, "exit_code": 0, "sound": "temporal"}
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] temporal: unexpected error: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -131,7 +131,7 @@ def handle(hook_data: dict) -> dict:
|
||||
|
||||
if _OBSERVE_ONLY:
|
||||
logger.warning("[presence_gate] OBSERVE-ONLY would-block: %s", reason)
|
||||
return _ALLOW
|
||||
return {**_ALLOW, "sound": "presence gate"}
|
||||
|
||||
logger.warning("[presence_gate] BLOCKED: %s", reason)
|
||||
return {
|
||||
|
||||
@@ -43,6 +43,7 @@ class TestAutoProcessHandler:
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert result["sound"] == "auto process"
|
||||
|
||||
def test_calls_memory_auto_process_module(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_process import handle
|
||||
@@ -168,6 +169,7 @@ class TestAutoProcessHandler:
|
||||
result = handle({"tool_name": "Bash", "cwd": "/tmp"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["sound"] == "auto process"
|
||||
|
||||
|
||||
class TestSessionGuard:
|
||||
|
||||
@@ -99,6 +99,7 @@ class TestCompassRecallHandler:
|
||||
assert result["exit_code"] == 0
|
||||
assert "[GOOD] #56:" in result["stdout"]
|
||||
assert "Never hardcode config in prompts" in result["stdout"]
|
||||
assert result["sound"] == "compass recall"
|
||||
mock_mark.assert_called_once_with([56])
|
||||
|
||||
def test_formats_bad_rating(self, tmp_path):
|
||||
|
||||
@@ -63,6 +63,7 @@ class TestContextGaugeHandle:
|
||||
assert "CONTEXT GAUGE" in result["stdout"]
|
||||
assert "run /prep NOW" in result["stdout"]
|
||||
assert "wrap up the current work item" not in result["stdout"]
|
||||
assert result["sound"] == "context gauge"
|
||||
|
||||
def test_fires_escalate_at_95_percent_of_trigger(self, tmp_path, monkeypatch):
|
||||
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
|
||||
@@ -77,6 +78,7 @@ class TestContextGaugeHandle:
|
||||
assert result["exit_code"] == 0
|
||||
assert "CONTEXT GAUGE" in result["stdout"]
|
||||
assert "wrap up the current work item" in result["stdout"]
|
||||
assert result["sound"] == "context gauge"
|
||||
|
||||
def test_fires_once_per_threshold_per_session(self, tmp_path, monkeypatch):
|
||||
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
|
||||
|
||||
@@ -107,6 +107,25 @@ class TestFeedbackPulseHandler:
|
||||
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_disabled_logs_distinguishing_line(self, tmp_path, caplog):
|
||||
import logging
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||
return_value=True,
|
||||
),
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
for i in range(11):
|
||||
self._handler()({"session_id": "test-disabled-log"})
|
||||
|
||||
assert "skipped (disabled via .aipass/feedback_off sentinel)" in caplog.text
|
||||
|
||||
def test_output_is_one_line(self, tmp_path):
|
||||
result = {"stdout": "", "exit_code": 0}
|
||||
with (
|
||||
|
||||
@@ -40,6 +40,7 @@ class TestHandle:
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "AUTO-COMPACT SNAPSHOT" in result["stdout"]
|
||||
assert result["sound"] == "pre compact prep"
|
||||
|
||||
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
|
||||
assert len(data["sessions"]) == 2
|
||||
|
||||
@@ -170,6 +170,7 @@ class TestHandle:
|
||||
):
|
||||
result = presence_gate.handle({})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["sound"] == "presence gate"
|
||||
|
||||
def test_gate_error_allows(self):
|
||||
with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True):
|
||||
|
||||
@@ -49,6 +49,7 @@ class TestSessionStartHandler:
|
||||
result = handle({"source": "startup", "session_id": "test-session"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["sound"] == "cadence reset"
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == -1
|
||||
|
||||
|
||||
@@ -857,7 +857,7 @@ class TestHandleIntegration:
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {"stdout": "", "exit_code": 0}
|
||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
||||
assert pending_file.exists()
|
||||
updated = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
assert updated["delivered"] is True
|
||||
@@ -902,7 +902,7 @@ class TestHandleIntegration:
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {"stdout": "", "exit_code": 0}
|
||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
||||
assert pending_file.exists()
|
||||
updated = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
assert "delivered" not in updated
|
||||
@@ -996,7 +996,7 @@ class TestHandleIntegration:
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {"stdout": "", "exit_code": 0}
|
||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
||||
assert extract_call_count >= 2
|
||||
assert pending_file.exists()
|
||||
updated = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
@@ -1035,7 +1035,7 @@ class TestHandleIntegration:
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {"stdout": "", "exit_code": 0}
|
||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
||||
assert not pending_file.exists()
|
||||
|
||||
def test_already_delivered_skips_fallback(self, tmp_path):
|
||||
@@ -1989,7 +1989,7 @@ class TestHandleMirrorIntegration:
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {"stdout": "", "exit_code": 0}
|
||||
assert result == {"stdout": "", "exit_code": 0, "sound": "telegram response"}
|
||||
assert len(sent_texts) == 1
|
||||
assert "You: What is this?" in sent_texts[0]
|
||||
assert "This is the answer." in sent_texts[0]
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_temporal.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for temporal prompt handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-07-21
|
||||
# Modified: 2026-07-21
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/prompt/temporal.py."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
_PDT = timezone(timedelta(hours=-7), "PDT")
|
||||
_UTC = timezone(timedelta(hours=0), "UTC")
|
||||
|
||||
|
||||
class TestPartOfDay:
|
||||
def test_morning_lower_bound(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(5) == "morning"
|
||||
|
||||
def test_morning_upper_bound(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(11) == "morning"
|
||||
|
||||
def test_afternoon_lower_bound(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(12) == "afternoon"
|
||||
|
||||
def test_afternoon_upper_bound(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(16) == "afternoon"
|
||||
|
||||
def test_evening_lower_bound(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(17) == "evening"
|
||||
|
||||
def test_evening_upper_bound(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(21) == "evening"
|
||||
|
||||
def test_night_wraps_midnight_upper(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(22) == "night"
|
||||
|
||||
def test_night_wraps_midnight_lower(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(0) == "night"
|
||||
|
||||
def test_night_upper_bound(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(4) == "night"
|
||||
|
||||
@pytest.mark.parametrize("hour", range(24))
|
||||
def test_every_hour_has_exactly_one_bucket(self, hour):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _part_of_day
|
||||
|
||||
assert _part_of_day(hour) in {"morning", "afternoon", "evening", "night"}
|
||||
|
||||
|
||||
class TestFormatLine:
|
||||
def test_shape_with_tz(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
|
||||
|
||||
line = _format_line(datetime(2026, 7, 21, 9, 42, tzinfo=_PDT))
|
||||
assert line == "Temporal: Tue 2026-07-21 09:42 PDT (morning)"
|
||||
|
||||
def test_afternoon_example_different_tz(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
|
||||
|
||||
line = _format_line(datetime(2026, 7, 21, 14, 5, tzinfo=_UTC))
|
||||
assert line == "Temporal: Tue 2026-07-21 14:05 UTC (afternoon)"
|
||||
|
||||
def test_night_example_after_midnight(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
|
||||
|
||||
line = _format_line(datetime(2026, 7, 22, 0, 30, tzinfo=_PDT))
|
||||
assert line == "Temporal: Wed 2026-07-22 00:30 PDT (night)"
|
||||
|
||||
def test_naive_datetime_omits_tz(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
|
||||
|
||||
line = _format_line(datetime(2026, 7, 21, 9, 42))
|
||||
assert line == "Temporal: Tue 2026-07-21 09:42 (morning)"
|
||||
|
||||
def test_is_a_single_line(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import _format_line
|
||||
|
||||
line = _format_line(datetime(2026, 7, 21, 9, 42, tzinfo=_PDT))
|
||||
assert "\n" not in line
|
||||
|
||||
|
||||
class TestHandle:
|
||||
def test_injects_temporal_line_every_call(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import handle
|
||||
|
||||
result = handle({})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"].startswith("Temporal: ")
|
||||
assert result["sound"] == "temporal"
|
||||
|
||||
def test_uses_host_local_tz_not_a_hardcoded_one(self):
|
||||
# Test host's own zone — never assume PDT/UTC/anything specific,
|
||||
# a clone running in another zone must see its own local time.
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import handle
|
||||
|
||||
expected_tz = datetime.now().astimezone().strftime("%Z")
|
||||
result = handle({})
|
||||
if expected_tz:
|
||||
assert expected_tz in result["stdout"]
|
||||
|
||||
def test_no_cadence_gating_fires_on_repeated_calls(self):
|
||||
from aipass.hooks.apps.handlers.prompt.temporal import handle
|
||||
|
||||
first = handle({"session_id": "s1"})
|
||||
second = handle({"session_id": "s1"})
|
||||
assert first["stdout"] != ""
|
||||
assert second["stdout"] != ""
|
||||
|
||||
def test_never_raises_on_unexpected_error(self, monkeypatch):
|
||||
from aipass.hooks.apps.handlers.prompt import temporal
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(temporal, "_format_line", _boom)
|
||||
result = temporal.handle({"session_id": "s-err"})
|
||||
assert result == {"stdout": "", "exit_code": 0}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user