fix(workspace): enrich poll-path inbox messages with peer_name/role/card_url
Reported: agents receiving messages via inbox_peek / wait_for_message get a plain envelope — text + peer_id + kind only. The push-path (a2a_mcp_server._build_channel_notification) already enriches the meta dict with peer_name, peer_role, and agent_card_url from the registry cache, but the poll-path returns InboxMessage.to_dict() unchanged. So a Claude Code host with channel-push gets the friendly identity, but every other MCP client (and Claude Code with push disabled — the universal default) sees plain text. This silently breaks the contract documented in a2a_mcp_server.py:303-345: > In both paths the same fields apply: kind, peer_id, peer_name, > peer_role, agent_card_url, activity_id Fix: a2a_tools._enrich_inbound_for_agent() — same shape as the push-path's enrichment, called from tool_inbox_peek and tool_wait_for_message. Cache-first non-blocking (5-min TTL via enrich_peer_metadata_nonblocking, same helper push uses), so a cache miss returns immediately with bare envelope and warms the cache for the next poll. agent_card_url is constructable from peer_id alone and surfaces even on cache miss, so the receiving agent always has a single endpoint to hit for capabilities. Degradation paths: - canvas_user (peer_id="") → pass through unchanged, no enrichment - a2a_client unavailable (test harness without registry) → bare envelope, agent still gets text + peer_id + kind + activity_id Tests: - canvas_user passes through unchanged - peer_agent cache hit → name + role + agent_card_url all present - peer_agent cache miss → agent_card_url still constructed - a2a_client unavailable → bare envelope, no crash All 4 pass against fixed code. Without the fix, the cache-hit and cache-miss tests would fail (peer_name/peer_role/agent_card_url keys absent from to_dict's output). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9bd2a2c45f
commit
3d0a7c381b
@ -550,6 +550,52 @@ async def tool_chat_history(
|
||||
return json.dumps(rows)
|
||||
|
||||
|
||||
def _enrich_inbound_for_agent(d: dict) -> dict:
|
||||
"""Add peer_name / peer_role / agent_card_url to a poll-path message.
|
||||
|
||||
The PUSH path (a2a_mcp_server._build_channel_notification) already
|
||||
enriches the meta dict with these fields, so a Claude Code host
|
||||
with channel-push sees them. The POLL path goes through
|
||||
InboxMessage.to_dict, which is intentionally identity-free (the
|
||||
storage layer doesn't know about the registry cache). Without this
|
||||
helper, every non-Claude-Code MCP client that uses inbox_peek /
|
||||
wait_for_message gets a plain message and the receiving agent
|
||||
can't tell who's writing — breaking the contract documented in
|
||||
a2a_mcp_server.py:303-345 ("In both paths the same fields apply").
|
||||
|
||||
Cache-first non-blocking enrichment (same shape as push): on cache
|
||||
miss the helper returns the bare message; the next call within the
|
||||
5-min TTL hits the warm cache. Failure to enrich is non-fatal —
|
||||
the agent still gets text + peer_id + kind + activity_id, just
|
||||
without the friendly identity.
|
||||
"""
|
||||
peer_id = d.get("peer_id") or ""
|
||||
if not peer_id:
|
||||
# canvas_user — no peer to enrich; helper returns the plain
|
||||
# message unchanged so the canvas reply path still works.
|
||||
return d
|
||||
try:
|
||||
from a2a_client import ( # local import — avoid module-load cycle
|
||||
_agent_card_url_for,
|
||||
enrich_peer_metadata_nonblocking,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
# If a2a_client is unavailable (test harness, partial install),
|
||||
# degrade gracefully — agent still gets the bare envelope.
|
||||
return d
|
||||
record = enrich_peer_metadata_nonblocking(peer_id)
|
||||
if record is not None:
|
||||
if name := record.get("name"):
|
||||
d["peer_name"] = name
|
||||
if role := record.get("role"):
|
||||
d["peer_role"] = role
|
||||
# agent_card_url is constructable from peer_id alone — surface it
|
||||
# even when registry enrichment misses, so the receiving agent has
|
||||
# a single endpoint to hit for the peer's full capability list.
|
||||
d["agent_card_url"] = _agent_card_url_for(peer_id)
|
||||
return d
|
||||
|
||||
|
||||
async def tool_inbox_peek(limit: int = 10) -> str:
|
||||
"""Return up to ``limit`` pending inbound messages without removing them."""
|
||||
import inbox # local import — avoids a circular dep at module load
|
||||
@ -558,7 +604,7 @@ async def tool_inbox_peek(limit: int = 10) -> str:
|
||||
if state is None:
|
||||
return _INBOX_NOT_ENABLED_MSG
|
||||
messages = state.peek(limit=limit if isinstance(limit, int) else 10)
|
||||
return json.dumps([m.to_dict() for m in messages])
|
||||
return json.dumps([_enrich_inbound_for_agent(m.to_dict()) for m in messages])
|
||||
|
||||
|
||||
async def tool_inbox_pop(activity_id: str) -> str:
|
||||
@ -606,4 +652,4 @@ async def tool_wait_for_message(timeout_secs: float = 60.0) -> str:
|
||||
message = await loop.run_in_executor(None, state.wait, timeout)
|
||||
if message is None:
|
||||
return json.dumps({"timeout": True, "timeout_secs": timeout})
|
||||
return json.dumps(message.to_dict())
|
||||
return json.dumps(_enrich_inbound_for_agent(message.to_dict()))
|
||||
|
||||
153
workspace/tests/test_a2a_tools_inbox_enrichment.py
Normal file
153
workspace/tests/test_a2a_tools_inbox_enrichment.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""Tests for `_enrich_inbound_for_agent` — the poll-path companion to
|
||||
the push-path enrichment in `a2a_mcp_server._build_channel_notification`.
|
||||
|
||||
The MCP poll path (inbox_peek / wait_for_message) returns
|
||||
`InboxMessage.to_dict()`, which has `activity_id, text, peer_id, kind,
|
||||
method, created_at` but NOT the registry-resolved `peer_name`,
|
||||
`peer_role`, or `agent_card_url`. The receiving agent then sees a
|
||||
plain message and can't tell who's writing — breaking the universal
|
||||
contract documented in `a2a_mcp_server.py:303-345` ("In both paths
|
||||
the same fields apply").
|
||||
|
||||
The enrichment helper closes that gap. These tests pin:
|
||||
- canvas_user (peer_id="") passes through unchanged
|
||||
- peer_agent with cache hit gets peer_name + peer_role + agent_card_url
|
||||
- peer_agent with cache miss still gets agent_card_url (constructable
|
||||
from peer_id alone)
|
||||
- a2a_client unavailable (test harness without registry) degrades
|
||||
gracefully — agent still gets the bare envelope
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# a2a_client.py reads WORKSPACE_ID at import time and raises if it's
|
||||
# unset. Stamp a stub before any test pulls in a2a_tools (which transitively
|
||||
# imports a2a_client). conftest.py mocks the SDK but not this env var.
|
||||
os.environ.setdefault("WORKSPACE_ID", "00000000-0000-0000-0000-000000000001")
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
PEER_UUID = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
|
||||
def test_canvas_user_passes_through_unchanged():
|
||||
from a2a_tools import _enrich_inbound_for_agent
|
||||
|
||||
base = {
|
||||
"activity_id": "act-1",
|
||||
"text": "hello from canvas",
|
||||
"peer_id": "",
|
||||
"kind": "canvas_user",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-05T11:00:00Z",
|
||||
}
|
||||
|
||||
out = _enrich_inbound_for_agent(dict(base))
|
||||
|
||||
# Plain pass-through — no enrichment fields added for canvas_user.
|
||||
assert out == base
|
||||
assert "peer_name" not in out
|
||||
assert "peer_role" not in out
|
||||
assert "agent_card_url" not in out
|
||||
|
||||
|
||||
def test_peer_agent_cache_hit_adds_name_role_and_card_url():
|
||||
from a2a_tools import _enrich_inbound_for_agent
|
||||
|
||||
record = {"name": "ops-agent", "role": "sre"}
|
||||
card_url = f"https://platform.example/registry/{PEER_UUID}/agent-card"
|
||||
|
||||
with patch(
|
||||
"a2a_client.enrich_peer_metadata_nonblocking",
|
||||
return_value=record,
|
||||
), patch(
|
||||
"a2a_client._agent_card_url_for",
|
||||
return_value=card_url,
|
||||
):
|
||||
out = _enrich_inbound_for_agent({
|
||||
"activity_id": "act-2",
|
||||
"text": "ping",
|
||||
"peer_id": PEER_UUID,
|
||||
"kind": "peer_agent",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-05T11:01:00Z",
|
||||
})
|
||||
|
||||
assert out["peer_name"] == "ops-agent"
|
||||
assert out["peer_role"] == "sre"
|
||||
assert out["agent_card_url"] == card_url
|
||||
|
||||
|
||||
def test_peer_agent_cache_miss_still_gets_agent_card_url():
|
||||
"""agent_card_url is constructable from peer_id alone — surface it
|
||||
even when registry enrichment misses, so the receiving agent has a
|
||||
single endpoint to hit for the peer's full capability list."""
|
||||
from a2a_tools import _enrich_inbound_for_agent
|
||||
|
||||
card_url = f"https://platform.example/registry/{PEER_UUID}/agent-card"
|
||||
|
||||
with patch(
|
||||
"a2a_client.enrich_peer_metadata_nonblocking",
|
||||
return_value=None, # cache miss
|
||||
), patch(
|
||||
"a2a_client._agent_card_url_for",
|
||||
return_value=card_url,
|
||||
):
|
||||
out = _enrich_inbound_for_agent({
|
||||
"activity_id": "act-3",
|
||||
"text": "ping",
|
||||
"peer_id": PEER_UUID,
|
||||
"kind": "peer_agent",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-05T11:02:00Z",
|
||||
})
|
||||
|
||||
assert "peer_name" not in out
|
||||
assert "peer_role" not in out
|
||||
assert out["agent_card_url"] == card_url
|
||||
|
||||
|
||||
def test_peer_agent_a2a_client_unavailable_degrades_gracefully(monkeypatch):
|
||||
"""If a2a_client can't be imported (test harness, partial install),
|
||||
return the bare envelope — agent still gets text + peer_id + kind +
|
||||
activity_id, just without the friendly identity."""
|
||||
from a2a_tools import _enrich_inbound_for_agent
|
||||
|
||||
# Stub a2a_client import to fail.
|
||||
real_module = sys.modules.pop("a2a_client", None)
|
||||
fake = types.ModuleType("a2a_client")
|
||||
# Deliberately omit enrich_peer_metadata_nonblocking and
|
||||
# _agent_card_url_for so the helper's fallback path fires.
|
||||
sys.modules["a2a_client"] = fake
|
||||
|
||||
try:
|
||||
out = _enrich_inbound_for_agent({
|
||||
"activity_id": "act-4",
|
||||
"text": "ping",
|
||||
"peer_id": PEER_UUID,
|
||||
"kind": "peer_agent",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-05T11:03:00Z",
|
||||
})
|
||||
finally:
|
||||
if real_module is not None:
|
||||
sys.modules["a2a_client"] = real_module
|
||||
else:
|
||||
sys.modules.pop("a2a_client", None)
|
||||
|
||||
# Bare envelope passes through — receiving agent still has enough
|
||||
# to act, even if the friendly identity is missing.
|
||||
assert out["peer_id"] == PEER_UUID
|
||||
assert out["text"] == "ping"
|
||||
assert out["kind"] == "peer_agent"
|
||||
assert "peer_name" not in out
|
||||
assert "peer_role" not in out
|
||||
assert "agent_card_url" not in out
|
||||
Loading…
Reference in New Issue
Block a user