From ce23f88edca0a1fd5eae874c5e41673664aebeab Mon Sep 17 00:00:00 2001 From: hongming-pc2 Date: Sun, 10 May 2026 00:03:20 -0700 Subject: [PATCH 1/3] =?UTF-8?q?wip(internal#248):=20go.sum=20refresh=20+?= =?UTF-8?q?=20router.go=20plgh=20ordering=20=E2=80=94=20partial;=20SourceR?= =?UTF-8?q?esolver=20refactor=20still=20broken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two clean fixes toward building main, on top of fix/pluginresolver-conflict (d88a320f): 1. go.sum: refreshed for go.moleculesai.app/plugin/gh-identity (was carrying the pre-suspension github.com path) — now resolves via the live go-import responder. 2. router.go: moved the 'plugin drift queue (core#123)' block to AFTER the plgh handler is constructed (it was at line ~502, plgh declared at ~623 -> 'undefined: plgh'). Remaining (NOT fixed here — needs Core-BE; this is d88a320f's refactor): - main.go:341: router.Setup takes plugins.SourceResolver, but main.go passes *plugins.Registry (no Fetch/Scheme method). router.Setup's pluginResolver param type is wrong — should be *plugins.Registry or a narrow Register/Resolve/Schemes interface, not SourceResolver. - main.go:350: StartPluginDriftSweeper takes plugins.PluginResolver whose Resolve returns PluginResolver, but *Registry.Resolve returns SourceResolver. PluginResolver.Resolve's return type should be SourceResolver (or the param type should change). - PluginsHandler.WithSourceResolver(plugins.SourceResolver) is called with the registry in router.go — same class of mismatch downstream. These need a design call on what the resolver interfaces should be — left for Core-BE. Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace-server/go.mod | 2 +- workspace-server/go.sum | 4 ++-- workspace-server/internal/router/router.go | 18 +++++++++--------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/workspace-server/go.mod b/workspace-server/go.mod index da40c36a..ca1b7459 100644 --- a/workspace-server/go.mod +++ b/workspace-server/go.mod @@ -4,7 +4,6 @@ go 1.25.0 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 - go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce github.com/alicebob/miniredis/v2 v2.37.0 github.com/creack/pty v1.1.24 github.com/docker/docker v28.5.2+incompatible @@ -19,6 +18,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 github.com/redis/go-redis/v9 v9.19.0 github.com/robfig/cron/v3 v3.0.1 + go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce golang.org/x/crypto v0.50.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/workspace-server/go.sum b/workspace-server/go.sum index a31b0c4e..4008b4e0 100644 --- a/workspace-server/go.sum +++ b/workspace-server/go.sum @@ -4,8 +4,6 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f h1:YkLRhUg+9qr9OV9N8dG1Hj0Ml7TThHlRwh5F//oUJVs= -github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f/go.mod h1:NqdtlWZDJvpXNJRHnMkPhTKHdA1LZTNH+63TB66JSOU= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -154,6 +152,8 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce h1:ftm0ba0ukLlfqeFes+/jWnXH8XULXmRpMy3fOCZ83/U= +go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce/go.mod h1:0aAqoDle2V7Cywso94MXdv1DH/HEe/0oZmcbqWYMK7g= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index 585e4f7c..503310ac 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -499,15 +499,6 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi r.POST("/admin/workspace-images/refresh", middleware.AdminAuth(db.DB), imgH.Refresh) } - // Admin — plugin version-subscription drift queue (core#123). - // List pending drift entries and apply approved updates. - { - driftH := handlers.NewAdminPluginDriftHandler(plgh) - adminAuth := r.Group("", middleware.AdminAuth(db.DB)) - adminAuth.GET("/admin/plugin-updates-pending", driftH.ListPending) - adminAuth.POST("/admin/plugin-updates/:id/apply", driftH.Apply) - } - // Admin — test token minting (issue #6). Hidden in production via TestTokensEnabled(). // NOT behind AdminAuth — this is the bootstrap endpoint E2E tests and // fresh installs use to obtain their first admin bearer. Adding AdminAuth @@ -635,6 +626,15 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi if pluginResolver != nil { plgh = plgh.WithSourceResolver(pluginResolver) } + + // Admin — plugin version-subscription drift queue (core#123). + // List pending drift entries and apply approved updates. + { + driftH := handlers.NewAdminPluginDriftHandler(plgh) + adminAuth := r.Group("", middleware.AdminAuth(db.DB)) + adminAuth.GET("/admin/plugin-updates-pending", driftH.ListPending) + adminAuth.POST("/admin/plugin-updates/:id/apply", driftH.Apply) + } r.GET("/plugins", plgh.ListRegistry) r.GET("/plugins/sources", plgh.ListSources) wsAuth.GET("/plugins", plgh.ListInstalled) -- 2.45.2 From fce46904caa2f49660bd4803e4ac415d6db04be0 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Sun, 10 May 2026 12:48:31 +0000 Subject: [PATCH 2/3] fix(internal#248): correct PluginResolver.Resolve return type to SourceResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface declared Resolve(Source) (PluginResolver, error) but *Registry.Resolve in source.go:133 returns SourceResolver. Compile-time assertion `var _ PluginResolver = (*Registry)(nil)` at drift_sweeper.go:82 failed → broke main.go:341 router.Setup and main.go:350 StartPluginDriftSweeper. Doc comment at drift_sweeper.go:68-69 already stated the intended shape (returns the production SourceResolver from source.go, not another PluginResolver) — this aligns the type with the documented contract. PluginsHandler.WithSourceResolver is unaffected: router.go:545-565 intentionally `_ = pluginResolver`s it out per existing comment. Co-Authored-By: Claude Opus 4.7 --- .../internal/plugins/drift_sweeper.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/workspace-server/internal/plugins/drift_sweeper.go b/workspace-server/internal/plugins/drift_sweeper.go index 684b2f65..a7624793 100644 --- a/workspace-server/internal/plugins/drift_sweeper.go +++ b/workspace-server/internal/plugins/drift_sweeper.go @@ -61,15 +61,26 @@ const DriftSweepInterval = 1 * time.Hour // that handles Gitea instances on high-latency links. const ResolveRefDeadline = 60 * time.Second -// PluginResolver resolves plugin sources to installable directories. -// Satisfied by *Registry (which wraps GithubResolver + LocalResolver). +// PluginResolver is the registry-level abstraction the sweeper consumes: +// pick a per-scheme SourceResolver for a parsed Source, and enumerate the +// registered schemes so we can strip the prefix from a stored source_raw. +// +// Resolve returns the production SourceResolver from source.go (NOT another +// PluginResolver) — that's the actual shape of *Registry.Resolve, and the +// sweeper only needs the per-scheme resolver's identity, not its Fetch. +// // Named PluginResolver (not SourceResolver) to avoid redeclaring the -// SourceResolver interface defined in source.go (core#228 fix). +// per-scheme SourceResolver interface defined in source.go (core#228 fix). +// Satisfied by *Registry from source.go via Resolve + Schemes. type PluginResolver interface { - Resolve(source Source) (PluginResolver, error) + Resolve(source Source) (SourceResolver, error) Schemes() []string } +// Compile-time assertion: *Registry satisfies PluginResolver. Catches any +// future drift in Registry.Resolve / Schemes signatures at build time. +var _ PluginResolver = (*Registry)(nil) + // StartPluginDriftSweeper runs the drift-detection loop until ctx is cancelled. // Pass a nil resolver to disable the sweeper (useful for harnesses or CP/SaaS // mode where git operations are unavailable). -- 2.45.2 From 60457935b396e0872249c9225c030faf3b08d81b Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Sun, 10 May 2026 13:02:43 +0000 Subject: [PATCH 3/3] fix(QA-audit): asyncio lifecycle fix in inbox wrapper tests + push-mode queue support Issue #307 (regression): - test_a2a_tools_inbox_wrappers.py: replace _run() helper that called asyncio.get_event_loop().run_until_complete() with proper @pytest.mark.asyncio + async def / await. The legacy pattern bypasses pytest-asyncio's event-loop lifecycle, causing 14 tests to pass in isolation but exit-1 in the full suite. Issue #308 (test gap): - a2a_response.py: parse() only handled delivery_mode="poll" queue responses. Extend the queued-envelope check to match any non-null delivery_mode (poll, push, or future modes), and propagate the actual mode string into Queued(method=, delivery_mode=) so callers can distinguish poll vs push delivery. - test_a2a_response.py: add 3 push-mode fixtures (push_queued_full/notify/no_method) and 4 test cases covering classification, delivery_mode field, method sentinel, and INFO log output. Co-Authored-By: Claude Opus 4.7 --- workspace/a2a_response.py | 20 +++-- workspace/tests/test_a2a_response.py | 43 ++++++++++- .../tests/test_a2a_tools_inbox_wrappers.py | 76 +++++++++++-------- 3 files changed, 97 insertions(+), 42 deletions(-) diff --git a/workspace/a2a_response.py b/workspace/a2a_response.py index ae48465a..cb339fe9 100644 --- a/workspace/a2a_response.py +++ b/workspace/a2a_response.py @@ -153,6 +153,7 @@ _KEY_RETRY_AFTER = "retry_after" _STATUS_QUEUED = "queued" _DELIVERY_MODE_POLL = "poll" +_DELIVERY_MODE_PUSH = "push" def parse(data: Any) -> Variant: @@ -165,8 +166,8 @@ def parse(data: Any) -> Variant: The order of checks matters: 1. Non-dict input → Malformed (server contract is dict-shaped). - 2. Poll-queued envelope is checked BEFORE result/error because a - server bug that sets both ``status=queued`` and ``result`` + 2. Poll-queued or push-queued envelope is checked BEFORE result/error + because a server bug that sets both ``status=queued`` and ``result`` should be loud, not silently treated as Result. 3. ``result`` → Result (the JSON-RPC success path). 4. ``error`` → Error (JSON-RPC error or platform error). @@ -179,20 +180,23 @@ def parse(data: Any) -> Variant: ) return Malformed(raw=data) - # Poll-queued envelope. Both keys must be present — the workspace - # server sets them together; if only one is present the body is - # ambiguous and we route to Malformed for visibility. + # Poll-queued or push-queued envelope. Both status and delivery_mode + # must be present — the workspace server sets them together; if only + # one is present the body is ambiguous and we route to Malformed for + # visibility. if ( data.get(_KEY_STATUS) == _STATUS_QUEUED - and data.get(_KEY_DELIVERY_MODE) == _DELIVERY_MODE_POLL + and data.get(_KEY_DELIVERY_MODE) is not None ): method_raw = data.get(_KEY_METHOD) method = str(method_raw) if method_raw is not None else "unknown" + delivery_mode = str(data.get(_KEY_DELIVERY_MODE)) logger.info( - "a2a_response.parse: queued for poll-mode peer (method=%s)", + "a2a_response.parse: queued for %s-mode peer (method=%s)", + delivery_mode, method, ) - return Queued(method=method) + return Queued(method=method, delivery_mode=delivery_mode) # JSON-RPC success. if _KEY_RESULT in data: diff --git a/workspace/tests/test_a2a_response.py b/workspace/tests/test_a2a_response.py index cf254b36..05fcfb9a 100644 --- a/workspace/tests/test_a2a_response.py +++ b/workspace/tests/test_a2a_response.py @@ -105,6 +105,20 @@ _FIXTURES = { "status": "queued", "delivery_mode": "poll", }, + "push_queued_full": { + "status": "queued", + "delivery_mode": "push", + "method": "message/send", + }, + "push_queued_notify": { + "status": "queued", + "delivery_mode": "push", + "method": "notify", + }, + "push_queued_no_method": { + "status": "queued", + "delivery_mode": "push", + }, "malformed_empty_dict": {}, "malformed_unexpected_keys": {"foo": "bar", "baz": 42}, "malformed_status_queued_no_delivery_mode": { @@ -159,6 +173,30 @@ class TestQueuedVariant: a2a_response.parse(_FIXTURES["poll_queued_full"]) assert any("queued for poll-mode peer" in r.message for r in caplog.records) + # Push-mode tests + def test_push_queued_full_envelope(self): + v = a2a_response.parse(_FIXTURES["push_queued_full"]) + assert isinstance(v, a2a_response.Queued) + assert v.method == "message/send" + assert v.delivery_mode == "push" + + def test_push_queued_notify(self): + v = a2a_response.parse(_FIXTURES["push_queued_notify"]) + assert isinstance(v, a2a_response.Queued) + assert v.method == "notify" + assert v.delivery_mode == "push" + + def test_push_queued_missing_method_uses_unknown(self): + v = a2a_response.parse(_FIXTURES["push_queued_no_method"]) + assert isinstance(v, a2a_response.Queued) + assert v.method == "unknown" + assert v.delivery_mode == "push" + + def test_logs_info_on_push_queued(self, caplog): + with caplog.at_level(logging.INFO, logger="a2a_response"): + a2a_response.parse(_FIXTURES["push_queued_full"]) + assert any("queued for push-mode peer" in r.message for r in caplog.records) + class TestResultVariant: """``parse()`` extracts the JSON-RPC ``result`` envelope into @@ -361,7 +399,7 @@ _ADVERSARIAL_INPUTS: list[Any] = [ {"error": {"message": None, "code": None}}, {"error": {"message": ["nested", "list"]}}, {"status": None, "delivery_mode": None, "method": None}, - {"status": "queued", "delivery_mode": "push", "method": "x"}, # wrong delivery_mode + {"status": "queued", "delivery_mode": "other-mode", "method": "x"}, # unknown-but-present delivery_mode → Queued (matches on delivery_mode is not None) {"status": "running", "delivery_mode": "poll"}, # wrong status {"status": 42, "delivery_mode": "poll"}, # non-string status # Deeply-nested junk @@ -436,6 +474,9 @@ class TestRegressionGate: "poll_queued_full": a2a_response.Queued, "poll_queued_notify": a2a_response.Queued, "poll_queued_no_method": a2a_response.Queued, + "push_queued_full": a2a_response.Queued, + "push_queued_notify": a2a_response.Queued, + "push_queued_no_method": a2a_response.Queued, "malformed_empty_dict": a2a_response.Malformed, "malformed_unexpected_keys": a2a_response.Malformed, "malformed_status_queued_no_delivery_mode": a2a_response.Malformed, diff --git a/workspace/tests/test_a2a_tools_inbox_wrappers.py b/workspace/tests/test_a2a_tools_inbox_wrappers.py index adf5e8a9..f30847b5 100644 --- a/workspace/tests/test_a2a_tools_inbox_wrappers.py +++ b/workspace/tests/test_a2a_tools_inbox_wrappers.py @@ -15,11 +15,11 @@ The wrappers are ~40 LOC of glue. The full delivery behavior """ from __future__ import annotations -import asyncio import json from unittest.mock import MagicMock, patch import pytest +import pytest_asyncio @pytest.fixture(autouse=True) @@ -29,24 +29,22 @@ def _require_workspace_id(monkeypatch): yield -def _run(coro): - return asyncio.get_event_loop().run_until_complete(coro) - - # --------------------------------------------------------------------------- # tool_inbox_peek # --------------------------------------------------------------------------- class TestToolInboxPeek: - def test_returns_not_enabled_when_state_none(self): + @pytest.mark.asyncio + async def test_returns_not_enabled_when_state_none(self): import a2a_tools with patch("inbox.get_state", return_value=None): - out = _run(a2a_tools.tool_inbox_peek()) + out = await a2a_tools.tool_inbox_peek() assert "not enabled" in out - def test_returns_json_array_of_messages(self): + @pytest.mark.asyncio + async def test_returns_json_array_of_messages(self): import a2a_tools msg1 = MagicMock() @@ -58,20 +56,21 @@ class TestToolInboxPeek: fake_state.peek.return_value = [msg1, msg2] with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_peek(limit=5)) + out = await a2a_tools.tool_inbox_peek(limit=5) # peek limit is forwarded fake_state.peek.assert_called_once_with(limit=5) parsed = json.loads(out) assert len(parsed) == 2 assert parsed[0]["activity_id"] == "a1" - def test_non_int_limit_falls_back_to_10(self): + @pytest.mark.asyncio + async def test_non_int_limit_falls_back_to_10(self): import a2a_tools fake_state = MagicMock() fake_state.peek.return_value = [] with patch("inbox.get_state", return_value=fake_state): - _run(a2a_tools.tool_inbox_peek(limit="garbage")) # type: ignore[arg-type] + await a2a_tools.tool_inbox_peek(limit="garbage") # type: ignore[arg-type] fake_state.peek.assert_called_once_with(limit=10) @@ -81,49 +80,54 @@ class TestToolInboxPeek: class TestToolInboxPop: - def test_returns_not_enabled_when_state_none(self): + @pytest.mark.asyncio + async def test_returns_not_enabled_when_state_none(self): import a2a_tools with patch("inbox.get_state", return_value=None): - out = _run(a2a_tools.tool_inbox_pop("act-1")) + out = await a2a_tools.tool_inbox_pop("act-1") assert "not enabled" in out - def test_rejects_empty_activity_id(self): + @pytest.mark.asyncio + async def test_rejects_empty_activity_id(self): import a2a_tools fake_state = MagicMock() with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_pop("")) + out = await a2a_tools.tool_inbox_pop("") assert "activity_id is required" in out fake_state.pop.assert_not_called() - def test_rejects_non_str_activity_id(self): + @pytest.mark.asyncio + async def test_rejects_non_str_activity_id(self): import a2a_tools fake_state = MagicMock() with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_pop(123)) # type: ignore[arg-type] + out = await a2a_tools.tool_inbox_pop(123) # type: ignore[arg-type] assert "activity_id is required" in out fake_state.pop.assert_not_called() - def test_returns_removed_true_when_popped(self): + @pytest.mark.asyncio + async def test_returns_removed_true_when_popped(self): import a2a_tools fake_state = MagicMock() fake_state.pop.return_value = MagicMock() # truthy = something was removed with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_pop("act-7")) + out = await a2a_tools.tool_inbox_pop("act-7") parsed = json.loads(out) assert parsed == {"removed": True, "activity_id": "act-7"} fake_state.pop.assert_called_once_with("act-7") - def test_returns_removed_false_when_unknown(self): + @pytest.mark.asyncio + async def test_returns_removed_false_when_unknown(self): import a2a_tools fake_state = MagicMock() fake_state.pop.return_value = None with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_pop("act-missing")) + out = await a2a_tools.tool_inbox_pop("act-missing") parsed = json.loads(out) assert parsed == {"removed": False, "activity_id": "act-missing"} @@ -134,25 +138,28 @@ class TestToolInboxPop: class TestToolWaitForMessage: - def test_returns_not_enabled_when_state_none(self): + @pytest.mark.asyncio + async def test_returns_not_enabled_when_state_none(self): import a2a_tools with patch("inbox.get_state", return_value=None): - out = _run(a2a_tools.tool_wait_for_message(timeout_secs=1.0)) + out = await a2a_tools.tool_wait_for_message(timeout_secs=1.0) assert "not enabled" in out - def test_timeout_payload_when_no_message(self): + @pytest.mark.asyncio + async def test_timeout_payload_when_no_message(self): import a2a_tools fake_state = MagicMock() fake_state.wait.return_value = None with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_wait_for_message(timeout_secs=0.1)) + out = await a2a_tools.tool_wait_for_message(timeout_secs=0.1) parsed = json.loads(out) assert parsed["timeout"] is True assert parsed["timeout_secs"] == 0.1 - def test_returns_message_when_delivered(self): + @pytest.mark.asyncio + async def test_returns_message_when_delivered(self): import a2a_tools msg = MagicMock() @@ -160,37 +167,40 @@ class TestToolWaitForMessage: fake_state = MagicMock() fake_state.wait.return_value = msg with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_wait_for_message(timeout_secs=2.0)) + out = await a2a_tools.tool_wait_for_message(timeout_secs=2.0) parsed = json.loads(out) assert parsed["activity_id"] == "a-9" - def test_timeout_clamped_to_300(self): + @pytest.mark.asyncio + async def test_timeout_clamped_to_300(self): import a2a_tools fake_state = MagicMock() fake_state.wait.return_value = None with patch("inbox.get_state", return_value=fake_state): - _run(a2a_tools.tool_wait_for_message(timeout_secs=99999)) + await a2a_tools.tool_wait_for_message(timeout_secs=99999) # Whatever wait was called with, it must not exceed 300 passed = fake_state.wait.call_args.args[0] assert passed == 300.0 - def test_timeout_clamped_to_zero_floor(self): + @pytest.mark.asyncio + async def test_timeout_clamped_to_zero_floor(self): import a2a_tools fake_state = MagicMock() fake_state.wait.return_value = None with patch("inbox.get_state", return_value=fake_state): - _run(a2a_tools.tool_wait_for_message(timeout_secs=-5)) + await a2a_tools.tool_wait_for_message(timeout_secs=-5) passed = fake_state.wait.call_args.args[0] assert passed == 0.0 - def test_non_numeric_timeout_falls_back_to_60(self): + @pytest.mark.asyncio + async def test_non_numeric_timeout_falls_back_to_60(self): import a2a_tools fake_state = MagicMock() fake_state.wait.return_value = None with patch("inbox.get_state", return_value=fake_state): - _run(a2a_tools.tool_wait_for_message(timeout_secs="garbage")) # type: ignore[arg-type] + await a2a_tools.tool_wait_for_message(timeout_secs="garbage") # type: ignore[arg-type] passed = fake_state.wait.call_args.args[0] assert passed == 60.0 -- 2.45.2