From ffb85ac02f31b787abb640226c70c6818f5f4dcb Mon Sep 17 00:00:00 2001 From: test Date: Fri, 28 Aug 2026 04:43:09 +0800 Subject: [PATCH 1/2] report Steam context to Wake --- akashic.plugin.toml | 2 +- context_source.py | 36 ++++++++++++----------- plugin.py | 14 ++++----- tests/test_context_source.py | 55 ++++++++++++++---------------------- tests/test_plugin.py | 8 ++++-- 5 files changed, 54 insertions(+), 61 deletions(-) diff --git a/akashic.plugin.toml b/akashic.plugin.toml index 42344dd..2a0ce83 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -1,6 +1,6 @@ schema_version = 1 name = "steam" -version = "3.1.0" +version = "3.2.0" api_version = 3 entrypoint = "plugin.py" diff --git a/context_source.py b/context_source.py index 15d72a4..660c2d6 100644 --- a/context_source.py +++ b/context_source.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import json import logging from collections.abc import Callable from datetime import UTC, datetime @@ -9,14 +8,14 @@ from pathlib import Path from agent.control.timer import TimerHandle, TimerStatus -from agent.lifecycle.types import BeforeTurnCtx from agent.plugin_composition import HealthHandle, PluginTimers +from plugins.wake.contracts import WakeContextSource from .steam_runtime import backend class SteamContextRuntime: - """用 Timer 刷新 Steam current state,并只为 Wake 追加 fresh hint。""" + """用 Timer 刷新 Steam current state,并上报可过期 Context。""" def __init__( self, @@ -24,6 +23,7 @@ def __init__( timers: PluginTimers, health: HealthHandle, report_incident: Callable[[str, str], object], + context: WakeContextSource, *, now: Callable[[], datetime] | None = None, ) -> None: @@ -31,6 +31,7 @@ def __init__( self._timers = timers self._health = health self._report_incident = report_incident + self._context = context self._now = now or (lambda: datetime.now(UTC)) self._handle: TimerHandle | None = None self._task: asyncio.Task[None] | None = None @@ -50,6 +51,7 @@ async def start(self) -> None: now = self._aware_now() await asyncio.to_thread(backend.initialize, self._data_root, now) deadline = await asyncio.to_thread(backend.next_deadline, self._data_root, now) + await asyncio.to_thread(self._report_current, now) self._arm(deadline) async def close(self) -> None: @@ -70,19 +72,6 @@ async def close(self) -> None: await handle.cleanup() self._stop_diagnostics() - def prepare(self, ctx: BeforeTurnCtx) -> None: - """只在 Wake channel 读取 fresh state 并追加一个普通 hint。""" - - if ctx.channel != "wake": - return - current = backend.wake_context(self._data_root, ctx.timestamp) - if current is None: - return - ctx.extra_hints.append( - "Steam current context:\n" - + json.dumps(current, sort_keys=True, separators=(",", ":")) - ) - def _arm(self, deadline: datetime) -> None: if self._closed or self._handle is not None: return @@ -131,6 +120,7 @@ async def _wait_refresh_rearm(self, handle: TimerHandle) -> None: else: self._health.recover() next_due = result.next_due + await asyncio.to_thread(self._report_current, now) self._log.info( "refresh committed presence=%s history_appended=%s next_due=%s", result.presence, @@ -144,6 +134,20 @@ async def _wait_refresh_rearm(self, handle: TimerHandle) -> None: if not self._closed and next_due is not None: self._arm(next_due) + def _report_current(self, now: datetime) -> None: + current = backend.wake_context(self._data_root, now) + if current is None: + return + observed_at = datetime.fromisoformat(str(current["observed_at"])) + expires_at = datetime.fromisoformat(str(current["expires_at"])) + _ = self._context.report( + source_id="steam-presence", + event_id="current", + payload=current, + observed_at=observed_at, + expires_at=expires_at, + ) + def _aware_now(self) -> datetime: value = self._now() if value.tzinfo is None: diff --git a/plugin.py b/plugin.py index 3f4a1a0..034ea70 100644 --- a/plugin.py +++ b/plugin.py @@ -2,7 +2,6 @@ from pydantic import BaseModel -from agent.lifecycle.composition import CONTEXT_PREPARED_EVENT from agent.plugin_composition import ( MCP_SERVERS, RUNTIME_STARTED, @@ -11,6 +10,7 @@ Context, McpServerDefinition, ) +from plugins.wake.contracts import WAKE_CONTEXT_SOURCE from .context_source import SteamContextRuntime @@ -21,15 +21,15 @@ class SteamConfig(BaseModel): api_version = 3 name = "steam" -version = "3.1.0" -desc = "Timer 刷新的 Steam current context 与用户 MCP" +version = "3.2.0" +desc = "Timer 上报的 Steam current Context 与用户 MCP" Config = SteamConfig -inject = (MCP_SERVERS, TIMERS) +inject = (MCP_SERVERS, TIMERS, WAKE_CONTEXT_SOURCE) skill_roots = ("skills",) async def apply(ctx: Context, config: object) -> None: - """组合用户 MCP、Timer current state 和 Wake context listener。""" + """组合用户 MCP、Timer current state 和 Wake Context 上报。""" if not isinstance(config, SteamConfig): raise TypeError("steam config 必须是 SteamConfig") @@ -46,19 +46,19 @@ async def apply(ctx: Context, config: object) -> None: ), ) - # 2. 正式 Root 独占 Timer 刷新;listener 只读 current state。 + # 2. 正式 Root 独占 Timer 刷新并上报 current state。 health = await ctx.health("context-refresh", required=True) runtime = SteamContextRuntime( ctx.data_root, ctx.require(TIMERS), health, ctx.report_incident, + ctx.require(WAKE_CONTEXT_SOURCE), ) def setup() -> object: return runtime.close _ = await ctx.effect(setup, label="steam-context-runtime") - _ = await ctx.on(CONTEXT_PREPARED_EVENT, runtime.prepare) _ = await ctx.on(RUNTIME_STARTED, lambda _: runtime.start()) _ = await ctx.on(RUNTIME_STOPPING, lambda _: runtime.close()) diff --git a/tests/test_context_source.py b/tests/test_context_source.py index 91e9d4d..035fc18 100644 --- a/tests/test_context_source.py +++ b/tests/test_context_source.py @@ -1,15 +1,14 @@ from __future__ import annotations import asyncio -import hashlib import json +from collections.abc import Mapping from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from agent.control.timer import TimerReceipt, TimerStatus -from agent.lifecycle.types import BeforeTurnCtx from agent.plugin_composition import PluginTimers from steam_test_plugin.context_source import SteamContextRuntime # pyright: ignore[reportMissingImports] from steam_test_plugin.steam_runtime import backend # pyright: ignore[reportMissingImports] @@ -86,6 +85,15 @@ def _config(data_root: Path) -> None: ) +class _Context: + 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} + + async def _eventually(predicate) -> None: for _ in range(200): if predicate(): @@ -94,20 +102,6 @@ async def _eventually(predicate) -> None: raise AssertionError("condition did not settle") -def _ctx(now: datetime, channel: str) -> BeforeTurnCtx: - return BeforeTurnCtx( - session_key="session", - channel=channel, - chat_id="chat", - content="hello", - timestamp=now, - retrieved_memory_block="", - retrieval_trace_raw=None, - history_messages=(), - turn_id="turn:1", - ) - - @pytest.mark.asyncio async def test_network_incident_retries_and_recovers( tmp_path: Path, @@ -133,6 +127,7 @@ def refresh(_data_root: Path, attempt: datetime) -> backend.RefreshResult: PluginTimers(timer), health, # type: ignore[arg-type] lambda kind, message: incidents.append((kind, message)), + _Context(), # type: ignore[arg-type] now=lambda: now, ) await runtime.start() @@ -153,7 +148,7 @@ def refresh(_data_root: Path, attempt: datetime) -> backend.RefreshResult: await runtime.close() -def test_context_listener_is_wake_only_fresh_only_and_read_only( +def test_current_presence_is_reported_as_expiring_context( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -166,29 +161,22 @@ def test_context_listener_is_wake_only_fresh_only_and_read_only( ) monkeypatch.setattr(backend, "_fetch_recently_played", lambda _: []) _ = backend.refresh(tmp_path, now) - database = tmp_path / "steam_proactive.sqlite3" - before = hashlib.sha256(database.read_bytes()).hexdigest() + context = _Context() runtime = SteamContextRuntime( tmp_path, PluginTimers(None), _Health(), # type: ignore[arg-type] lambda _kind, _message: None, + context, # type: ignore[arg-type] now=lambda: now, ) - passive = _ctx(now, "passive") - runtime.prepare(passive) - wake = _ctx(now + timedelta(minutes=1), "wake") - runtime.prepare(wake) - stale = _ctx(now + timedelta(minutes=6), "wake") - runtime.prepare(stale) + runtime._report_current(now) # pyright: ignore[reportPrivateUsage] - assert passive.extra_hints == [] - assert len(wake.extra_hints) == 1 - assert wake.extra_hints[0].startswith("Steam current context:\n") - assert wake.abort is False - assert stale.extra_hints == [] - assert hashlib.sha256(database.read_bytes()).hexdigest() == before + assert len(context.reports) == 1 + assert context.reports[0]["source_id"] == "steam-presence" + assert context.reports[0]["event_id"] == "current" + assert context.reports[0]["expires_at"] == now + timedelta(minutes=5) @pytest.mark.asyncio @@ -206,6 +194,7 @@ async def test_contract_failure_degrades_and_does_not_retry( PluginTimers(timer), health, # type: ignore[arg-type] lambda kind, message: incidents.append((kind, message)), + _Context(), # type: ignore[arg-type] now=lambda: now, ) monkeypatch.setattr( @@ -223,7 +212,5 @@ async def test_contract_failure_degrades_and_does_not_retry( assert len(timer.handles) == 1 assert health.reason == "RuntimeError: schema mismatch" - assert incidents == [ - ("steam_refresh_contract", "RuntimeError: schema mismatch") - ] + assert incidents == [("steam_refresh_contract", "RuntimeError: schema mismatch")] await runtime.close() diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 2f0af97..7724ee6 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -21,6 +21,7 @@ from agent.plugins.composable import ComposablePlugin from agent.plugins.manager import _copy_validation_data from agent.plugins.static_manifest import load_static_plugin_manifest +from plugins.wake.contracts import WAKE_CONTEXT_SOURCE ROOT = Path(__file__).resolve().parents[1] @@ -29,7 +30,7 @@ def test_pure_v3_exports_and_exact_apply() -> None: assert plugin.api_version == 3 assert plugin.name == "steam" - assert plugin.version == "3.1.0" + assert plugin.version == "3.2.0" assert plugin.skill_roots == ("skills",) assert tuple(inspect.signature(plugin.apply).parameters) == ("ctx", "config") assert ComposablePlugin.from_module(plugin).skill_roots == ("skills",) @@ -46,12 +47,14 @@ async def test_apply_registers_user_mcp_and_dormant_context_runtime( TIMERS, PluginTimers(cast(OneShotTimer, object())), ) + _ = await root.context.provide(WAKE_CONTEXT_SOURCE, object()) data_root = tmp_path / "plugin-data" await root.mount( ComposablePlugin.from_module(plugin), name="steam", runtime=PluginRuntime( plugin_id="steam", + generation_id="steam:test", plugin_dir=ROOT, data_dir=data_root, workspace=tmp_path / "workspace", @@ -68,7 +71,6 @@ async def test_apply_registers_user_mcp_and_dormant_context_runtime( assert server.candidate_env == {"STEAM_BACKEND": "recording"} assert not data_root.exists() assert root.topology_view().listeners == ( - "serial:turn.context_prepared:steam", "serial:runtime.started:steam", "serial:runtime.stopping:steam", ) @@ -79,7 +81,7 @@ def test_static_manifest_excludes_state_and_bounded_logs() -> None: manifest = load_static_plugin_manifest(ROOT) assert manifest.name == plugin.name == "steam" - assert manifest.version == plugin.version == "3.1.0" + assert manifest.version == plugin.version == "3.2.0" assert manifest.api_version == plugin.api_version == 3 assert manifest.requirements == ("mcp/requirements.txt",) assert "steam_proactive.sqlite3" in manifest.exclude_data_paths From a0fda0602185a0a49aefc9fc0a381451c58d26e5 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 28 Aug 2026 13:16:33 +0800 Subject: [PATCH 2/2] feat(eventmail): publish Steam context through plugin services --- .github/workflows/plugin-api-v3.yml | 2 +- akashic.plugin.toml | 2 +- context_source.py | 15 ++++++---- eventmail.py | 27 ++++++++++++++++++ plugin.py | 42 +++++++++++++++------------ tests/test_context_source.py | 1 - tests/test_manager_integration.py | 44 +++++++---------------------- tests/test_plugin.py | 40 ++++++++++++++++++++++---- 8 files changed, 106 insertions(+), 67 deletions(-) create mode 100644 eventmail.py diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 5962e90..14fa8c0 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 2a0ce83..549f961 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -1,6 +1,6 @@ schema_version = 1 name = "steam" -version = "3.2.0" +version = "3.2.1" api_version = 3 entrypoint = "plugin.py" diff --git a/context_source.py b/context_source.py index 660c2d6..3075d3e 100644 --- a/context_source.py +++ b/context_source.py @@ -7,10 +7,14 @@ from logging.handlers import RotatingFileHandler from pathlib import Path -from agent.control.timer import TimerHandle, TimerStatus -from agent.plugin_composition import HealthHandle, PluginTimers -from plugins.wake.contracts import WakeContextSource - +from agent.plugin_composition import ( + HealthHandle, + PluginTimers, + TimerHandle, + TimerStatus, +) + +from .eventmail import BoundContextSource from .steam_runtime import backend @@ -23,7 +27,7 @@ def __init__( timers: PluginTimers, health: HealthHandle, report_incident: Callable[[str, str], object], - context: WakeContextSource, + context: BoundContextSource, *, now: Callable[[], datetime] | None = None, ) -> None: @@ -141,7 +145,6 @@ def _report_current(self, now: datetime) -> None: observed_at = datetime.fromisoformat(str(current["observed_at"])) expires_at = datetime.fromisoformat(str(current["expires_at"])) _ = self._context.report( - source_id="steam-presence", event_id="current", payload=current, observed_at=observed_at, diff --git a/eventmail.py b/eventmail.py new file mode 100644 index 0000000..c78aa00 --- /dev/null +++ b/eventmail.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime +from typing import Protocol + +from agent.plugin_composition import ServiceKey + + +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_CONTEXT_SOURCE = ServiceKey[ContextSourceServices]( + "eventmail.context_source.v1" +) diff --git a/plugin.py b/plugin.py index 034ea70..3e8ffa8 100644 --- a/plugin.py +++ b/plugin.py @@ -10,9 +10,8 @@ Context, McpServerDefinition, ) -from plugins.wake.contracts import WAKE_CONTEXT_SOURCE - from .context_source import SteamContextRuntime +from .eventmail import EVENTMAIL_CONTEXT_SOURCE class SteamConfig(BaseModel): @@ -21,10 +20,10 @@ class SteamConfig(BaseModel): api_version = 3 name = "steam" -version = "3.2.0" +version = "3.2.1" desc = "Timer 上报的 Steam current Context 与用户 MCP" Config = SteamConfig -inject = (MCP_SERVERS, TIMERS, WAKE_CONTEXT_SOURCE) +inject = (MCP_SERVERS, TIMERS) skill_roots = ("skills",) @@ -46,19 +45,26 @@ async def apply(ctx: Context, config: object) -> None: ), ) - # 2. 正式 Root 独占 Timer 刷新并上报 current state。 - health = await ctx.health("context-refresh", required=True) - runtime = SteamContextRuntime( - ctx.data_root, - ctx.require(TIMERS), - health, - ctx.report_incident, - ctx.require(WAKE_CONTEXT_SOURCE), - ) + # 2. EventMail 存在时,独立子 Fiber 才刷新 current state。 + async def apply_eventmail(source_ctx: Context) -> None: + health = await source_ctx.health("context-refresh", required=True) + runtime = SteamContextRuntime( + source_ctx.data_root, + source_ctx.require(TIMERS), + health, + source_ctx.report_incident, + source_ctx.require(EVENTMAIL_CONTEXT_SOURCE).bind("steam-presence"), + ) - def setup() -> object: - return runtime.close + def setup() -> object: + return runtime.close - _ = await ctx.effect(setup, label="steam-context-runtime") - _ = await ctx.on(RUNTIME_STARTED, lambda _: runtime.start()) - _ = await ctx.on(RUNTIME_STOPPING, lambda _: runtime.close()) + _ = await source_ctx.effect(setup, label="steam-context-runtime") + _ = await source_ctx.on(RUNTIME_STARTED, lambda _: runtime.start()) + _ = await source_ctx.on(RUNTIME_STOPPING, lambda _: runtime.close()) + + _ = await ctx.inject( + (TIMERS, EVENTMAIL_CONTEXT_SOURCE), + apply_eventmail, + name="steam-eventmail-source", + ) diff --git a/tests/test_context_source.py b/tests/test_context_source.py index 035fc18..ad699a3 100644 --- a/tests/test_context_source.py +++ b/tests/test_context_source.py @@ -174,7 +174,6 @@ def test_current_presence_is_reported_as_expiring_context( runtime._report_current(now) # pyright: ignore[reportPrivateUsage] assert len(context.reports) == 1 - assert context.reports[0]["source_id"] == "steam-presence" assert context.reports[0]["event_id"] == "current" assert context.reports[0]["expires_at"] == now + timedelta(minutes=5) diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py index ba8486f..06a1b97 100644 --- a/tests/test_manager_integration.py +++ b/tests/test_manager_integration.py @@ -12,8 +12,6 @@ import pytest import agent.plugins.manager as plugin_manager_module from agent.control.timer import TimerReceipt, TimerStatus -from agent.lifecycle.composition import CONTEXT_PREPARED_EVENT -from agent.lifecycle.types import BeforeTurnCtx from agent.plugins.manager import PluginManager from bus.event_bus import EventBus @@ -21,6 +19,7 @@ ROOT = Path(__file__).resolve().parents[1] +CORE_ROOT = Path(os.environ["AKASHIC_AGENT_ROOT"]) class _TimerHandle: @@ -74,6 +73,10 @@ def _stage_plugin(tmp_path: Path) -> Path: """复制真实 Steam 插件并链接调用方声明的 artifact 运行时。""" runtime = Path(os.environ["AKASHIC_PLUGIN_FIXTURE_PYTHON"]).parent.parent + shutil.copytree( + CORE_ROOT / "plugins" / "eventmail", + tmp_path / "plugins" / "eventmail", + ) source = tmp_path / "plugins" / "steam" shutil.copytree( ROOT, @@ -165,20 +168,6 @@ def _seed_fresh_state(data_root: Path, now: datetime) -> None: connection.commit() -def _ctx(now: datetime, channel: str) -> BeforeTurnCtx: - return BeforeTurnCtx( - session_key="session", - channel=channel, - chat_id="chat", - content="hello", - timestamp=now, - retrieved_memory_block="", - retrieval_trace_raw=None, - history_messages=(), - turn_id="turn:1", - ) - - @pytest.mark.asyncio async def test_manager_candidate_context_and_timer_handoff( tmp_path: Path, @@ -217,21 +206,13 @@ def timer_factory() -> _Timer: assert "get_player_summaries" in runtime.mcp.server("steam").tool_names lifecycle = asyncio.create_task(manager.run_runtime_services()) try: - # 1. 稳定 Root 只注册一个 Timer;listener 不影响 passive。 + # 1. 稳定 Root 只注册一个 Timer;Context 只写 EventMail。 await _eventually(lambda: sum(len(timer.handles) for timer in timers) == 1) formal_timer = next(timer for timer in timers if timer.handles) - passive = _ctx(now, "passive") - wake = _ctx(now, "wake") - _ = await snapshot.composition_root.context.serial( - CONTEXT_PREPARED_EVENT, - passive, - ) - _ = await snapshot.composition_root.context.serial( - CONTEXT_PREPARED_EVENT, - wake, + assert not any( + listener.startswith("serial:turn.context_prepared") + for listener in snapshot.composition_root.topology_view().listeners ) - assert passive.extra_hints == [] - assert len(wake.extra_hints) == 1 # 2. candidate 可握手,但没有 Timer、外网或正式 write set。 database = data_root / "steam_proactive.sqlite3" @@ -246,12 +227,7 @@ def timer_factory() -> _Timer: assert sum(len(timer.handles) for timer in timers) == 1 candidate_root = candidate.runtime_snapshot.composition_root assert candidate_root is not None - candidate_wake = _ctx(now, "wake") - _ = await candidate_root.context.serial( - CONTEXT_PREPARED_EVENT, - candidate_wake, - ) - assert candidate_wake.extra_hints == [] + assert candidate_root.receipt().optional_pending == () assert hashlib.sha256(config.read_bytes()).hexdigest() == formal_hashes["config"] assert hashlib.sha256(database.read_bytes()).hexdigest() == formal_hashes["database"] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 7724ee6..586b746 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -21,7 +21,7 @@ from agent.plugins.composable import ComposablePlugin from agent.plugins.manager import _copy_validation_data from agent.plugins.static_manifest import load_static_plugin_manifest -from plugins.wake.contracts import WAKE_CONTEXT_SOURCE +from steam_test_plugin.eventmail import EVENTMAIL_CONTEXT_SOURCE # pyright: ignore[reportMissingImports] ROOT = Path(__file__).resolve().parents[1] @@ -30,7 +30,7 @@ def test_pure_v3_exports_and_exact_apply() -> None: assert plugin.api_version == 3 assert plugin.name == "steam" - assert plugin.version == "3.2.0" + assert plugin.version == "3.2.1" assert plugin.skill_roots == ("skills",) assert tuple(inspect.signature(plugin.apply).parameters) == ("ctx", "config") assert ComposablePlugin.from_module(plugin).skill_roots == ("skills",) @@ -47,7 +47,12 @@ async def test_apply_registers_user_mcp_and_dormant_context_runtime( TIMERS, PluginTimers(cast(OneShotTimer, object())), ) - _ = await root.context.provide(WAKE_CONTEXT_SOURCE, object()) + class Sources: + def bind(self, source_id: str) -> object: + assert source_id == "steam-presence" + return object() + + _ = await root.context.provide(EVENTMAIL_CONTEXT_SOURCE, Sources()) data_root = tmp_path / "plugin-data" await root.mount( ComposablePlugin.from_module(plugin), @@ -71,9 +76,32 @@ async def test_apply_registers_user_mcp_and_dormant_context_runtime( assert server.candidate_env == {"STEAM_BACKEND": "recording"} assert not data_root.exists() assert root.topology_view().listeners == ( - "serial:runtime.started:steam", - "serial:runtime.stopping:steam", + "serial:runtime.started:steam-eventmail-source", + "serial:runtime.stopping:steam-eventmail-source", + ) + await root.dispose() + + +@pytest.mark.asyncio +async def test_apply_keeps_user_mcp_without_eventmail(tmp_path: Path) -> None: + root = CompositionRoot("steam:without-eventmail") + servers = PluginMcpServers(root.instance_token) + await root.context.provide(MCP_SERVERS, servers) + await root.context.provide(TIMERS, PluginTimers.candidate_validation()) + await root.mount( + ComposablePlugin.from_module(plugin), + name="steam", + runtime=PluginRuntime( + plugin_id="steam", + generation_id="steam:without-eventmail", + plugin_dir=ROOT, + data_dir=tmp_path / "plugin-data", + workspace=tmp_path / "workspace", + config=plugin.SteamConfig(), + ), ) + + assert "steam" in _freeze_plugin_mcp_servers(servers, root.instance_token) await root.dispose() @@ -81,7 +109,7 @@ def test_static_manifest_excludes_state_and_bounded_logs() -> None: manifest = load_static_plugin_manifest(ROOT) assert manifest.name == plugin.name == "steam" - assert manifest.version == plugin.version == "3.2.0" + assert manifest.version == plugin.version == "3.2.1" assert manifest.api_version == plugin.api_version == 3 assert manifest.requirements == ("mcp/requirements.txt",) assert "steam_proactive.sqlite3" in manifest.exclude_data_paths