From c70c62e446d7360b75192fe8be71fbbe1830e4e3 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Fri, 28 Aug 2026 04:43:08 +0800 Subject: [PATCH 1/2] report Fitbit health and sleep to Wake --- akashic.plugin.toml | 2 +- plugin.py | 45 ++-- src/content_adapter.py | 104 ++++---- src/sleep_context.py | 32 --- tests/test_content_adapter.py | 479 +++++++++------------------------- tests/test_plugin.py | 44 ++-- tests/test_sleep_context.py | 46 +--- 7 files changed, 216 insertions(+), 536 deletions(-) diff --git a/akashic.plugin.toml b/akashic.plugin.toml index 0ba1801..ac87fde 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -1,6 +1,6 @@ schema_version = 1 name = "fitbit" -version = "3.1.0" +version = "3.2.0" api_version = 3 entrypoint = "plugin.py" diff --git a/plugin.py b/plugin.py index c422dd4..00718c3 100644 --- a/plugin.py +++ b/plugin.py @@ -1,11 +1,8 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -from typing import Protocol - from pydantic import BaseModel, ConfigDict, Field -from agent.lifecycle.composition import CONTEXT_PREPARED_EVENT from agent.plugin_composition import ( MANAGED_PROCESSES, MCP_SERVERS, @@ -19,22 +16,17 @@ McpServerDefinition, MobileUiDefinition, MobileUiNavigation, - ServiceKey, +) +from plugins.wake.contracts import ( + WAKE_ALERT_SOURCE, + WAKE_CONTEXT_SOURCE, ) from .src.content_adapter import ( - BoundContentSource, - FitbitContentRuntime, + FitbitWakeRuntime, FitbitMonitorClient, ) from .src.mobile_reader import mobile_ui_query -from .src.sleep_context import FitbitAdapterStore, SleepContextAppender - - -class ContentSourceServices(Protocol): - def bind(self, source_id: str) -> BoundContentSource: ... - - -CONTENT_SOURCE = ServiceKey[ContentSourceServices]("content.source.v1") +from .src.sleep_context import FitbitAdapterStore class FitbitContentConfig(BaseModel): @@ -52,15 +44,22 @@ class FitbitConfig(BaseModel): api_version = 3 name = "fitbit" -version = "3.1.0" -desc = "Fitbit health monitor, Content source, and sleep context" +version = "3.2.0" +desc = "Fitbit health Alert and sleep Context source" Config = FitbitConfig -inject = (MANAGED_PROCESSES, MCP_SERVERS, TIMERS, CONTENT_SOURCE, UI_SLOTS) +inject = ( + MANAGED_PROCESSES, + MCP_SERVERS, + TIMERS, + WAKE_ALERT_SOURCE, + WAKE_CONTEXT_SOURCE, + UI_SLOTS, +) dashboard_module = "dashboard.py" async def apply(ctx: Context, config: FitbitConfig) -> None: - """装配 monitor、工具、Content 采集、睡眠上下文与移动界面。""" + """装配 monitor、工具、Wake 来源和移动界面。""" # 1. 登记现有 monitor 与用户显式调用的普通 MCP 工具 await ctx.require(MANAGED_PROCESSES).register( @@ -93,10 +92,11 @@ async def apply(ctx: Context, config: FitbitConfig) -> None: # 2. 绑定唯一正式来源;candidate Root 不会收到 STARTED store = FitbitAdapterStore(ctx.data_root / "adapter.sqlite3") store.initialize(datetime.now(UTC)) - runtime = FitbitContentRuntime( + runtime = FitbitWakeRuntime( store, ctx.require(TIMERS), - ctx.require(CONTENT_SOURCE).bind("fitbit-health-alerts"), + ctx.require(WAKE_ALERT_SOURCE), + ctx.require(WAKE_CONTEXT_SOURCE), FitbitMonitorClient(), poll_interval=timedelta(seconds=config.content.poll_interval_seconds), sleep_ttl=timedelta(seconds=config.content.sleep_ttl_seconds), @@ -105,8 +105,8 @@ async def apply(ctx: Context, config: FitbitConfig) -> None: def setup() -> object: return runtime.close - _ = await ctx.effect(setup, label="fitbit-content-runtime") - poll_health = await ctx.health("fitbit-content-poll") + _ = await ctx.effect(setup, label="fitbit-wake-runtime") + poll_health = await ctx.health("fitbit-wake-poll") async def start(_event: object) -> None: await runtime.start(ctx, poll_health) @@ -116,7 +116,6 @@ async def stop(_event: object) -> None: _ = await ctx.on(RUNTIME_STARTED, start) _ = await ctx.on(RUNTIME_STOPPING, stop) - _ = await ctx.on(CONTEXT_PREPARED_EVENT, SleepContextAppender(store).prepare) # 3. 在同一个 exact Root 上保留现有移动投影 await ctx.require(UI_SLOTS).register_mobile( diff --git a/src/content_adapter.py b/src/content_adapter.py index fdff540..c86c01f 100644 --- a/src/content_adapter.py +++ b/src/content_adapter.py @@ -3,28 +3,19 @@ import asyncio import hashlib import json -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Mapping from datetime import UTC, datetime, timedelta -from typing import Protocol, cast +from typing import cast from urllib.parse import quote import requests from agent.control.timer import TimerHandle, TimerStatus from agent.plugin_composition import Context, HealthHandle, PluginTimers +from plugins.wake.contracts import WakeAlertSource, WakeContextSource from .sleep_context import FitbitAdapterStore -class BoundContentSource(Protocol): - def submit( - self, batch_id: str, items: Sequence[Mapping[str, object]] - ) -> Mapping[str, object]: ... - - def unsettled(self, limit: int = 100) -> tuple[Mapping[str, object], ...]: ... - - def ack(self, settlement_ref: str) -> Mapping[str, object]: ... - - class FitbitMonitorTransientError(RuntimeError): """表示 monitor HTTP/IO 边界可在下一次 Timer 重试。""" @@ -63,14 +54,15 @@ def ensure_not_pending(self, event_id: str) -> None: raise RuntimeError(f"Fitbit event ACK 后仍在 pending 队列: {event_id}") -class FitbitContentRuntime: - """结算已投递 ACK,提交一次 monitor 快照,再登记一个 Timer。""" +class FitbitWakeRuntime: + """上报 Fitbit Alert 与 Context,再登记一个 Timer。""" def __init__( self, store: FitbitAdapterStore, timers: PluginTimers, - content: BoundContentSource, + alerts: WakeAlertSource, + context: WakeContextSource, monitor: FitbitMonitorClient, *, poll_interval: timedelta, @@ -80,7 +72,8 @@ def __init__( ) -> None: self._store = store self._timers = timers - self._content = content + self._alerts = alerts + self._context = context self._monitor = monitor self._poll_interval = poll_interval self._sleep_ttl = sleep_ttl @@ -94,10 +87,10 @@ async def start(self, ctx: Context, health: HealthHandle) -> None: """恢复来源 deadline,并启动唯一 Fiber-owned 采集循环。""" if self._closed: - raise RuntimeError("Fitbit Content runtime 已关闭") + raise RuntimeError("Fitbit Wake runtime 已关闭") if self._task is None: self._task = await ctx.spawn( - self._run(ctx, health), name="fitbit-content-poll" + self._run(ctx, health), name="fitbit-wake-poll" ) async def close(self) -> None: @@ -143,43 +136,45 @@ async def _run(self, ctx: Context, health: HealthHandle) -> None: def tick(self) -> None: """先结算历史投递,再发布当前 monitor 快照。""" - # 1. 先完成外部 ACK,再结算 Content - self._drain_unsettled() - - # 2. 只拉取一次,独立归一化健康事件,并优先提交 Content + # 1. 只拉取一次;终态 Alert 先 ACK,其余按稳定身份上报。 snapshot = self._monitor_snapshot() items = normalize_health_events(snapshot) - batch_id = stable_batch_id(items) - _ = self._content.submit(batch_id, items) - - # 3. Content 接受批次后,才推进私有缓存和 deadline now = _aware(self._now()) + for item in items: + event_id = str(item["item_id"]) + status = self._alerts.status( + source_id="fitbit-health-alerts", + event_id=event_id, + ) + if status in {"delivered", "skipped"}: + self._ensure_not_pending(event_id) + if self._after_provider_ack is not None: + self._after_provider_ack() + continue + _ = self._alerts.report( + source_id="fitbit-health-alerts", + event_id=event_id, + payload=_mapping(item, "payload"), + observed_at=now, + ) + + # 2. 睡眠状态是可覆盖、会过期的 Context,不参与 Content 初筛。 sleep = normalize_sleep(snapshot) + expires_at = now + self._sleep_ttl + _ = self._context.report( + source_id="fitbit-sleep", + event_id="current", + payload=sleep, + observed_at=now, + expires_at=expires_at, + ) self._store.commit_snapshot( sleep, observed_at=now, - expires_at=now + self._sleep_ttl, + expires_at=expires_at, next_due=now + self._poll_interval, ) - def _drain_unsettled(self) -> None: - while True: - rows = self._content.unsettled(limit=100) - for row in rows: - payload = _mapping(row, "payload") - event_id = _string(payload, "upstream_event_id") - settlement_ref = _string(row, "settlement_ref") - self._ensure_not_pending(event_id) - if self._after_provider_ack is not None: - self._after_provider_ack() - settled = self._content.ack(settlement_ref) - if settled.get("settled") is not True: - raise RuntimeError( - f"Fitbit Content ACK 未结算: {dict(settled)!r}" - ) - if len(rows) < 100: - return - def _monitor_snapshot(self) -> Mapping[str, object]: try: return self._monitor.snapshot() @@ -249,19 +244,6 @@ def normalize_sleep(snapshot: Mapping[str, object]) -> Mapping[str, object]: } -def stable_batch_id(items: Sequence[Mapping[str, object]]) -> str: - identity = sorted( - ( - {"item_id": item["item_id"], "revision": item["revision"]} - for item in items - ), - key=lambda item: (str(item["item_id"]), str(item["revision"])), - ) - return "fitbit-monitor:" + hashlib.sha256( - _canonical(identity).encode("utf-8") - ).hexdigest() - - def _event_id(value: object) -> str: if not isinstance(value, Mapping): raise TypeError("Fitbit health event 必须是对象") @@ -283,10 +265,12 @@ def _string(payload: Mapping[str, object], name: str) -> str: def _canonical(payload: object) -> str: - return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ) def _aware(value: datetime) -> datetime: if value.tzinfo is None: - raise ValueError("Fitbit Content clock 必须带时区") + raise ValueError("Fitbit Wake clock 必须带时区") return value.astimezone(UTC) diff --git a/src/sleep_context.py b/src/sleep_context.py index cf59107..abfee3a 100644 --- a/src/sleep_context.py +++ b/src/sleep_context.py @@ -8,9 +8,6 @@ from pathlib import Path from typing import cast -from agent.lifecycle.types import BeforeTurnCtx - - _SCHEMA = """ CREATE TABLE IF NOT EXISTS source_state( singleton INTEGER PRIMARY KEY CHECK(singleton = 1), @@ -114,35 +111,6 @@ def _transaction(self, *, write: bool) -> Generator[sqlite3.Connection]: connection.close() -class SleepContextAppender: - """只为 Wake Turn 追加未过期的 Fitbit 睡眠提示。""" - - def __init__(self, store: FitbitAdapterStore) -> None: - self._store = store - - async def prepare(self, ctx: BeforeTurnCtx) -> None: - if ctx.channel != "wake": - return - sleep = self._store.current_sleep(ctx.timestamp) - if sleep is None: - return - state = _string(sleep, "state") - probability = sleep.get("prob") - lag = sleep.get("data_lag_min") - ctx.extra_hints.append( - "Fitbit 睡眠上下文(概率判断,不是事实):" - f"state={state}, probability={probability}, data_lag_min={lag}。" - "若可能正在睡觉,普通内容应克制打扰;明显高兴趣或高相关内容仍可发送。" - ) - - -def _string(payload: Mapping[str, object], name: str) -> str: - value = payload.get(name) - if not isinstance(value, str) or not value: - raise ValueError(f"Fitbit sleep {name} 必须是非空字符串") - return value - - def _aware(value: datetime) -> datetime: if value.tzinfo is None: raise ValueError("Fitbit adapter 时间必须带时区") diff --git a/tests/test_content_adapter.py b/tests/test_content_adapter.py index 72b3884..e1ebbb8 100644 --- a/tests/test_content_adapter.py +++ b/tests/test_content_adapter.py @@ -1,32 +1,20 @@ from __future__ import annotations import asyncio -import json -import os -import signal -import sqlite3 -import subprocess -import sys -import threading -from collections.abc import Generator, Mapping, Sequence +from collections.abc import Mapping from datetime import UTC, datetime, timedelta -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import cast -from urllib.parse import unquote import pytest + from agent.control.timer import AsyncioOneShotTimer from agent.plugin_composition import CompositionRoot, PluginTimers -from plugins.content import plugin as content_plugin -from plugins.content.store import ContentIdentityConflict, ContentStore - +from plugins.wake.contracts import WakeAlertSource, WakeContextSource from src.content_adapter import ( - BoundContentSource, - FitbitContentRuntime, FitbitMonitorClient, + FitbitWakeRuntime, normalize_health_events, - stable_batch_id, ) from src.sleep_context import FitbitAdapterStore @@ -56,405 +44,192 @@ class RecordingMonitor: - def __init__(self, snapshot: Mapping[str, object]) -> None: - self.current = dict(snapshot) + def __init__(self) -> None: self.acknowledged: list[str] = [] def snapshot(self) -> Mapping[str, object]: - return self.current + return SNAPSHOT def ensure_not_pending(self, event_id: str) -> None: self.acknowledged.append(event_id) -def _bound(store: ContentStore, changed=lambda: None) -> BoundContentSource: - return content_plugin._SourceServices(store, changed).bind( - "fitbit-health-alerts" - ) +class RecordingAlerts: + def __init__(self) -> None: + self.reports: list[dict[str, object]] = [] + self.statuses: dict[str, str] = {} + + def report(self, **kwargs: object) -> Mapping[str, object]: + self.reports.append(dict(kwargs)) + return {"accepted": True} + + def status(self, *, source_id: str, event_id: str) -> str | None: + assert source_id == "fitbit-health-alerts" + return self.statuses.get(event_id) + + +class RecordingContext: + def __init__(self) -> None: + self.reports: list[dict[str, object]] = [] + + def report(self, **kwargs: object) -> Mapping[str, object]: + self.reports.append(dict(kwargs)) + return {"changed": True} def _runtime( tmp_path: Path, - content: BoundContentSource, - monitor: object, -) -> tuple[FitbitContentRuntime, FitbitAdapterStore]: + alerts: RecordingAlerts, + context: RecordingContext, + monitor: RecordingMonitor, +) -> tuple[FitbitWakeRuntime, FitbitAdapterStore]: store = FitbitAdapterStore(tmp_path / "adapter.sqlite3") store.initialize(NOW) - runtime = FitbitContentRuntime( + return ( + FitbitWakeRuntime( + store, + PluginTimers.candidate_validation(), + cast(WakeAlertSource, alerts), + cast(WakeContextSource, context), + cast(FitbitMonitorClient, monitor), + poll_interval=timedelta(minutes=5), + sleep_ttl=timedelta(minutes=10), + now=lambda: NOW, + ), store, - PluginTimers.candidate_validation(), - content, - cast(FitbitMonitorClient, monitor), - poll_interval=timedelta(minutes=5), - sleep_ttl=timedelta(minutes=10), - now=lambda: NOW, ) - return runtime, store -def test_submit_commits_before_private_deadline_and_replay_is_idempotent( +def test_health_reports_alert_and_sleep_reports_expiring_context( tmp_path: Path, ) -> None: - content_store = ContentStore(tmp_path / "content.sqlite3") - content_store.initialize() - calls = 0 - - def fail_after_commit() -> None: - nonlocal calls - calls += 1 - if calls == 1: - raise RuntimeError("post-commit hint failed") - - first, adapter = _runtime( - tmp_path, - _bound(content_store, fail_after_commit), - RecordingMonitor(SNAPSHOT), - ) - with pytest.raises(RuntimeError, match="post-commit hint failed"): - first.tick() + alerts = RecordingAlerts() + context = RecordingContext() + runtime, store = _runtime(tmp_path, alerts, context, RecordingMonitor()) - assert content_store.state_counts() == {"pending": 1} - assert adapter.next_due() == NOW - assert adapter.current_sleep(NOW) is None + runtime.tick() - second, adapter = _runtime( - tmp_path, - _bound(content_store), - RecordingMonitor(SNAPSHOT), - ) - second.tick() - assert content_store.state_counts() == {"pending": 1} - assert adapter.next_due() == NOW + timedelta(minutes=5) - sleep = adapter.current_sleep(NOW) - assert sleep is not None and sleep["state"] == "sleeping" - - -def test_health_content_and_sleep_cache_have_separate_owners(tmp_path: Path) -> None: - content_store = ContentStore(tmp_path / "content.sqlite3") - content_store.initialize() - runtime, adapter = _runtime( - tmp_path, - _bound(content_store), - RecordingMonitor(SNAPSHOT), - ) + assert alerts.reports[0]["event_id"] == "fitbit:event-1" + assert context.reports == [ + { + "source_id": "fitbit-sleep", + "event_id": "current", + "payload": store.current_sleep(NOW), + "observed_at": NOW, + "expires_at": NOW + timedelta(minutes=10), + } + ] + assert store.next_due() == NOW + timedelta(minutes=5) + + +@pytest.mark.parametrize("status", ["delivered", "skipped"]) +def test_terminal_alert_is_acknowledged_instead_of_reported( + tmp_path: Path, status: str +) -> None: + alerts = RecordingAlerts() + alerts.statuses["fitbit:event-1"] = status + context = RecordingContext() + monitor = RecordingMonitor() + runtime, _ = _runtime(tmp_path, alerts, context, monitor) runtime.tick() - snapshot = content_store.snapshot(NOW) - snapshot_items = cast(Sequence[Mapping[str, object]], snapshot["items"]) - assert len(snapshot_items) == 1 - payload = cast(Mapping[str, object], snapshot_items[0]["payload"]) - assert payload["upstream_event_id"] == "fitbit:event-1" - assert "sleep" not in payload - sleep = adapter.current_sleep(NOW) - assert sleep is not None and sleep["state"] == "sleeping" + assert monitor.acknowledged == ["fitbit:event-1"] + assert alerts.reports == [] + assert len(context.reports) == 1 -def test_normalization_has_stable_batch_and_revision() -> None: - first = normalize_health_events(SNAPSHOT) - second = normalize_health_events(json.loads(json.dumps(SNAPSHOT))) - assert first == second - assert stable_batch_id(first) == stable_batch_id(second) - assert first[0]["requires_ack"] is True +def test_revised_alert_replaces_pending_payload_and_acks_once(tmp_path: Path) -> None: + class RevisedMonitor(RecordingMonitor): + def __init__(self) -> None: + super().__init__() + self.current = SNAPSHOT + def snapshot(self) -> Mapping[str, object]: + return self.current -def test_batch_identity_does_not_depend_on_monitor_queue_order() -> None: - first = normalize_health_events(SNAPSHOT)[0] - second = { - **first, - "item_id": "fitbit:event-2", - "revision": "revision-2", - } - assert stable_batch_id((first, second)) == stable_batch_id((second, first)) - - -def test_reordered_monitor_batch_replays_identical_content_sequence(tmp_path) -> None: - second_event = { - "id": "fitbit:event-2", - "type": "spo2_low", - "message": "血氧偏低", - "severity": "high", - "created_at": "2026-08-23 08:01", - "suggested_tone": "先确认状态", - "metrics": {"spo2": 89}, - } - first_snapshot = { + alerts = RecordingAlerts() + context = RecordingContext() + monitor = RevisedMonitor() + runtime, _ = _runtime(tmp_path, alerts, context, monitor) + runtime.tick() + revised = { **SNAPSHOT, "health_events": [ - cast(list[Mapping[str, object]], SNAPSHOT["health_events"])[0], - second_event, + {**cast(list[dict[str, object]], SNAPSHOT["health_events"])[0], + "message": "静息心率已经恢复"} ], } - reordered = { - **first_snapshot, - "health_events": list(reversed(first_snapshot["health_events"])), - } - first_items = normalize_health_events(first_snapshot) - second_items = normalize_health_events(reordered) - assert second_items == first_items - - control_store = ContentStore(tmp_path / "conflict-control.sqlite3") - control_store.initialize() - control = _bound(control_store) - batch_id = stable_batch_id(first_items) - _ = control.submit(batch_id, first_items) - with pytest.raises(ContentIdentityConflict): - _ = control.submit(batch_id, tuple(reversed(first_items))) - - content_store = ContentStore(tmp_path / "content.sqlite3") - content_store.initialize() - bound = _bound(content_store) - first_receipt = bound.submit(batch_id, first_items) - replay_receipt = bound.submit(batch_id, second_items) + monitor.current = revised + runtime.tick() - assert replay_receipt == first_receipt - assert content_store.state_counts() == {"pending": 2} + assert [report["event_id"] for report in alerts.reports] == [ + "fitbit:event-1", + "fitbit:event-1", + ] + alerts.statuses["fitbit:event-1"] = "delivered" + runtime.tick() + assert monitor.acknowledged == ["fitbit:event-1"] -@pytest.mark.asyncio -async def test_reload_has_only_one_real_timer_wait(tmp_path: Path) -> None: - active = 0 - maximum = 0 - entered = asyncio.Event() - - async def sleeper(_delay: float) -> None: - nonlocal active, maximum - active += 1 - maximum = max(maximum, active) - entered.set() - try: - await asyncio.Future() - finally: - active -= 1 - - content_store = ContentStore(tmp_path / "content.sqlite3") - content_store.initialize() - adapter = FitbitAdapterStore(tmp_path / "adapter.sqlite3") - adapter.initialize(NOW) - timers = PluginTimers(AsyncioOneShotTimer(clock=lambda: NOW, sleeper=sleeper)) - - def make_runtime() -> FitbitContentRuntime: - return FitbitContentRuntime( - adapter, - timers, - _bound(content_store), - cast(FitbitMonitorClient, RecordingMonitor(SNAPSHOT)), - poll_interval=timedelta(minutes=5), - sleep_ttl=timedelta(minutes=10), - now=lambda: NOW, - ) - - first = make_runtime() - first_root = CompositionRoot("fitbit-reload:first") - first_health = await first_root.context.health("fitbit-content-poll") - await first.start(first_root.context, first_health) - await entered.wait() - assert active == 1 - await first.close() - assert active == 0 - await first_root.dispose() - - entered.clear() - second = make_runtime() - second_root = CompositionRoot("fitbit-reload:second") - second_health = await second_root.context.health("fitbit-content-poll") - await second.start(second_root.context, second_health) - await entered.wait() - assert active == 1 - assert maximum == 1 - await second.close() - await second_root.dispose() +def test_normalization_has_stable_identity_and_revision() -> None: + first = normalize_health_events(SNAPSHOT) + second = normalize_health_events(dict(SNAPSHOT)) + assert first == second + assert first[0]["item_id"] == "fitbit:event-1" + assert first[0]["requires_ack"] is True @pytest.mark.asyncio -async def test_transient_oserror_rearms_and_recovers_without_partial_state( - tmp_path: Path, -) -> None: - sleeper_calls = 0 - third_wait = asyncio.Event() +async def test_transient_monitor_error_rearms_and_recovers(tmp_path: Path) -> None: + waits = 0 + finished = asyncio.Event() async def sleeper(_delay: float) -> None: - nonlocal sleeper_calls - sleeper_calls += 1 - if sleeper_calls <= 2: + nonlocal waits + waits += 1 + if waits < 3: return - third_wait.set() + finished.set() await asyncio.Future() class FailOnceMonitor(RecordingMonitor): def __init__(self) -> None: - super().__init__(SNAPSHOT) - self.snapshot_calls = 0 + super().__init__() + self.calls = 0 def snapshot(self) -> Mapping[str, object]: - self.snapshot_calls += 1 - if self.snapshot_calls == 1: + self.calls += 1 + if self.calls == 1: raise OSError("temporary monitor read failure") return super().snapshot() - content_store = ContentStore(tmp_path / "content.sqlite3") - content_store.initialize() - adapter = FitbitAdapterStore(tmp_path / "adapter.sqlite3") - adapter.initialize(NOW) + alerts = RecordingAlerts() + context = RecordingContext() monitor = FailOnceMonitor() - runtime = FitbitContentRuntime( - adapter, + store = FitbitAdapterStore(tmp_path / "adapter.sqlite3") + store.initialize(NOW) + runtime = FitbitWakeRuntime( + store, PluginTimers(AsyncioOneShotTimer(clock=lambda: NOW, sleeper=sleeper)), - _bound(content_store), + cast(WakeAlertSource, alerts), + cast(WakeContextSource, context), cast(FitbitMonitorClient, monitor), poll_interval=timedelta(minutes=5), sleep_ttl=timedelta(minutes=10), now=lambda: NOW, ) - root = CompositionRoot("fitbit-transient-retry") - health = await root.context.health("fitbit-content-poll") + root = CompositionRoot("fitbit-retry") + health = await root.context.health("fitbit-wake-poll") await runtime.start(root.context, health) - await third_wait.wait() + await finished.wait() - assert sleeper_calls == 3 - assert monitor.snapshot_calls == 2 + assert monitor.calls == 2 assert health.healthy - assert content_store.state_counts() == {"pending": 1} - assert adapter.next_due() == NOW + timedelta(minutes=5) - assert monitor.acknowledged == [] + assert len(alerts.reports) == 1 assert any( - incident.kind == "fitbit_content_retry" - and "temporary monitor read failure" in incident.message - for incident in root.receipt().incidents + incident.kind == "fitbit_content_retry" for incident in root.receipt().incidents ) await runtime.close() await root.dispose() - - -class _MonitorState: - def __init__(self) -> None: - self.pending = [dict(cast(list[Mapping[str, object]], SNAPSHOT["health_events"])[0])] - - -class _MonitorHandler(BaseHTTPRequestHandler): - state: _MonitorState - - def do_GET(self) -> None: # noqa: N802 - if self.path != "/api/agent": - self.send_error(404) - return - self._json({**SNAPSHOT, "health_events": self.state.pending}) - - def do_POST(self) -> None: # noqa: N802 - prefix = "/api/agent/acknowledge/" - if not self.path.startswith(prefix): - self.send_error(404) - return - event_id = unquote(self.path.removeprefix(prefix)) - before = len(self.state.pending) - self.state.pending = [row for row in self.state.pending if row["id"] != event_id] - self._json({"acknowledged": len(self.state.pending) < before}) - - def log_message(self, format: str, *args: object) -> None: - _ = format, args - - def _json(self, payload: Mapping[str, object]) -> None: - body = json.dumps(payload).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - -@pytest.fixture -def monitor_server() -> Generator[tuple[str, _MonitorState], None, None]: - state = _MonitorState() - handler = type("MonitorHandler", (_MonitorHandler,), {"state": state}) - server = ThreadingHTTPServer(("127.0.0.1", 0), handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - host = str(server.server_address[0]) - port = int(server.server_address[1]) - yield f"http://{host}:{port}", state - finally: - server.shutdown() - server.server_close() - thread.join() - - -def _delivered_content(path: Path) -> None: - store = ContentStore(path) - store.initialize() - items = normalize_health_events(SNAPSHOT) - store.submit("fitbit-health-alerts", stable_batch_id(items), items) - snapshot = store.snapshot(NOW) - snapshot_items = cast(Sequence[Mapping[str, object]], snapshot["items"]) - selected = store.select( - cast(Mapping[str, object], snapshot_items[0]["ref"]), - cast(int, snapshot["snapshot_seq"]), - {"session_id": "wake:fitbit", "turn_id": "turn-1"}, - NOW, - ) - token = cast(str, selected["selection_token"]) - store.transition(token, "ready_for_delivery") - store.transition(token, "delivered", settlement_ref="delivery:fitbit:1") - - -def test_ack_response_sigkill_recovers_by_desired_state( - tmp_path: Path, - monitor_server: tuple[str, _MonitorState], -) -> None: - base_url, state = monitor_server - content_path = tmp_path / "content.sqlite3" - _delivered_content(content_path) - script = """ -import os, signal, sys -from pathlib import Path -from agent.plugin_composition import PluginTimers -from plugins.content import plugin as content_plugin -from plugins.content.store import ContentStore -from src.content_adapter import FitbitContentRuntime, FitbitMonitorClient -from src.sleep_context import FitbitAdapterStore -from datetime import UTC, datetime, timedelta - -content_store = ContentStore(Path(sys.argv[1])) -content_store.initialize() -bound = content_plugin._SourceServices(content_store, lambda: None).bind('fitbit-health-alerts') -adapter = FitbitAdapterStore(Path(sys.argv[2])) -adapter.initialize(datetime.now(UTC)) -runtime = FitbitContentRuntime( - adapter, PluginTimers.candidate_validation(), bound, FitbitMonitorClient(sys.argv[3]), - poll_interval=timedelta(minutes=5), sleep_ttl=timedelta(minutes=10), - after_provider_ack=lambda: os.kill(os.getpid(), signal.SIGKILL), -) -runtime._drain_unsettled() -""" - result = subprocess.run( - [ - sys.executable, - "-c", - script, - str(content_path), - str(tmp_path / "child-adapter.sqlite3"), - base_url, - ], - check=False, - ) - assert result.returncode == -signal.SIGKILL - assert state.pending == [] - assert ContentStore(content_path).state_counts() == {"delivered": 1} - - content_store = ContentStore(content_path) - runtime, _adapter = _runtime( - tmp_path, - _bound(content_store), - FitbitMonitorClient(base_url), - ) - runtime._drain_unsettled() - assert ContentStore(content_path).state_counts() == {"settled": 1} - with sqlite3.connect(content_path) as connection: - row = connection.execute( - "SELECT status, settlement_ref, payload_json FROM items" - ).fetchone() - assert row is not None - assert row[0] == "settled" - assert row[1] == "delivery:fitbit:1" - assert json.loads(row[2])["upstream_event_id"] == "fitbit:event-1" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 11459aa..f025603 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -25,7 +25,7 @@ from agent.plugins import manager as manager_module from agent.plugins.composable import ComposablePlugin from agent.plugins.static_manifest import load_static_plugin_manifest -from plugins.content import plugin as content_plugin +from plugins.wake.contracts import WAKE_ALERT_SOURCE, WAKE_CONTEXT_SOURCE from fitbit_test_plugin import plugin as plugin_module # pyright: ignore[reportMissingImports] from fitbit_test_plugin.plugin import FitbitConfig # pyright: ignore[reportMissingImports] @@ -34,35 +34,27 @@ ROOT = Path(__file__).resolve().parents[1] -CORE_ROOT = Path(content_plugin.__file__).resolve().parents[2] async def _mount_services(root: CompositionRoot, tmp_path: Path) -> None: await root.context.provide(TIMERS, PluginTimers.candidate_validation()) - await root.mount( - ComposablePlugin.from_module(content_plugin), - name="content", - runtime=PluginRuntime( - plugin_id="content", - plugin_dir=CORE_ROOT / "plugins/content", - data_dir=tmp_path / "content-data", - workspace=tmp_path / "workspace", - config=object(), - ), - ) + _ = await root.context.provide(WAKE_ALERT_SOURCE, object()) + _ = await root.context.provide(WAKE_CONTEXT_SOURCE, object()) def test_pure_v3_exports_and_exact_apply() -> None: assert plugin_module.api_version == 3 assert plugin_module.name == "fitbit" - assert plugin_module.version == "3.1.0" + assert plugin_module.version == "3.2.0" assert tuple(inspect.signature(plugin_module.apply).parameters) == ("ctx", "config") - assert ComposablePlugin.from_module(plugin_module).dashboard_module == "dashboard.py" + assert ( + ComposablePlugin.from_module(plugin_module).dashboard_module == "dashboard.py" + ) assert "PROACTIVE_COMPONENTS" not in ROOT.joinpath("plugin.py").read_text() @pytest.mark.asyncio -async def test_apply_registers_content_runtime_tools_and_mobile_ui( +async def test_apply_registers_wake_runtime_tools_and_mobile_ui( tmp_path: Path, ) -> None: root = CompositionRoot("fitbit:test") @@ -80,6 +72,7 @@ async def test_apply_registers_content_runtime_tools_and_mobile_ui( name="fitbit", runtime=PluginRuntime( plugin_id="fitbit", + generation_id="fitbit:test", plugin_dir=ROOT, data_dir=data_dir, workspace=tmp_path / "workspace", @@ -108,7 +101,7 @@ async def test_apply_registers_content_runtime_tools_and_mobile_ui( def test_static_manifest_freezes_runtime_and_candidate_exclusions() -> None: manifest = load_static_plugin_manifest(ROOT) assert manifest.name == "fitbit" - assert manifest.version == "3.1.0" + assert manifest.version == "3.2.0" assert manifest.requirements == ("requirements.txt",) assert len(manifest.managed_processes) == 1 assert manifest.managed_processes[0].formal_port == 18765 @@ -159,13 +152,12 @@ def get_sleep_history(self) -> dict[str, object]: return history monkeypatch.setattr(mobile_reader, "FitbitMobileDashboardReader", Reader) - assert mobile_ui_query( - "fitbit.current", {}, session_id=None, turn_id=None - ) == current - assert mobile_ui_query( - "fitbit.sleep_history", {}, session_id=None, turn_id=None - ) == history + assert ( + mobile_ui_query("fitbit.current", {}, session_id=None, turn_id=None) == current + ) + assert ( + mobile_ui_query("fitbit.sleep_history", {}, session_id=None, turn_id=None) + == history + ) with pytest.raises(ValueError, match="未知 fitbit 移动方法"): - mobile_ui_query( - "fitbit.write", {}, session_id=None, turn_id=None - ) + mobile_ui_query("fitbit.write", {}, session_id=None, turn_id=None) diff --git a/tests/test_sleep_context.py b/tests/test_sleep_context.py index bf99ab0..b54b7ff 100644 --- a/tests/test_sleep_context.py +++ b/tests/test_sleep_context.py @@ -3,28 +3,12 @@ from datetime import UTC, datetime, timedelta from pathlib import Path -import pytest -from agent.lifecycle.types import BeforeTurnCtx - -from src.sleep_context import FitbitAdapterStore, SleepContextAppender +from src.sleep_context import FitbitAdapterStore NOW = datetime(2026, 8, 23, 8, tzinfo=UTC) -def _ctx(channel: str, now: datetime) -> BeforeTurnCtx: - return BeforeTurnCtx( - session_key=f"{channel}:fitbit", - channel=channel, - chat_id="fitbit", - content="continuation", - timestamp=now, - retrieved_memory_block="", - retrieval_trace_raw=None, - history_messages=(), - ) - - def _store(path: Path) -> FitbitAdapterStore: store = FitbitAdapterStore(path) store.initialize(NOW) @@ -60,28 +44,6 @@ def test_sleep_cache_overwrites_one_current_projection(tmp_path: Path) -> None: assert current is not None and current["state"] == "awake" -@pytest.mark.asyncio -async def test_wake_duty_and_fresh_sleep_hint_coexist(tmp_path: Path) -> None: - ctx = _ctx("wake", NOW + timedelta(minutes=2)) - ctx.extra_hints.append("Content duty: Fitbit health alert") - - await SleepContextAppender(_store(tmp_path / "adapter.sqlite3")).prepare(ctx) - - assert ctx.extra_hints[0] == "Content duty: Fitbit health alert" - assert "state=sleeping" in ctx.extra_hints[1] - assert ctx.abort is False - assert ctx.content == "continuation" - - -@pytest.mark.asyncio -async def test_passive_turn_has_no_sleep_hint(tmp_path: Path) -> None: - ctx = _ctx("mobile", NOW + timedelta(minutes=2)) - await SleepContextAppender(_store(tmp_path / "adapter.sqlite3")).prepare(ctx) - assert ctx.extra_hints == [] - - -@pytest.mark.asyncio -async def test_stale_sleep_cache_has_no_wake_hint(tmp_path: Path) -> None: - ctx = _ctx("wake", NOW + timedelta(minutes=11)) - await SleepContextAppender(_store(tmp_path / "adapter.sqlite3")).prepare(ctx) - assert ctx.extra_hints == [] +def test_expired_sleep_projection_is_not_returned(tmp_path: Path) -> None: + store = _store(tmp_path / "adapter.sqlite3") + assert store.current_sleep(NOW + timedelta(minutes=10)) is None From 94116473bf335acf12894623031ff0781e158850 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Fri, 28 Aug 2026 13:16:33 +0800 Subject: [PATCH 2/2] feat(eventmail): publish Fitbit alerts and context --- .github/workflows/plugin-api-v3.yml | 2 +- akashic.plugin.toml | 2 +- plugin.py | 68 +++++++++++++++-------------- src/content_adapter.py | 20 +++++---- src/eventmail.py | 47 ++++++++++++++++++++ tests/test_content_adapter.py | 21 ++++----- tests/test_manager_integration.py | 7 ++- tests/test_plugin.py | 47 +++++++++++++++++--- 8 files changed, 150 insertions(+), 64 deletions(-) create mode 100644 src/eventmail.py diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index e535709..4b7e121 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 9da3a988a2bf62b0f550bd4f6bb98c4eeb1f56f5 + ref: 39cbdcefc155aaf6c41deafd7754a37e6126c23c path: .akashic-core - uses: actions/setup-python@v5 with: diff --git a/akashic.plugin.toml b/akashic.plugin.toml index ac87fde..cee9485 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -1,6 +1,6 @@ schema_version = 1 name = "fitbit" -version = "3.2.0" +version = "3.2.1" api_version = 3 entrypoint = "plugin.py" diff --git a/plugin.py b/plugin.py index 00718c3..6f37ec3 100644 --- a/plugin.py +++ b/plugin.py @@ -17,14 +17,11 @@ MobileUiDefinition, MobileUiNavigation, ) -from plugins.wake.contracts import ( - WAKE_ALERT_SOURCE, - WAKE_CONTEXT_SOURCE, -) from .src.content_adapter import ( FitbitWakeRuntime, FitbitMonitorClient, ) +from .src.eventmail import EVENTMAIL_ALERT_SOURCE, EVENTMAIL_CONTEXT_SOURCE from .src.mobile_reader import mobile_ui_query from .src.sleep_context import FitbitAdapterStore @@ -44,15 +41,13 @@ class FitbitConfig(BaseModel): api_version = 3 name = "fitbit" -version = "3.2.0" +version = "3.2.1" desc = "Fitbit health Alert and sleep Context source" Config = FitbitConfig inject = ( MANAGED_PROCESSES, MCP_SERVERS, TIMERS, - WAKE_ALERT_SOURCE, - WAKE_CONTEXT_SOURCE, UI_SLOTS, ) dashboard_module = "dashboard.py" @@ -89,34 +84,41 @@ async def apply(ctx: Context, config: FitbitConfig) -> None: ), ) - # 2. 绑定唯一正式来源;candidate Root 不会收到 STARTED - store = FitbitAdapterStore(ctx.data_root / "adapter.sqlite3") - store.initialize(datetime.now(UTC)) - runtime = FitbitWakeRuntime( - store, - ctx.require(TIMERS), - ctx.require(WAKE_ALERT_SOURCE), - ctx.require(WAKE_CONTEXT_SOURCE), - FitbitMonitorClient(), - poll_interval=timedelta(seconds=config.content.poll_interval_seconds), - sleep_ttl=timedelta(seconds=config.content.sleep_ttl_seconds), + # 2. EventMail 存在时,独立子 Fiber 才启动健康来源。 + async def apply_eventmail(source_ctx: Context) -> None: + store = FitbitAdapterStore(source_ctx.data_root / "adapter.sqlite3") + store.initialize(datetime.now(UTC)) + runtime = FitbitWakeRuntime( + store, + source_ctx.require(TIMERS), + source_ctx.require(EVENTMAIL_ALERT_SOURCE).bind("fitbit-health-alerts"), + source_ctx.require(EVENTMAIL_CONTEXT_SOURCE).bind("fitbit-sleep"), + FitbitMonitorClient(), + poll_interval=timedelta(seconds=config.content.poll_interval_seconds), + sleep_ttl=timedelta(seconds=config.content.sleep_ttl_seconds), + ) + + def setup() -> object: + return runtime.close + + _ = await source_ctx.effect(setup, label="fitbit-eventmail-runtime") + poll_health = await source_ctx.health("fitbit-eventmail-poll") + + async def start(_event: object) -> None: + await runtime.start(source_ctx, poll_health) + + async def stop(_event: object) -> None: + await runtime.close() + + _ = await source_ctx.on(RUNTIME_STARTED, start) + _ = await source_ctx.on(RUNTIME_STOPPING, stop) + + _ = await ctx.inject( + (TIMERS, EVENTMAIL_ALERT_SOURCE, EVENTMAIL_CONTEXT_SOURCE), + apply_eventmail, + name="fitbit-eventmail-source", ) - def setup() -> object: - return runtime.close - - _ = await ctx.effect(setup, label="fitbit-wake-runtime") - poll_health = await ctx.health("fitbit-wake-poll") - - async def start(_event: object) -> None: - await runtime.start(ctx, poll_health) - - async def stop(_event: object) -> None: - await runtime.close() - - _ = await ctx.on(RUNTIME_STARTED, start) - _ = await ctx.on(RUNTIME_STOPPING, stop) - # 3. 在同一个 exact Root 上保留现有移动投影 await ctx.require(UI_SLOTS).register_mobile( ctx, diff --git a/src/content_adapter.py b/src/content_adapter.py index c86c01f..54a62a2 100644 --- a/src/content_adapter.py +++ b/src/content_adapter.py @@ -10,9 +10,14 @@ import requests -from agent.control.timer import TimerHandle, TimerStatus -from agent.plugin_composition import Context, HealthHandle, PluginTimers -from plugins.wake.contracts import WakeAlertSource, WakeContextSource +from agent.plugin_composition import ( + Context, + HealthHandle, + PluginTimers, + TimerHandle, + TimerStatus, +) +from .eventmail import BoundAlertSource, BoundContextSource from .sleep_context import FitbitAdapterStore @@ -61,8 +66,8 @@ def __init__( self, store: FitbitAdapterStore, timers: PluginTimers, - alerts: WakeAlertSource, - context: WakeContextSource, + alerts: BoundAlertSource, + context: BoundContextSource, monitor: FitbitMonitorClient, *, poll_interval: timedelta, @@ -143,16 +148,14 @@ def tick(self) -> None: for item in items: event_id = str(item["item_id"]) status = self._alerts.status( - source_id="fitbit-health-alerts", event_id=event_id, ) - if status in {"delivered", "skipped"}: + if status in {"delivered", "skipped", "expired"}: self._ensure_not_pending(event_id) if self._after_provider_ack is not None: self._after_provider_ack() continue _ = self._alerts.report( - source_id="fitbit-health-alerts", event_id=event_id, payload=_mapping(item, "payload"), observed_at=now, @@ -162,7 +165,6 @@ def tick(self) -> None: sleep = normalize_sleep(snapshot) expires_at = now + self._sleep_ttl _ = self._context.report( - source_id="fitbit-sleep", event_id="current", payload=sleep, observed_at=now, diff --git a/src/eventmail.py b/src/eventmail.py new file mode 100644 index 0000000..c92c731 --- /dev/null +++ b/src/eventmail.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime +from typing import Protocol + +from agent.plugin_composition import ServiceKey + + +class BoundAlertSource(Protocol): + def report( + self, + *, + event_id: str, + payload: Mapping[str, object], + observed_at: datetime, + expires_at: datetime | None = None, + ) -> Mapping[str, object]: ... + + def status(self, *, event_id: str) -> str | None: ... + + +class AlertSourceServices(Protocol): + def bind(self, source_id: str) -> BoundAlertSource: ... + + +class BoundContextSource(Protocol): + def report( + self, + *, + event_id: str, + payload: Mapping[str, object], + observed_at: datetime, + expires_at: datetime | None = None, + ) -> Mapping[str, object]: ... + + +class ContextSourceServices(Protocol): + def bind(self, source_id: str) -> BoundContextSource: ... + + +EVENTMAIL_ALERT_SOURCE = ServiceKey[AlertSourceServices]( + "eventmail.alert_source.v1" +) +EVENTMAIL_CONTEXT_SOURCE = ServiceKey[ContextSourceServices]( + "eventmail.context_source.v1" +) diff --git a/tests/test_content_adapter.py b/tests/test_content_adapter.py index e1ebbb8..63367e1 100644 --- a/tests/test_content_adapter.py +++ b/tests/test_content_adapter.py @@ -10,7 +10,10 @@ from agent.control.timer import AsyncioOneShotTimer from agent.plugin_composition import CompositionRoot, PluginTimers -from plugins.wake.contracts import WakeAlertSource, WakeContextSource +from fitbit_test_plugin.src.eventmail import ( # pyright: ignore[reportMissingImports] + BoundAlertSource, + BoundContextSource, +) from src.content_adapter import ( FitbitMonitorClient, FitbitWakeRuntime, @@ -63,8 +66,7 @@ def report(self, **kwargs: object) -> Mapping[str, object]: self.reports.append(dict(kwargs)) return {"accepted": True} - def status(self, *, source_id: str, event_id: str) -> str | None: - assert source_id == "fitbit-health-alerts" + def status(self, *, event_id: str) -> str | None: return self.statuses.get(event_id) @@ -89,8 +91,8 @@ def _runtime( FitbitWakeRuntime( store, PluginTimers.candidate_validation(), - cast(WakeAlertSource, alerts), - cast(WakeContextSource, context), + cast(BoundAlertSource, alerts), + cast(BoundContextSource, context), cast(FitbitMonitorClient, monitor), poll_interval=timedelta(minutes=5), sleep_ttl=timedelta(minutes=10), @@ -112,8 +114,7 @@ def test_health_reports_alert_and_sleep_reports_expiring_context( assert alerts.reports[0]["event_id"] == "fitbit:event-1" assert context.reports == [ { - "source_id": "fitbit-sleep", - "event_id": "current", + "event_id": "current", "payload": store.current_sleep(NOW), "observed_at": NOW, "expires_at": NOW + timedelta(minutes=10), @@ -122,7 +123,7 @@ def test_health_reports_alert_and_sleep_reports_expiring_context( assert store.next_due() == NOW + timedelta(minutes=5) -@pytest.mark.parametrize("status", ["delivered", "skipped"]) +@pytest.mark.parametrize("status", ["delivered", "skipped", "expired"]) def test_terminal_alert_is_acknowledged_instead_of_reported( tmp_path: Path, status: str ) -> None: @@ -212,8 +213,8 @@ def snapshot(self) -> Mapping[str, object]: runtime = FitbitWakeRuntime( store, PluginTimers(AsyncioOneShotTimer(clock=lambda: NOW, sleeper=sleeper)), - cast(WakeAlertSource, alerts), - cast(WakeContextSource, context), + cast(BoundAlertSource, alerts), + cast(BoundContextSource, context), cast(FitbitMonitorClient, monitor), poll_interval=timedelta(minutes=5), sleep_ttl=timedelta(minutes=10), diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py index e9ab705..c5fe616 100644 --- a/tests/test_manager_integration.py +++ b/tests/test_manager_integration.py @@ -6,12 +6,11 @@ from pathlib import Path import pytest -from agent.plugin_composition import TIMERS from agent.plugins.generation import PluginGeneration from agent.plugins.manager import PluginManager from agent.plugins.snapshot import RuntimeSnapshot from bus.event_bus import EventBus -from plugins.content import plugin as content_plugin +from plugins.eventmail import plugin as content_plugin ROOT = Path(__file__).resolve().parents[1] @@ -131,7 +130,7 @@ async def test_manager_rebuilds_fitbit_runtime_on_exact_formal_root( for relative in ("plugin.py", "akashic.plugin.toml"): path = plugin_root / relative path.write_text( - path.read_text(encoding="utf-8").replace("3.1.0", "3.1.1"), + path.read_text(encoding="utf-8").replace("3.2.1", "3.2.2"), encoding="utf-8", ) candidate = await manager.prepare_candidate("fitbit") @@ -140,7 +139,7 @@ async def test_manager_rebuilds_fitbit_runtime_on_exact_formal_root( validation_root = candidate.validation_workspace.parent candidate_snapshot = candidate.runtime_snapshot assert candidate_snapshot.composition_root is not None - assert candidate_snapshot.composition_root.context.require(TIMERS).formal is False + assert candidate_snapshot.composition_root.receipt().optional_pending == () assert candidate.validation_workspace != tmp_path / "workspace" assert _tree_digest(formal_data) == formal_digest original_invariants = manager._post_publish_invariants # pyright: ignore[reportPrivateUsage] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f025603..7b93f5e 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -25,10 +25,12 @@ from agent.plugins import manager as manager_module from agent.plugins.composable import ComposablePlugin from agent.plugins.static_manifest import load_static_plugin_manifest -from plugins.wake.contracts import WAKE_ALERT_SOURCE, WAKE_CONTEXT_SOURCE - from fitbit_test_plugin import plugin as plugin_module # pyright: ignore[reportMissingImports] from fitbit_test_plugin.plugin import FitbitConfig # pyright: ignore[reportMissingImports] +from fitbit_test_plugin.src.eventmail import ( # pyright: ignore[reportMissingImports] + EVENTMAIL_ALERT_SOURCE, + EVENTMAIL_CONTEXT_SOURCE, +) from src import mobile_reader from src.mobile_reader import mobile_ui_query @@ -37,15 +39,19 @@ async def _mount_services(root: CompositionRoot, tmp_path: Path) -> None: + class Sources: + def bind(self, source_id: str) -> object: + return object() + await root.context.provide(TIMERS, PluginTimers.candidate_validation()) - _ = await root.context.provide(WAKE_ALERT_SOURCE, object()) - _ = await root.context.provide(WAKE_CONTEXT_SOURCE, object()) + _ = await root.context.provide(EVENTMAIL_ALERT_SOURCE, Sources()) + _ = await root.context.provide(EVENTMAIL_CONTEXT_SOURCE, Sources()) def test_pure_v3_exports_and_exact_apply() -> None: assert plugin_module.api_version == 3 assert plugin_module.name == "fitbit" - assert plugin_module.version == "3.2.0" + assert plugin_module.version == "3.2.1" assert tuple(inspect.signature(plugin_module.apply).parameters) == ("ctx", "config") assert ( ComposablePlugin.from_module(plugin_module).dashboard_module == "dashboard.py" @@ -98,10 +104,39 @@ async def test_apply_registers_wake_runtime_tools_and_mobile_ui( await root.dispose() +@pytest.mark.asyncio +async def test_apply_keeps_tools_and_mobile_ui_without_eventmail(tmp_path: Path) -> None: + root = CompositionRoot("fitbit:without-eventmail") + processes = PluginManagedProcesses(root.instance_token) + servers = PluginMcpServers(root.instance_token) + ui_slots = PluginUiSlots() + await root.context.provide(MANAGED_PROCESSES, processes) + await root.context.provide(MCP_SERVERS, servers) + await root.context.provide(TIMERS, PluginTimers.candidate_validation()) + await root.context.provide(UI_SLOTS, ui_slots) + await root.mount( + ComposablePlugin.from_module(plugin_module), + name="fitbit", + runtime=PluginRuntime( + plugin_id="fitbit", + generation_id="fitbit:without-eventmail", + plugin_dir=ROOT, + data_dir=tmp_path / "plugin-data", + workspace=tmp_path / "workspace", + config=FitbitConfig(), + ), + ) + + assert "fitbit" in _freeze_plugin_mcp_servers(servers, root.instance_token) + assert "fitbit" in ui_slots.freeze() + assert not (tmp_path / "plugin-data/adapter.sqlite3").exists() + await root.dispose() + + def test_static_manifest_freezes_runtime_and_candidate_exclusions() -> None: manifest = load_static_plugin_manifest(ROOT) assert manifest.name == "fitbit" - assert manifest.version == "3.2.0" + assert manifest.version == "3.2.1" assert manifest.requirements == ("requirements.txt",) assert len(manifest.managed_processes) == 1 assert manifest.managed_processes[0].formal_port == 18765