From 6500a4b5d85581e1d1e46323f74c2fbcdbf39c44 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Thu, 7 May 2026 10:48:36 -0700 Subject: [PATCH] test(v2 managed agents): adapter tests for model object split, stateful streaming, permission auto-grant send_message: - assert {providerID, modelID} object body (not string) - 'openrouter/anthropic/claude-3' splits on first '/' only - models without '/' omit the field entirely - model=None omits the field list_messages: assert {info, parts} envelopes normalize to MessageRow, with reasoning/step-start/step-finish filtered out of content and the combined providerID/modelID showing up as our model string. stream_events: assert text deltas route through partID -> type tracking, the partID map persists across multiple message.part.updated events, session.idle is enriched with the tracked assistant message_id, and permission.asked triggers a fire-and-forget POST to /session/:sid/permissions/:per_id with {response:'once'}. --- .../managed_agents/adapters/test_opencode.py | 487 ++++++++++++++---- 1 file changed, 400 insertions(+), 87 deletions(-) diff --git a/tests/test_litellm/managed_agents/adapters/test_opencode.py b/tests/test_litellm/managed_agents/adapters/test_opencode.py index 8a77bd617d4..d763824d70c 100644 --- a/tests/test_litellm/managed_agents/adapters/test_opencode.py +++ b/tests/test_litellm/managed_agents/adapters/test_opencode.py @@ -1,22 +1,33 @@ """Unit tests for the opencode sandbox adapter. -Covers `litellm/managed_agents/adapters/opencode.py` per the v2 contract §7 -(`.claude/v2_api_contract.md`): +Covers `litellm/managed_agents/adapters/opencode.py` per the v2 contract +§7 (`.claude/v2_api_contract.md`) and against the REAL opencode 1.14.41 +wire shapes documented in `.claude/v2_opencode_real_responses.md`. +Coverage: - send_message: POST /session//prompt_async with - body {parts: [{type:"text", text: content}], model}; 204 -> success; + body ``{parts:[{type:"text",text:content}], model:{providerID,modelID}}``; + string-form ``"/"`` is split into the object form; + ``model`` is omitted when it has no ``/`` separator; 204 -> success; ConnectError -> SandboxUnreachableError; returns None. - list_messages: GET /session//message; normalize - each entry into MessageRow; empty -> []. - - stream_events: yields ("connected", {...}) first, then translated - opencode events; drops events for other opencode_session_ids; + each ``{info, parts}`` envelope into MessageRow; empty -> []. + - stream_events: yields ``("connected", ...)`` first; tracks + ``partID -> type`` across events so ``message.part.delta`` for text + is yielded as ``message.text.delta``; routes + ``message.part.updated`` for tools to ``message.tool.started`` / + ``.completed``; tracks the in-flight assistant ``message_id`` so + ``session.idle`` (which carries no messageID in real opencode) is + enriched before being yielded; auto-grants permission on + ``permission.asked``; drops events for other opencode_session_ids; ConnectError -> SandboxUnreachableError. - delete: DELETE /session/; errors swallowed. -HTTP mocking uses `respx` (already a dev dep). Tests are async and rely on -the repo's `asyncio_mode = "auto"` setting. +HTTP mocking uses ``respx`` (already a dev dep). Tests are async and rely +on the repo's ``asyncio_mode = "auto"`` setting. """ +import asyncio import json from typing import Any, Dict, List @@ -24,11 +35,7 @@ import httpx import pytest import respx -# The adapter module is being built in parallel by the adapter agent. -# Skip cleanly if it isn't here yet so the rest of the test suite still runs. -opencode_module = pytest.importorskip( - "litellm.managed_agents.adapters.opencode" -) +opencode_module = pytest.importorskip("litellm.managed_agents.adapters.opencode") adapter_base = pytest.importorskip("litellm.managed_agents.adapters.base") types_module = pytest.importorskip("litellm.managed_agents.types") @@ -38,22 +45,25 @@ MessageRow = types_module.MessageRow SANDBOX_URL = "http://127.0.0.1:1234" -OC_SID = "oc_sid_xxx" +OC_SID = "ses_oc_xxx" OUR_SID = "ses_test" +_TS_CREATED_MS = 1778172682972 +_TS_COMPLETED_MS = 1778172689905 + def _make_adapter() -> Any: """Build a fresh adapter instance. - Adapters are stateless per the protocol docstring, so no setup is needed. - The builder exists so a future change in constructor signature only has - to be updated in one place. + Adapters are stateless across calls per the protocol docstring, so + no setup is needed. The builder exists so a future change in + constructor signature only has to be updated in one place. """ return OpencodeAdapter() # --------------------------------------------------------------------------- -# send_message +# send_message — model object split # --------------------------------------------------------------------------- @@ -61,13 +71,15 @@ class TestSendMessage: """POST /session//prompt_async.""" @pytest.mark.asyncio - async def test_posts_correct_url_and_body(self) -> None: + async def test_posts_correct_url_and_splits_model_into_provider_object( + self, + ) -> None: adapter = _make_adapter() with respx.mock(assert_all_called=True) as mock: - route = mock.post( - f"{SANDBOX_URL}/session/{OC_SID}/prompt_async" - ).mock(return_value=httpx.Response(204)) + route = mock.post(f"{SANDBOX_URL}/session/{OC_SID}/prompt_async").mock( + return_value=httpx.Response(204) + ) result = await adapter.send_message( sandbox_url=SANDBOX_URL, @@ -78,31 +90,93 @@ class TestSendMessage: assert result is None assert route.called - assert route.call_count == 1 request = route.calls[0].request - assert request.method == "POST" - assert ( - str(request.url) - == f"{SANDBOX_URL}/session/{OC_SID}/prompt_async" - ) - body = json.loads(request.content.decode("utf-8")) + # Real opencode rejects string-form ``model`` — must be object. assert body == { "parts": [{"type": "text", "text": "Hello, who are you?"}], - "model": "anthropic/claude-opus-4", + "model": { + "providerID": "anthropic", + "modelID": "claude-opus-4", + }, } + @pytest.mark.asyncio + async def test_model_with_extra_slashes_keeps_modelid_intact(self) -> None: + """Some model identifiers contain ``/`` (e.g. + ``"openrouter/anthropic/claude-3"``). We split on the first slash + only so the rest stays as ``modelID``.""" + adapter = _make_adapter() + + with respx.mock(assert_all_called=True) as mock: + route = mock.post(f"{SANDBOX_URL}/session/{OC_SID}/prompt_async").mock( + return_value=httpx.Response(204) + ) + + await adapter.send_message( + sandbox_url=SANDBOX_URL, + opencode_session_id=OC_SID, + content="hi", + model="openrouter/anthropic/claude-3", + ) + + body = json.loads(route.calls[0].request.content.decode("utf-8")) + assert body["model"] == { + "providerID": "openrouter", + "modelID": "anthropic/claude-3", + } + + @pytest.mark.asyncio + async def test_model_without_slash_is_omitted(self) -> None: + """opencode treats a missing ``model`` field as "use default" — + we omit the field entirely instead of guessing a providerID.""" + adapter = _make_adapter() + + with respx.mock(assert_all_called=True) as mock: + route = mock.post(f"{SANDBOX_URL}/session/{OC_SID}/prompt_async").mock( + return_value=httpx.Response(204) + ) + + await adapter.send_message( + sandbox_url=SANDBOX_URL, + opencode_session_id=OC_SID, + content="hi", + model="just-a-name", + ) + + body = json.loads(route.calls[0].request.content.decode("utf-8")) + assert "model" not in body + assert body == {"parts": [{"type": "text", "text": "hi"}]} + + @pytest.mark.asyncio + async def test_model_none_is_omitted(self) -> None: + adapter = _make_adapter() + + with respx.mock(assert_all_called=True) as mock: + route = mock.post(f"{SANDBOX_URL}/session/{OC_SID}/prompt_async").mock( + return_value=httpx.Response(204) + ) + + await adapter.send_message( + sandbox_url=SANDBOX_URL, + opencode_session_id=OC_SID, + content="hi", + model=None, + ) + + body = json.loads(route.calls[0].request.content.decode("utf-8")) + assert "model" not in body + @pytest.mark.asyncio async def test_204_is_success(self) -> None: """opencode returns 204 No Content on prompt_async success.""" adapter = _make_adapter() with respx.mock as mock: - mock.post( - f"{SANDBOX_URL}/session/{OC_SID}/prompt_async" - ).mock(return_value=httpx.Response(204)) + mock.post(f"{SANDBOX_URL}/session/{OC_SID}/prompt_async").mock( + return_value=httpx.Response(204) + ) - # Should NOT raise. send_message returns None on success. result = await adapter.send_message( sandbox_url=SANDBOX_URL, opencode_session_id=OC_SID, @@ -117,9 +191,9 @@ class TestSendMessage: adapter = _make_adapter() with respx.mock as mock: - mock.post( - f"{SANDBOX_URL}/session/{OC_SID}/prompt_async" - ).mock(side_effect=httpx.ConnectError("connection refused")) + mock.post(f"{SANDBOX_URL}/session/{OC_SID}/prompt_async").mock( + side_effect=httpx.ConnectError("connection refused") + ) with pytest.raises(SandboxUnreachableError): await adapter.send_message( @@ -131,7 +205,7 @@ class TestSendMessage: # --------------------------------------------------------------------------- -# list_messages +# list_messages — {info, parts} envelopes # --------------------------------------------------------------------------- @@ -139,33 +213,57 @@ class TestListMessages: """GET /session//message.""" @pytest.mark.asyncio - async def test_normalizes_each_message_and_returns_message_rows( + async def test_normalizes_real_shape_messages_into_message_rows( self, ) -> None: adapter = _make_adapter() + # Real opencode response shape: bare array of {info, parts} envelopes. opencode_payload: List[Dict[str, Any]] = [ { - "id": "msg_oc_1", - "sessionID": OC_SID, - "role": "user", - "parts": [{"type": "text", "text": "refactor src/auth.py"}], - "completedAt": "2026-05-07T15:04:05.123Z", + "info": { + "id": "msg_oc_1", + "sessionID": OC_SID, + "role": "user", + "time": {"created": _TS_CREATED_MS}, + }, + "parts": [ + { + "type": "text", + "text": "refactor src/auth.py", + "id": "prt_1", + } + ], }, { - "id": "msg_oc_2", - "sessionID": OC_SID, - "role": "assistant", + "info": { + "id": "msg_oc_2", + "sessionID": OC_SID, + "role": "assistant", + "providerID": "opencode", + "modelID": "minimax-m2.5-free", + "time": { + "created": _TS_CREATED_MS, + "completed": _TS_COMPLETED_MS, + }, + "finish": "stop", + }, "parts": [ + {"type": "step-start", "snapshot": "..."}, + {"type": "reasoning", "text": "thinking..."}, {"type": "text", "text": "I'll start by reading."}, { "type": "tool", - "name": "read", - "input": {"path": "src/auth.py"}, - "output": "...", + "tool": "read", + "callID": "call_xxx", + "state": { + "status": "completed", + "input": {"filePath": "src/auth.py"}, + "output": "...", + }, }, + {"type": "step-finish", "reason": "stop"}, ], - "completedAt": "2026-05-07T15:04:06.000Z", }, ] @@ -196,14 +294,16 @@ class TestListMessages: assert rows[1].id == "msg_oc_2" assert rows[1].role == "assistant" + # step-start, reasoning, step-finish are filtered out of content. assert rows[1].content == "I'll start by reading." assert rows[1].tools == [ { "name": "read", - "input": {"path": "src/auth.py"}, + "input": {"filePath": "src/auth.py"}, "output": "...", } ] + assert rows[1].model == "opencode/minimax-m2.5-free" @pytest.mark.asyncio async def test_empty_array_returns_empty_list(self) -> None: @@ -231,10 +331,8 @@ class TestListMessages: def _sse_payload(events: List[Dict[str, Any]]) -> bytes: """Build a valid SSE response body from event dicts. - opencode emits events on `/event` formatted as standard - SSE: each event is a `data: \n\n` chunk. The adapter is expected - to decode each `data:` line into a dict and pass it to - `normalize_opencode_event` after filtering by `sessionID`. + opencode emits events on ``/event`` formatted as standard + SSE: each event is a ``data: \\n\\n`` chunk. """ chunks = [] for ev in events: @@ -250,34 +348,58 @@ class TestStreamEvents: adapter = _make_adapter() events_on_wire = [ + # 1. New assistant message (no completed yet). { "type": "message.updated", - "sessionID": OC_SID, - "properties": { - "info": {"id": "msg_a4b5c6", "role": "assistant"}, - "sessionID": OC_SID, - }, - }, - { - "type": "message.part.updated", - "sessionID": OC_SID, "properties": { "sessionID": OC_SID, - "part": { - "type": "text", - "messageID": "msg_a4b5c6", - "text": "Hello", + "info": { + "id": "msg_a4b5c6", + "sessionID": OC_SID, + "role": "assistant", + "time": {"created": _TS_CREATED_MS}, }, }, }, + # 2. Text part appears (registers partID -> "text"). { - "type": "session.idle", - "sessionID": OC_SID, + "type": "message.part.updated", + "properties": { + "sessionID": OC_SID, + "part": { + "id": "prt_text_1", + "type": "text", + "messageID": "msg_a4b5c6", + "sessionID": OC_SID, + "text": "", + }, + }, + }, + # 3. Streaming text delta — must route via partID lookup. + { + "type": "message.part.delta", "properties": { "sessionID": OC_SID, "messageID": "msg_a4b5c6", - "content": "Hello", - "completedAt": "2026-05-07T15:04:05.123Z", + "partID": "prt_text_1", + "field": "text", + "delta": "Hello", + }, + }, + # 4. Per-message completion via message.updated with time.completed. + { + "type": "message.updated", + "properties": { + "sessionID": OC_SID, + "info": { + "id": "msg_a4b5c6", + "sessionID": OC_SID, + "role": "assistant", + "time": { + "created": _TS_CREATED_MS, + "completed": _TS_COMPLETED_MS, + }, + }, }, }, ] @@ -305,34 +427,173 @@ class TestStreamEvents: assert first_type == "connected" assert first_data.get("session_id") == OUR_SID - # Subsequent translated events should appear in order. translated_types = [t for t, _ in collected[1:]] assert "message.started" in translated_types assert "message.text.delta" in translated_types assert "message.completed" in translated_types + # Verify the text delta carried the right content. + text_deltas = [d for t, d in collected if t == "message.text.delta"] + assert len(text_deltas) == 1 + assert text_deltas[0]["delta"] == "Hello" + assert text_deltas[0]["message_id"] == "msg_a4b5c6" + @pytest.mark.asyncio - async def test_drops_events_for_other_opencode_sessions(self) -> None: + async def test_part_types_map_persists_across_events(self) -> None: + """A delta arriving AFTER a tool ``message.part.updated`` must + still find the ``text`` part registered earlier in the stream.""" adapter = _make_adapter() - other_sid = "oc_sid_OTHER" events_on_wire = [ - # Belongs to a different opencode session — must be filtered out. + # Register text part. { - "type": "message.updated", - "sessionID": other_sid, + "type": "message.part.updated", "properties": { - "info": {"id": "msg_other", "role": "assistant"}, - "sessionID": other_sid, + "sessionID": OC_SID, + "part": { + "id": "prt_text_1", + "type": "text", + "messageID": "msg_a4b5c6", + "sessionID": OC_SID, + "text": "", + }, }, }, - # Belongs to our session — should be yielded. + # Register tool part (different partID). { - "type": "message.updated", - "sessionID": OC_SID, + "type": "message.part.updated", "properties": { - "info": {"id": "msg_ours", "role": "assistant"}, "sessionID": OC_SID, + "part": { + "id": "prt_tool_1", + "type": "tool", + "messageID": "msg_a4b5c6", + "sessionID": OC_SID, + "tool": "read", + "callID": "call_xxx", + "state": { + "status": "running", + "input": {"filePath": "src/auth.py"}, + }, + }, + }, + }, + # Text delta should still route via the original partID. + { + "type": "message.part.delta", + "properties": { + "sessionID": OC_SID, + "messageID": "msg_a4b5c6", + "partID": "prt_text_1", + "field": "text", + "delta": "still text", + }, + }, + ] + + with respx.mock as mock: + mock.get(f"{SANDBOX_URL}/event").mock( + return_value=httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_sse_payload(events_on_wire), + ) + ) + + collected: List[Any] = [] + async for ev in adapter.stream_events( + sandbox_url=SANDBOX_URL, + opencode_session_id=OC_SID, + our_session_id=OUR_SID, + ): + collected.append(ev) + + text_deltas = [d for t, d in collected if t == "message.text.delta"] + assert len(text_deltas) == 1 + assert text_deltas[0]["delta"] == "still text" + + tool_starts = [d for t, d in collected if t == "message.tool.started"] + assert len(tool_starts) == 1 + assert tool_starts[0]["tool"] == "read" + + @pytest.mark.asyncio + async def test_session_idle_is_enriched_with_tracked_message_id( + self, + ) -> None: + """Real opencode ``session.idle`` payload only carries + ``sessionID``. The adapter must inject the most recent assistant + ``message_id`` from prior ``message.updated`` events.""" + adapter = _make_adapter() + + events_on_wire = [ + { + "type": "message.updated", + "properties": { + "sessionID": OC_SID, + "info": { + "id": "msg_tracked", + "sessionID": OC_SID, + "role": "assistant", + "time": {"created": _TS_CREATED_MS}, + }, + }, + }, + { + "type": "session.idle", + "properties": {"sessionID": OC_SID}, + }, + ] + + with respx.mock as mock: + mock.get(f"{SANDBOX_URL}/event").mock( + return_value=httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_sse_payload(events_on_wire), + ) + ) + + collected: List[Any] = [] + async for ev in adapter.stream_events( + sandbox_url=SANDBOX_URL, + opencode_session_id=OC_SID, + our_session_id=OUR_SID, + ): + collected.append(ev) + + completes = [d for t, d in collected if t == "message.completed"] + # `session.idle` produces a ``message.completed`` enriched + # with the tracked ``message_id``. + assert any(d.get("message_id") == "msg_tracked" for d in completes) + + @pytest.mark.asyncio + async def test_drops_events_for_other_opencode_sessions(self) -> None: + adapter = _make_adapter() + + other_sid = "ses_oc_OTHER" + events_on_wire = [ + { + "type": "message.updated", + "properties": { + "sessionID": other_sid, + "info": { + "id": "msg_other", + "sessionID": other_sid, + "role": "assistant", + "time": {"created": _TS_CREATED_MS}, + }, + }, + }, + { + "type": "message.updated", + "properties": { + "sessionID": OC_SID, + "info": { + "id": "msg_ours", + "sessionID": OC_SID, + "role": "assistant", + "time": {"created": _TS_CREATED_MS}, + }, }, }, ] @@ -354,8 +615,6 @@ class TestStreamEvents: ): collected.append(ev) - # We expect: [connected, message.started for msg_ours]. The - # `msg_other` event must NOT appear. translated = [data for t, data in collected if t == "message.started"] assert len(translated) == 1 assert translated[0].get("message_id") == "msg_ours" @@ -363,6 +622,60 @@ class TestStreamEvents: for _, data in collected: assert data.get("message_id") != "msg_other" + @pytest.mark.asyncio + async def test_permission_asked_triggers_auto_grant(self) -> None: + """``permission.asked`` events fire a fire-and-forget POST to + ``/session/:sid/permissions/:per_id`` body + ``{"response":"once"}`` so tool calls don't hang.""" + adapter = _make_adapter() + + permission_id = "per_xxx" + events_on_wire = [ + { + "type": "permission.asked", + "properties": { + "id": permission_id, + "sessionID": OC_SID, + "permission": "external_directory", + "patterns": ["/etc/*"], + "metadata": {"filepath": "/etc/hostname"}, + "tool": {"messageID": "msg_a4b5c6", "callID": "call_xxx"}, + }, + }, + ] + + with respx.mock(assert_all_called=True) as mock: + mock.get(f"{SANDBOX_URL}/event").mock( + return_value=httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_sse_payload(events_on_wire), + ) + ) + grant_route = mock.post( + f"{SANDBOX_URL}/session/{OC_SID}/permissions/{permission_id}" + ).mock(return_value=httpx.Response(200, json=True)) + + async for _ in adapter.stream_events( + sandbox_url=SANDBOX_URL, + opencode_session_id=OC_SID, + our_session_id=OUR_SID, + ): + pass + + # Auto-grant fires fire-and-forget via asyncio.create_task — + # give the loop a chance to settle outstanding tasks. + for _ in range(5): + if grant_route.called: + break + await asyncio.sleep(0.01) + + assert grant_route.called + grant_body = json.loads( + grant_route.calls[0].request.content.decode("utf-8") + ) + assert grant_body == {"response": "once"} + @pytest.mark.asyncio async def test_connect_error_raises_sandbox_unreachable(self) -> None: adapter = _make_adapter()