From 32f5ea80bf88a7583c79f0ddeeededc267230cec Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Thu, 2 Apr 2026 08:33:00 -0400 Subject: [PATCH] docs: clarify chat output mode contract --- docs/guide.md | 3 +++ src/opencode_a2a/server/agent_card.py | 8 +++++-- tests/server/test_agent_card.py | 1 + tests/server/test_app_behaviors.py | 34 +++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index 0a2361e..1a5925e 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -172,6 +172,9 @@ If one deployment works while another fails against the same upstream provider, - The service forwards A2A `message:send` to OpenCode session/message calls. - Main chat requests may override the upstream model for one request through `metadata.shared.model`. - Provider/model catalog discovery is available through `opencode.providers.list` and `opencode.models.list`. +- Main chat requests that explicitly send `configuration.acceptedOutputModes` must stay compatible with the declared chat output modes. +- Current main chat requests must continue accepting `text/plain`; requests that only accept `application/json` or other incompatible modes are rejected before execution starts. +- `application/json` is additive structured-output support for incremental `tool_call` payloads. It does not guarantee that ordinary assistant prose can always be losslessly represented as JSON, so consumers that expect normal chat text should keep accepting `text/plain`. - Main chat input supports structured A2A `parts` passthrough: - `TextPart` is forwarded as an OpenCode text part. - `FilePart(FileWithBytes)` is forwarded as a `file` part with a `data:` URL. diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index 26cfd65..824988d 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -383,7 +383,9 @@ def _build_agent_skills( name="OpenCode Chat", description=( "Handle core A2A chat turns with shared session binding and optional " - "request-scoped model selection." + "request-scoped model selection. Chat clients should continue accepting " + "text/plain responses; application/json is additive structured-output " + "support." ), input_modes=list(_CHAT_INPUT_MODES), output_modes=list(_CHAT_OUTPUT_MODES), @@ -453,7 +455,9 @@ def _build_agent_skills( description=( "Handle core A2A message/send and message/stream requests by routing " "TextPart and FilePart inputs to OpenCode sessions with shared session " - "binding and optional request-scoped model selection." + "binding and optional request-scoped model selection. Chat clients " + "should continue accepting text/plain responses; application/json is " + "additive structured-output support." ), input_modes=list(_CHAT_INPUT_MODES), output_modes=list(_CHAT_OUTPUT_MODES), diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index cb0e773..78c8f73 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -652,6 +652,7 @@ def test_agent_card_chat_examples_include_project_hint_when_configured() -> None chat_skill = next(skill for skill in card.skills if skill.id == "opencode.chat") assert chat_skill.examples is None assert "shared session binding" in chat_skill.description + assert "text/plain responses" in chat_skill.description assert "core-a2a" in chat_skill.tags assert "portable" in chat_skill.tags diff --git a/tests/server/test_app_behaviors.py b/tests/server/test_app_behaviors.py index 6726707..1ce3852 100644 --- a/tests/server/test_app_behaviors.py +++ b/tests/server/test_app_behaviors.py @@ -734,6 +734,40 @@ async def _setup_message_execution(self, params, context=None): # noqa: ANN001 assert isinstance(exc_info.value.error, UnsupportedOperationError) assert "require text/plain" in exc_info.value.error.message + assert exc_info.value.error.data == { + "accepted_output_modes": ["application/json"], + "required_output_modes": ["text/plain"], + "supported_output_modes": ["text/plain", "application/json"], + } + assert handler.setup_called is False + + +@pytest.mark.asyncio +async def test_on_message_send_stream_rejects_incompatible_output_modes_before_execution() -> None: + class _Handler(OpencodeRequestHandler): + def __init__(self) -> None: + super().__init__(agent_executor=MagicMock(), task_store=MagicMock()) + self.setup_called = False + + async def _setup_message_execution(self, params, context=None): # noqa: ANN001 + del params, context + self.setup_called = True + raise AssertionError("_setup_message_execution should not be called") + + handler = _Handler() + params = types.SimpleNamespace( + configuration=types.SimpleNamespace(accepted_output_modes=["image/png"]) + ) + + with pytest.raises(ServerError) as exc_info: + await handler.on_message_send_stream(params).__anext__() + + assert isinstance(exc_info.value.error, UnsupportedOperationError) + assert "not compatible" in exc_info.value.error.message + assert exc_info.value.error.data == { + "accepted_output_modes": ["image/png"], + "supported_output_modes": ["text/plain", "application/json"], + } assert handler.setup_called is False