vystak-transport-http 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .eggs/
9
+ *.egg
10
+ .venv/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ .pyright/
14
+
15
+ # TypeScript
16
+ node_modules/
17
+ *.tsbuildinfo
18
+ coverage/
19
+
20
+ # IDE
21
+ .idea/
22
+ .vscode/
23
+ *.swp
24
+ *.swo
25
+ *~
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Environment
32
+ .env
33
+ .env.local
34
+
35
+ # Vystak generated output (was .agentstack/ before rename)
36
+ .vystak/
37
+ .agentstack/
38
+
39
+ # Bundled templates copied into vystak-cli wheel at build time (build hook output)
40
+ packages/python/vystak-cli/src/vystak_cli/templates/
41
+
42
+ # Tooling workspaces (brainstorm sessions, local agent state)
43
+ .superpowers/
44
+ .worktrees/
45
+ .claude/
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: vystak-transport-http
3
+ Version: 0.2.0
4
+ Summary: HTTP transport for Vystak east-west A2A traffic
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fastapi>=0.115
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: pydantic>=2.0
9
+ Requires-Dist: sse-starlette>=2.0
10
+ Requires-Dist: vystak
11
+ Description-Content-Type: text/markdown
12
+
13
+ # vystak-transport-http
14
+
15
+ HTTP implementation of the Vystak `Transport` ABC.
16
+
17
+ - `HttpTransport` — concrete Transport using httpx (client) and FastAPI (server's /a2a is already handled; serve() is a no-op).
18
+ - `HttpTransportPlugin` — `TransportPlugin` providing env-var contract and (empty) provisioning.
@@ -0,0 +1,6 @@
1
+ # vystak-transport-http
2
+
3
+ HTTP implementation of the Vystak `Transport` ABC.
4
+
5
+ - `HttpTransport` — concrete Transport using httpx (client) and FastAPI (server's /a2a is already handled; serve() is a no-op).
6
+ - `HttpTransportPlugin` — `TransportPlugin` providing env-var contract and (empty) provisioning.
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "vystak-transport-http"
3
+ dynamic = ["version"]
4
+ description = "HTTP transport for Vystak east-west A2A traffic"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "vystak",
9
+ "httpx>=0.27",
10
+ "fastapi>=0.115",
11
+ "sse-starlette>=2.0",
12
+ "pydantic>=2.0",
13
+ ]
14
+
15
+ [dependency-groups]
16
+ dev = [
17
+ "pytest>=8.0",
18
+ "pytest-asyncio>=0.23",
19
+ "uvicorn>=0.34",
20
+ ]
21
+
22
+ [build-system]
23
+ requires = ["hatchling", "hatch-vcs"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/vystak_transport_http"]
28
+
29
+ [tool.uv.sources]
30
+ vystak = { workspace = true }
31
+
32
+ [tool.pytest.ini_options]
33
+ asyncio_mode = "auto"
34
+
35
+ [tool.hatch.version]
36
+ source = "vcs"
37
+ raw-options = {root = "../../.."}
@@ -0,0 +1,6 @@
1
+ """HTTP transport implementation for Vystak."""
2
+
3
+ from vystak_transport_http.plugin import HttpTransportPlugin
4
+ from vystak_transport_http.transport import HttpTransport
5
+
6
+ __all__ = ["HttpTransport", "HttpTransportPlugin"]
@@ -0,0 +1,24 @@
1
+ """HttpChannelDelivery — POST /deliver to the channel's HTTP delivery port."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ from vystak_channel_runtime.delivery import ChannelDelivery, DeliveryRequest
7
+
8
+
9
+ class HttpChannelDelivery(ChannelDelivery):
10
+ def __init__(self, channel_routes: dict[str, str]) -> None:
11
+ # canonical_name → base URL like http://host:9999
12
+ self._routes = dict(channel_routes)
13
+
14
+ async def deliver(
15
+ self,
16
+ channel_canonical_name: str,
17
+ request: DeliveryRequest,
18
+ *,
19
+ timeout: float = 30,
20
+ ) -> None:
21
+ url = self._routes[channel_canonical_name].rstrip("/") + "/deliver"
22
+ async with httpx.AsyncClient(timeout=timeout) as c:
23
+ r = await c.post(url, json=request.model_dump(mode="json"))
24
+ r.raise_for_status()
@@ -0,0 +1,39 @@
1
+ """HttpTransportPlugin — registers the HTTP transport with providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from vystak.providers.base import GeneratedCode, TransportPlugin
6
+ from vystak.schema import Platform, Transport
7
+ from vystak.schema.agent import Agent
8
+ from vystak.transport.naming import slug
9
+
10
+
11
+ class HttpTransportPlugin(TransportPlugin):
12
+ """HTTP transport plugin. No broker to provision; listener handled by
13
+ the generated FastAPI app already."""
14
+
15
+ type = "http"
16
+
17
+ def build_provision_nodes(self, transport: Transport, platform: Platform):
18
+ return []
19
+
20
+ def generate_env_contract(self, transport: Transport, context: dict) -> dict[str, str]:
21
+ return {"VYSTAK_TRANSPORT_TYPE": "http"}
22
+
23
+ def generate_listener_code(self, transport: Transport) -> GeneratedCode | None:
24
+ return None
25
+
26
+ def resolve_address_for(self, agent: Agent, platform: Platform) -> str:
27
+ """Return the Docker-style DNS URL for an agent.
28
+
29
+ Matches the Docker provider's container naming (`vystak-{agent_name}`)
30
+ since that's what DockerAgentNode actually creates and what Docker's
31
+ network DNS resolves on the shared vystak-net. Namespace is not
32
+ encoded in the URL because the container name is namespace-flat today
33
+ — v1 deployments are single-namespace per Docker host.
34
+
35
+ Azure providers override this (or use their own plugin) since the
36
+ ingress hostname isn't derivable from the name alone.
37
+ """
38
+ port = agent.port or 8000
39
+ return f"http://vystak-{slug(agent.name)}:{port}/a2a"
@@ -0,0 +1,219 @@
1
+ """HttpTransport — uses httpx (client) and relies on generated FastAPI /a2a (server)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import uuid
8
+ from collections.abc import AsyncIterator
9
+ from typing import Any
10
+
11
+ import httpx
12
+ from pydantic import ValidationError
13
+ from vystak.transport import (
14
+ A2AEvent,
15
+ A2AMessage,
16
+ A2AResult,
17
+ AgentRef,
18
+ Transport,
19
+ )
20
+ from vystak.transport.base import ServerDispatcherProtocol
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class HttpTransport(Transport):
26
+ """HTTP implementation of the Transport ABC.
27
+
28
+ Client side: httpx POST to `{agent_url}/a2a` with JSON-RPC A2A envelope.
29
+ Server side: the generated agent already exposes `/a2a` via FastAPI;
30
+ `serve()` is a no-op.
31
+
32
+ Routes are supplied at construction time (typically built from
33
+ `VYSTAK_ROUTES_JSON` + the platform's canonical-to-URL mapping).
34
+ """
35
+
36
+ type = "http"
37
+ supports_streaming = True
38
+
39
+ def __init__(self, routes: dict[str, str]) -> None:
40
+ """`routes` maps canonical_name -> absolute URL ending in `/a2a`."""
41
+ self._routes = dict(routes)
42
+
43
+ def resolve_address(self, canonical_name: str) -> str:
44
+ try:
45
+ return self._routes[canonical_name]
46
+ except KeyError:
47
+ raise KeyError(
48
+ f"HttpTransport has no route for canonical name "
49
+ f"{canonical_name!r}; known: {sorted(self._routes)}"
50
+ ) from None
51
+
52
+ async def send_task(
53
+ self,
54
+ agent: AgentRef,
55
+ message: A2AMessage,
56
+ metadata: dict[str, Any],
57
+ *,
58
+ timeout: float,
59
+ ) -> A2AResult:
60
+ url = self.resolve_address(agent.canonical_name)
61
+ payload = self._build_payload("message/send", message, metadata)
62
+ try:
63
+ async with httpx.AsyncClient(timeout=timeout) as client:
64
+ response = await client.post(url, json=payload)
65
+ response.raise_for_status()
66
+ body = response.json()
67
+ except httpx.TimeoutException as exc:
68
+ raise TimeoutError(str(exc)) from exc
69
+ return self._parse_result(body, message.correlation_id)
70
+
71
+ async def stream_task(
72
+ self,
73
+ agent: AgentRef,
74
+ message: A2AMessage,
75
+ metadata: dict[str, Any],
76
+ *,
77
+ timeout: float,
78
+ ) -> AsyncIterator[A2AEvent]:
79
+ url = self.resolve_address(agent.canonical_name)
80
+ payload = self._build_payload("message/stream", message, metadata)
81
+ async with (
82
+ httpx.AsyncClient(timeout=timeout) as client,
83
+ client.stream("POST", url, json=payload) as response,
84
+ ):
85
+ response.raise_for_status()
86
+ async for line in response.aiter_lines():
87
+ if not line or not line.startswith("data:"):
88
+ continue
89
+ data = line.removeprefix("data:").strip()
90
+ if not data:
91
+ continue
92
+ try:
93
+ parsed = json.loads(data)
94
+ except json.JSONDecodeError:
95
+ continue
96
+ # A2AEvent model_validate tolerates missing optional fields.
97
+ try:
98
+ yield A2AEvent.model_validate(parsed)
99
+ except ValidationError:
100
+ # Skip lines that don't match the A2AEvent shape (e.g.,
101
+ # legacy JSON-RPC envelope frames emitted by the
102
+ # LangChain adapter for token/status/final events).
103
+ # Existing SSE consumers can still parse the envelope
104
+ # themselves at a higher layer. Log at debug so this is
105
+ # visible if real bugs surface.
106
+ logger.debug("skipping non-A2AEvent SSE frame: %r", parsed)
107
+ continue
108
+
109
+ async def serve(self, canonical_name: str, handler: ServerDispatcherProtocol) -> None:
110
+ # FastAPI's /a2a route already handles inbound HTTP; nothing to do.
111
+ return None
112
+
113
+ def _agent_base_url(self, agent: AgentRef) -> str:
114
+ """Derive the agent's base URL from its A2A wire address.
115
+
116
+ Plan A's URL format is consistent: the A2A endpoint lives at `{base}/a2a`,
117
+ so stripping the suffix gives the base. For future transports that use
118
+ different paths, revisit.
119
+ """
120
+ a2a_url = self.resolve_address(agent.canonical_name)
121
+ return a2a_url.removesuffix("/a2a")
122
+
123
+ async def create_response(
124
+ self,
125
+ agent: AgentRef,
126
+ request: dict[str, Any],
127
+ metadata: dict[str, Any],
128
+ *,
129
+ timeout: float,
130
+ ) -> dict[str, Any]:
131
+ base = self._agent_base_url(agent)
132
+ async with httpx.AsyncClient(timeout=timeout) as client:
133
+ response = await client.post(f"{base}/v1/responses", json=request)
134
+ response.raise_for_status()
135
+ return response.json()
136
+
137
+ async def create_response_stream(
138
+ self,
139
+ agent: AgentRef,
140
+ request: dict[str, Any],
141
+ metadata: dict[str, Any],
142
+ *,
143
+ timeout: float,
144
+ ) -> AsyncIterator[dict[str, Any]]:
145
+ base = self._agent_base_url(agent)
146
+ # Force stream=true even if caller didn't set it — this method's
147
+ # contract is that we yield chunks.
148
+ body = {**request, "stream": True}
149
+ async with (
150
+ httpx.AsyncClient(timeout=timeout) as client,
151
+ client.stream("POST", f"{base}/v1/responses", json=body) as response,
152
+ ):
153
+ response.raise_for_status()
154
+ async for line in response.aiter_lines():
155
+ if not line or not line.startswith("data:"):
156
+ continue
157
+ data = line.removeprefix("data:").strip()
158
+ if not data or data == "[DONE]":
159
+ continue
160
+ try:
161
+ yield json.loads(data)
162
+ except json.JSONDecodeError:
163
+ continue
164
+
165
+ async def get_response(
166
+ self,
167
+ agent: AgentRef,
168
+ response_id: str,
169
+ *,
170
+ timeout: float,
171
+ ) -> dict[str, Any] | None:
172
+ base = self._agent_base_url(agent)
173
+ async with httpx.AsyncClient(timeout=timeout) as client:
174
+ response = await client.get(f"{base}/v1/responses/{response_id}")
175
+ if response.status_code == 404:
176
+ return None
177
+ response.raise_for_status()
178
+ return response.json()
179
+
180
+ def _build_payload(
181
+ self, method: str, message: A2AMessage, metadata: dict[str, Any]
182
+ ) -> dict[str, Any]:
183
+ # A2A v0.3 spec shape (`message/send`, `message/stream`). The SDK's
184
+ # v0.3 compat layer requires `kind: "message"` plus a non-empty
185
+ # `messageId` on the wire — using the correlation_id as the
186
+ # messageId keeps client/server callsites lined up for tracing.
187
+ # `kind: "text"` on each part is similarly required.
188
+ parts = [
189
+ {**(p if isinstance(p, dict) else {}), "kind": "text"}
190
+ for p in message.parts
191
+ ]
192
+ return {
193
+ "jsonrpc": "2.0",
194
+ "id": str(uuid.uuid4()),
195
+ "method": method,
196
+ "params": {
197
+ "message": {
198
+ "kind": "message",
199
+ "messageId": message.correlation_id,
200
+ "role": message.role,
201
+ "parts": parts,
202
+ "contextId": message.correlation_id,
203
+ },
204
+ "metadata": {**message.metadata, **metadata},
205
+ },
206
+ }
207
+
208
+ def _parse_result(self, body: dict[str, Any], fallback_correlation: str) -> A2AResult:
209
+ result = body.get("result", {}) or {}
210
+ parts = result.get("status", {}).get("message", {}).get("parts", [])
211
+ text = ""
212
+ for part in parts:
213
+ if isinstance(part, dict) and "text" in part:
214
+ text += part["text"]
215
+ return A2AResult(
216
+ text=text,
217
+ correlation_id=result.get("correlation_id") or fallback_correlation,
218
+ metadata={},
219
+ )
File without changes
@@ -0,0 +1,30 @@
1
+ """HttpChannelDelivery test."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock, patch
4
+
5
+ import pytest
6
+ from vystak_channel_runtime.delivery import DeliveryRequest
7
+ from vystak_transport_http.delivery import HttpChannelDelivery
8
+
9
+
10
+ @pytest.mark.asyncio
11
+ async def test_post_to_channel_url():
12
+ routes = {"x.channels.dev": "http://vystak-channel-x:9999"}
13
+ d = HttpChannelDelivery(routes)
14
+ with patch("vystak_transport_http.delivery.httpx.AsyncClient") as ac:
15
+ client = AsyncMock()
16
+ ac.return_value.__aenter__.return_value = client
17
+ response = MagicMock()
18
+ response.raise_for_status = MagicMock()
19
+ client.post = AsyncMock(return_value=response)
20
+ await d.deliver("x.channels.dev", DeliveryRequest(thread_id="t", text="x"))
21
+ client.post.assert_awaited_once()
22
+ url = client.post.call_args.args[0]
23
+ assert url == "http://vystak-channel-x:9999/deliver"
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_unknown_channel_raises():
28
+ d = HttpChannelDelivery({})
29
+ with pytest.raises(KeyError):
30
+ await d.deliver("ghost.channels.dev", DeliveryRequest(thread_id="t", text="x"))
@@ -0,0 +1,60 @@
1
+ """Tests for HttpTransportPlugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from vystak.schema import Platform, Transport
6
+ from vystak.schema.agent import Agent
7
+ from vystak.schema.model import Model
8
+ from vystak.schema.provider import Provider
9
+ from vystak_transport_http import HttpTransportPlugin
10
+
11
+
12
+ def test_type():
13
+ p = HttpTransportPlugin()
14
+ assert p.type == "http"
15
+
16
+
17
+ def test_no_provision_nodes():
18
+ p = HttpTransportPlugin()
19
+ t = Transport(name="default-http", type="http")
20
+ provider = Provider(name="docker", type="docker")
21
+ pl = Platform(name="main", type="docker", provider=provider, transport=t)
22
+ assert p.build_provision_nodes(t, pl) == []
23
+
24
+
25
+ def test_env_contract():
26
+ p = HttpTransportPlugin()
27
+ t = Transport(name="default-http", type="http")
28
+ env = p.generate_env_contract(t, context={})
29
+ assert env["VYSTAK_TRANSPORT_TYPE"] == "http"
30
+
31
+
32
+ def test_no_listener_code():
33
+ p = HttpTransportPlugin()
34
+ t = Transport(name="default-http", type="http")
35
+ assert p.generate_listener_code(t) is None
36
+
37
+
38
+ def test_resolve_address_for_docker_dns():
39
+ """resolve_address_for returns the Docker container name-matched URL."""
40
+ p = HttpTransportPlugin()
41
+ provider = Provider(name="docker", type="docker")
42
+ pl = Platform(name="main", type="docker", provider=provider, namespace="prod")
43
+ _openai = Provider(name="openai", type="openai", api_key_env="OPENAI_API_KEY")
44
+ model = Model(name="gpt-4o", model_name="gpt-4o", provider=_openai)
45
+ agent = Agent(name="my-agent", framework="langchain-python", default_model=model, port=9000)
46
+ url = p.resolve_address_for(agent, pl)
47
+ # Matches DockerAgentNode's container naming: `vystak-{agent_name}`.
48
+ assert url == "http://vystak-my-agent:9000/a2a"
49
+
50
+
51
+ def test_resolve_address_for_default_port():
52
+ """resolve_address_for uses port 8000 when agent.port is None."""
53
+ p = HttpTransportPlugin()
54
+ provider = Provider(name="docker", type="docker")
55
+ pl = Platform(name="main", type="docker", provider=provider, namespace="default")
56
+ _openai = Provider(name="openai", type="openai", api_key_env="OPENAI_API_KEY")
57
+ model = Model(name="gpt-4o", model_name="gpt-4o", provider=_openai)
58
+ agent = Agent(name="worker", framework="langchain-python", default_model=model)
59
+ url = p.resolve_address_for(agent, pl)
60
+ assert url == "http://vystak-worker:8000/a2a"
@@ -0,0 +1,219 @@
1
+ """Tests for HttpTransport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import asynccontextmanager
7
+
8
+ import pytest
9
+ import uvicorn
10
+ from fastapi import FastAPI, Request
11
+ from sse_starlette.sse import EventSourceResponse
12
+ from vystak.transport import (
13
+ A2AMessage,
14
+ AgentRef,
15
+ )
16
+ from vystak.transport.contract import TransportContract
17
+ from vystak_transport_http import HttpTransport
18
+
19
+
20
+ def _build_app(handler) -> FastAPI:
21
+ """Minimal FastAPI app exposing /a2a for the test agent.
22
+
23
+ ``handler`` is a ``ServerDispatcherProtocol`` (per Plan C), so A2A calls
24
+ route via ``dispatch_a2a`` / ``dispatch_a2a_stream``.
25
+ """
26
+ app = FastAPI()
27
+
28
+ @app.post("/a2a")
29
+ async def a2a_endpoint(request: Request):
30
+ body = await request.json()
31
+ params = body.get("params", {})
32
+ metadata = params.get("metadata", {})
33
+ msg_params = params.get("message", {})
34
+ message = A2AMessage(
35
+ role=msg_params.get("role", "user"),
36
+ parts=msg_params.get("parts", []),
37
+ # Phase 10: v0.3 spec puts the correlation/thread id on the
38
+ # message itself (`messageId`/`contextId`) rather than top-level
39
+ # `params.id`. Test fake reads either path for compatibility.
40
+ correlation_id=(
41
+ params.get("id")
42
+ or msg_params.get("messageId")
43
+ or metadata.get("correlation_id", "")
44
+ ),
45
+ metadata=metadata,
46
+ )
47
+
48
+ if body.get("method") == "message/stream":
49
+
50
+ async def gen():
51
+ async for ev in handler.dispatch_a2a_stream(message, metadata):
52
+ yield {"data": ev.model_dump_json()}
53
+
54
+ return EventSourceResponse(gen())
55
+
56
+ result = await handler.dispatch_a2a(message, metadata)
57
+ return {
58
+ "jsonrpc": "2.0",
59
+ "id": body.get("id"),
60
+ "result": {
61
+ "status": {"message": {"parts": [{"text": result.text}]}},
62
+ "correlation_id": result.correlation_id,
63
+ },
64
+ }
65
+
66
+ return app
67
+
68
+
69
+ @asynccontextmanager
70
+ async def _serve(app: FastAPI, port: int):
71
+ config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
72
+ server = uvicorn.Server(config)
73
+ task = asyncio.create_task(server.serve())
74
+ # Wait for the server to bind.
75
+ for _ in range(100):
76
+ if server.started:
77
+ break
78
+ await asyncio.sleep(0.01)
79
+ try:
80
+ yield
81
+ finally:
82
+ server.should_exit = True
83
+ await task
84
+
85
+
86
+ class TestHttpTransport(TransportContract):
87
+ """Runs the shared transport contract against HttpTransport."""
88
+
89
+ @pytest.fixture
90
+ def serve_agent(self, unused_tcp_port):
91
+ @asynccontextmanager
92
+ async def _ctx(canonical_name: str, handler):
93
+ app = _build_app(handler)
94
+ async with _serve(app, unused_tcp_port):
95
+ client = HttpTransport(
96
+ routes={canonical_name: f"http://127.0.0.1:{unused_tcp_port}/a2a"}
97
+ )
98
+ yield client
99
+
100
+ return _ctx
101
+
102
+
103
+ class TestHttpTransportBasics:
104
+ def test_type(self):
105
+ t = HttpTransport(routes={})
106
+ assert t.type == "http"
107
+ assert t.supports_streaming is True
108
+
109
+ def test_resolve_address_lookup(self):
110
+ t = HttpTransport(routes={"x.agents.default": "http://example:8000/a2a"})
111
+ assert t.resolve_address("x.agents.default") == "http://example:8000/a2a"
112
+
113
+ def test_resolve_address_unknown(self):
114
+ t = HttpTransport(routes={})
115
+ with pytest.raises(KeyError):
116
+ t.resolve_address("unknown.agents.default")
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_serve_is_noop(self):
120
+ t = HttpTransport(routes={})
121
+ # serve() returns immediately; the actual /a2a route is served by
122
+ # the generated agent's FastAPI app.
123
+ await t.serve("x.agents.default", handler=None)
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_create_response_posts_to_agent(self, unused_tcp_port):
127
+ """create_response POSTs the request body to the agent's /v1/responses."""
128
+ from fastapi import FastAPI
129
+ from fastapi.responses import JSONResponse
130
+
131
+ received: dict = {}
132
+ app = FastAPI()
133
+
134
+ @app.post("/v1/responses")
135
+ async def handler(request: Request): # Request imported at module level
136
+ received["body"] = await request.json()
137
+ return JSONResponse(
138
+ {
139
+ "id": "resp-1",
140
+ "object": "response",
141
+ "created_at": 1,
142
+ "model": "vystak/test",
143
+ "output": [{"type": "message", "content": "hi"}],
144
+ "status": "completed",
145
+ }
146
+ )
147
+
148
+ async with _serve(app, unused_tcp_port):
149
+ routes = {"test.agents.default": f"http://127.0.0.1:{unused_tcp_port}/a2a"}
150
+ t = HttpTransport(routes=routes)
151
+ ref = AgentRef(canonical_name="test.agents.default")
152
+ result = await t.create_response(
153
+ ref, {"model": "vystak/test", "input": "hello"}, {}, timeout=5
154
+ )
155
+ assert result["id"] == "resp-1"
156
+ assert received["body"]["input"] == "hello"
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_get_response_returns_none_on_404(self, unused_tcp_port):
160
+ from fastapi import FastAPI
161
+ from fastapi.responses import JSONResponse
162
+
163
+ app = FastAPI()
164
+
165
+ @app.get("/v1/responses/{response_id}")
166
+ async def handler(response_id: str):
167
+ if response_id == "missing":
168
+ return JSONResponse({"error": "not found"}, status_code=404)
169
+ return JSONResponse({"id": response_id, "object": "response"})
170
+
171
+ async with _serve(app, unused_tcp_port):
172
+ routes = {"test.agents.default": f"http://127.0.0.1:{unused_tcp_port}/a2a"}
173
+ t = HttpTransport(routes=routes)
174
+ ref = AgentRef(canonical_name="test.agents.default")
175
+ assert await t.get_response(ref, "missing", timeout=5) is None
176
+ got = await t.get_response(ref, "resp-1", timeout=5)
177
+ assert got["id"] == "resp-1"
178
+
179
+ def test_agent_base_url_strips_a2a(self):
180
+ routes = {"x.agents.default": "http://vystak-x:8000/a2a"}
181
+ t = HttpTransport(routes=routes)
182
+ ref = AgentRef(canonical_name="x.agents.default")
183
+ assert t._agent_base_url(ref) == "http://vystak-x:8000"
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_stream_task_skips_non_a2a_event_frames(self, unused_tcp_port):
187
+ """stream_task must silently skip SSE frames whose JSON does not
188
+ match the A2AEvent shape (e.g., legacy JSON-RPC envelope frames
189
+ from the LangChain adapter), rather than aborting the stream."""
190
+ app = FastAPI()
191
+
192
+ @app.post("/a2a")
193
+ async def a2a_endpoint(request: Request):
194
+ body = await request.json()
195
+ assert body.get("method") == "message/stream"
196
+
197
+ async def gen():
198
+ # Frame 1: valid A2AEvent (bare).
199
+ yield {"data": '{"type": "tool_call", "data": {"tool_name": "x"}}'}
200
+ # Frame 2: not an A2AEvent (no `type` field) -> ValidationError.
201
+ yield {"data": '{"jsonrpc": "2.0", "id": "n", "result": {}}'}
202
+ # Frame 3: valid A2AEvent (bare).
203
+ yield {"data": '{"type": "final", "text": "done", "final": true}'}
204
+
205
+ return EventSourceResponse(gen())
206
+
207
+ async with _serve(app, unused_tcp_port):
208
+ routes = {"test.agents.default": f"http://127.0.0.1:{unused_tcp_port}/a2a"}
209
+ t = HttpTransport(routes=routes)
210
+ ref = AgentRef(canonical_name="test.agents.default")
211
+ msg = A2AMessage(role="user", parts=[{"text": "x"}], metadata={})
212
+
213
+ received = []
214
+ async for ev in t.stream_task(ref, msg, {}, timeout=5):
215
+ received.append(ev)
216
+
217
+ assert len(received) == 2
218
+ assert received[0].type == "tool_call"
219
+ assert received[1].type == "final"