Compare commits

..

6 Commits

Author SHA1 Message Date
fullstack-engineer f1370ea86b test(workspace): add push-mode queue coverage for a2a_response.py (closes #308)
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 1s
sop-tier-check / tier-check (pull_request) Successful in 41s
audit-force-merge / audit (pull_request) Has been skipped
Add 4 fixtures + 12 tests covering the push-mode at-capacity envelope
path in a2a_response.parse() (lines 189-197).

Push-mode envelopes ({"queued": true, ...}) are returned by the
platform when a push-mode workspace (has public URL) is at capacity.
The proxy queues the request and returns {"queued": true, "message":
"...", "queue_id": "..."}. This path was added in PR #278 but never
tested — lines 182-197 had zero fixture coverage.

Fixtures added:
- push_queued_full     {"queued": true, method: "message/send", queue_id: "q-1"}
- push_queued_notify   {"queued": true, method: "notify", queue_id: "q-2"}
- push_queued_no_method  {"queued": true, queue_id: "q-3"}  (falls back to "message/send")
- push_queued_no_queue_id {"queued": true, method: "message/send"}

Tests cover: full envelope, notify method, method fallback, queue_id
absence, push vs poll distinction, INFO logging, and adversarial inputs
where non-True truthy values (queued: 1, queued: "yes") route to
Malformed rather than Queued.

Also updates test_every_fixture_classifies_to_expected_variant to
enumerate the new fixtures.
2026-05-10 16:17:29 +00:00
core-devops a3c9f0b717 Merge pull request 'ci: pin GitHub Actions by SHA instead of mutable tags (staging sync)' (#276) from ci/staging-sha-pinning into staging
Secret scan / Scan diff for credential-shaped strings (push) Failing after 2s
2026-05-10 14:03:05 +00:00
fullstack-engineer bea89ce4e9 fix(a2a): handle string-form errors in delegate_task
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 14s
sop-tier-check / tier-check (pull_request) Failing after 7s
audit-force-merge / audit (pull_request) Failing after 5s
The A2A proxy can return three error shapes:
  {"error": "plain string"}
  {"error": {"message": "...", "code": ...}}
  {"error": {"message": {"nested": "object"}}}   ← value at .message is a string

builtin_tools/a2a_tools.py:72 called data["error"].get("message")
without guarding against error being a string, which raised:
  AttributeError: 'str' object has no attribute 'get'

This broke every delegation attempt through the legacy a2a_tools path
(the LangChain-wrapped version used by adapter templates). The
SSOT parser a2a_response.py already handled string errors; the
legacy inline sniffer in a2a_tools.py did not.

Fix: branch on isinstance(err, dict/str/other) before calling .get().

Also update both publish-workflow files to remove the dead
`staging` branch trigger — trunk-based migration (PR #109,
2026-05-08) removed the staging branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:39:32 +00:00
integration-tester 14f05b5a64 chore: restore manifest.json after trigger test 2026-05-10 11:38:34 +00:00
integration-tester 7caee806df chore: trigger publish workflow [Integration Tester 2026-05-10T08:45Z] 2026-05-10 11:38:34 +00:00
integration-tester a914f675a4 chore: staging trigger commit from Integration Tester 2026-05-10 11:38:34 +00:00
7 changed files with 98 additions and 81 deletions
@@ -32,11 +32,9 @@ on:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
# Serialize per-branch so two rapid main pushes don't race the same
# :staging-latest tag retag. Allow parallel runs as they produce
# different :staging-<sha> tags and last-write-wins on :staging-latest.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
+1
View File
@@ -0,0 +1 @@
staging trigger
+1
View File
@@ -44,3 +44,4 @@
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
]
}
// Triggered by Integration Tester at 2026-05-10T08:52Z
+10
View File
@@ -77,6 +77,16 @@ async def delegate_task(workspace_id: str, task: str) -> str:
return str(result) if isinstance(result, str) else "(no text)"
elif "error" in data:
err = data["error"]
# Handle both string-form errors ("error": "some string")
# and object-form errors ("error": {"message": "...", "code": ...}).
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
elif isinstance(err, str):
msg = err
else:
msg = str(err)
return f"Error: {msg}"
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
-16
View File
@@ -51,22 +51,6 @@ class AdaptorSource:
def _load_module_from_path(module_name: str, path: Path):
"""Import a Python file by absolute path. Returns the module or None on failure."""
# Ensure the plugins_registry package and its submodules are importable in the
# fresh module namespace created by module_from_spec(). Plugin adapters
# (molecule-skill-*/adapters/*.py) use "from plugins_registry.builtins import ..."
# which requires plugins_registry and its submodules to already be in sys.modules.
# We import and register them before exec_module so the plugin's own
# from ... import statements resolve correctly.
import sys
import plugins_registry
sys.modules.setdefault("plugins_registry", plugins_registry)
for _sub in ("builtins", "protocol", "raw_drop"):
try:
sub = importlib.import_module(f"plugins_registry.{_sub}")
sys.modules.setdefault(f"plugins_registry.{_sub}", sub)
except Exception:
# Submodule may not exist in all versions; skip if absent.
pass
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
return None
@@ -1,60 +0,0 @@
"""Tests for _load_module_from_path sys.modules injection fix (issue #296).
Verifies that plugin adapters using "from plugins_registry.builtins import ..."
can be loaded via _load_module_from_path() without ModuleNotFoundError.
"""
import sys
import tempfile
import os
from pathlib import Path
# Ensure the plugins_registry package is importable
import plugins_registry
from plugins_registry import _load_module_from_path
def test_load_adapter_with_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry.builtins import ...' loads cleanly."""
# Write a temp adapter file that does the exact import from the bug report.
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n")
f.write("assert Adaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "Adaptor"), "module should expose Adaptor"
finally:
os.unlink(adapter_path)
def test_load_adapter_with_full_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry import ...' loads cleanly."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry import InstallContext, resolve\n")
f.write("from plugins_registry.protocol import PluginAdaptor\n")
f.write("assert InstallContext is not None\n")
f.write("assert resolve is not None\n")
f.write("assert PluginAdaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter_full", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "InstallContext"), "module should expose InstallContext"
assert hasattr(module, "resolve"), "module should expose resolve"
assert hasattr(module, "PluginAdaptor"), "module should expose PluginAdaptor"
finally:
os.unlink(adapter_path)
if __name__ == "__main__":
test_load_adapter_with_plugins_registry_import()
test_load_adapter_with_full_plugins_registry_import()
print("ALL TESTS PASS")
+83
View File
@@ -115,12 +115,91 @@ _FIXTURES = {
"malformed_delivery_mode_no_status": {
"delivery_mode": "poll",
},
# --- Push-mode queue envelopes ---
# Returned when a push-mode workspace (has public URL) is at capacity.
# The platform queues the request and returns {"queued": true, ...}.
# Distinguishable from poll-mode by data.get("queued") is True alone.
"push_queued_full": {
"queued": True,
"method": "message/send",
"queue_id": "q-1",
},
"push_queued_notify": {
"queued": True,
"method": "notify",
"queue_id": "q-2",
},
"push_queued_no_method": {
# method absent — parser must not raise; falls back to "message/send".
"queued": True,
"queue_id": "q-3",
},
"push_queued_no_queue_id": {
# queue_id absent — parser must not raise; logs queue_id="?".
"queued": True,
"method": "message/send",
},
}
# ============== Variant-by-variant coverage ==============
class TestPushQueuedVariant:
"""``parse()`` returns ``Queued`` for push-mode at-capacity envelope
(lines 189-197 of a2a_response.py): ``{"queued": true, ...}``.
The push-mode path was added in PR #278 alongside the a2a_proxy.go
push-at-capacity branch. Lines 182-197 were not covered until this test.
"""
def test_full_envelope_message_send(self):
v = a2a_response.parse(_FIXTURES["push_queued_full"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "message/send"
assert v.delivery_mode == "poll"
def test_envelope_with_notify(self):
v = a2a_response.parse(_FIXTURES["push_queued_notify"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "notify"
def test_envelope_missing_method_falls_back_to_message_send(self):
# a2a_response.py:191 — method_raw is None, defaults to "message/send".
v = a2a_response.parse(_FIXTURES["push_queued_no_method"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "message/send"
def test_envelope_missing_queue_id_still_queued(self):
# queue_id is purely informational; its absence must not break parsing.
v = a2a_response.parse(_FIXTURES["push_queued_no_queue_id"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "message/send"
def test_push_queued_is_distinct_from_poll_queued(self):
# Same Queued variant, but from different wire shapes. Confirm both paths.
push_v = a2a_response.parse(_FIXTURES["push_queued_full"])
poll_v = a2a_response.parse(_FIXTURES["poll_queued_full"])
assert isinstance(push_v, a2a_response.Queued)
assert isinstance(poll_v, a2a_response.Queued)
assert push_v.method == poll_v.method == "message/send"
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 busy push-mode peer" in r.message for r in caplog.records)
assert any("queue_id=q-1" in r.message for r in caplog.records)
def test_queued_true_is_distinct_from_queued_truthy(self):
# "queued": 1 / "queued": "yes" — these are truthy but not True,
# and must NOT trigger the push-mode path. Route to Malformed instead.
v = a2a_response.parse({"queued": 1})
assert isinstance(v, a2a_response.Malformed)
v = a2a_response.parse({"queued": "yes"})
assert isinstance(v, a2a_response.Malformed)
class TestQueuedVariant:
"""``parse()`` recognizes the workspace-server poll-mode short-circuit
envelope (a2a_proxy.go:402-406) and returns ``Queued``."""
@@ -436,6 +515,10 @@ 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,
"push_queued_no_queue_id": a2a_response.Queued,
"malformed_empty_dict": a2a_response.Malformed,
"malformed_unexpected_keys": a2a_response.Malformed,
"malformed_status_queued_no_delivery_mode": a2a_response.Malformed,