diff --git a/docs/extension-specifications.md b/docs/extension-specifications.md index 1100ced..33c73d4 100644 --- a/docs/extension-specifications.md +++ b/docs/extension-specifications.md @@ -1,6 +1,6 @@ # Extension Specifications -This document is the stable specification index for the shared and provider-private extension URIs published by `opencode-a2a`. It is intentionally a compact URI/spec map, not the main consumer guide. For runtime behavior, request examples, and operational setup, see [`guide.md`](./guide.md). For compatibility promises and stability expectations, see [`compatibility.md`](./compatibility.md). +This document is the stable specification index for extension URIs published by `opencode-a2a`. It is intentionally a compact URI/spec map, not the main consumer guide. For runtime behavior, request examples, and operational setup, see [`guide.md`](./guide.md). For compatibility promises and stability expectations, see [`compatibility.md`](./compatibility.md). ## Discovery Surface Note @@ -15,6 +15,7 @@ Provider-private contract note: - `opencode.*` methods in this repository are deployment-specific provider extensions, not portable A2A baseline capabilities. - Shared `metadata.shared.*` contracts are intended to remain low-risk and transportable. - Compatibility and wire-contract URIs are descriptive metadata contracts, not activatable runtime capabilities. +- URI path segments identify the contract, not its auth or disclosure tier; public-vs-extended disclosure is controlled by Agent Card and OpenAPI surfaces. ## SDK and Discovery Compatibility @@ -26,20 +27,20 @@ Provider-private contract note: | Extension | Scope | Disclosure | URI | Section | | --- | --- | --- | --- | --- | -| Shared Session Binding v1 | Shared request metadata | Public + extended | `urn:opencode-a2a:extension:shared:session-binding:v1` | [Shared Session Binding v1](#shared-session-binding-v1) | -| Shared Model Selection v1 | Shared request metadata | Public + extended | `urn:opencode-a2a:extension:shared:model-selection:v1` | [Shared Model Selection v1](#shared-model-selection-v1) | -| Shared Stream Hints v1 | Shared response/stream metadata | Public + extended | `urn:opencode-a2a:extension:shared:stream-hints:v1` | [Shared Stream Hints v1](#shared-stream-hints-v1) | -| Shared Interactive Interrupt v1 | Shared JSON-RPC callback methods | Public + extended | `urn:opencode-a2a:extension:shared:interactive-interrupt:v1` | [Shared Interactive Interrupt v1](#shared-interactive-interrupt-v1) | -| OpenCode Session Management v1 | Provider-private JSON-RPC methods | Extended only | `urn:opencode-a2a:extension:private:session-management:v1` | [OpenCode Session Management v1](#opencode-session-management-v1) | -| OpenCode Provider Discovery v1 | Provider-private JSON-RPC methods | Extended only | `urn:opencode-a2a:extension:private:provider-discovery:v1` | [OpenCode Provider Discovery v1](#opencode-provider-discovery-v1) | -| OpenCode Workspace Control v1 | Provider-private JSON-RPC methods | Extended only | `urn:opencode-a2a:extension:private:workspace-control:v1` | [OpenCode Workspace Control v1](#opencode-workspace-control-v1) | -| OpenCode Interrupt Recovery v1 | Provider-private JSON-RPC methods | Extended only | `urn:opencode-a2a:extension:private:interrupt-recovery:v1` | [OpenCode Interrupt Recovery v1](#opencode-interrupt-recovery-v1) | -| A2A Compatibility Profile v1 | Authenticated discovery metadata | Extended only | `urn:opencode-a2a:extension:private:compatibility-profile:v1` | [A2A Compatibility Profile v1](#a2a-compatibility-profile-v1) | -| A2A Wire Contract v1 | Authenticated discovery metadata | Extended only | `urn:opencode-a2a:extension:private:wire-contract:v1` | [A2A Wire Contract v1](#a2a-wire-contract-v1) | +| Shared Session Binding v1 | Shared request metadata | Public + extended | `urn:opencode-a2a:extension:session-binding:v1` | [Shared Session Binding v1](#shared-session-binding-v1) | +| Shared Model Selection v1 | Shared request metadata | Public + extended | `urn:opencode-a2a:extension:model-selection:v1` | [Shared Model Selection v1](#shared-model-selection-v1) | +| Shared Stream Hints v1 | Shared response/stream metadata | Public + extended | `urn:opencode-a2a:extension:stream-hints:v1` | [Shared Stream Hints v1](#shared-stream-hints-v1) | +| Shared Interactive Interrupt v1 | Shared JSON-RPC callback methods | Public + extended | `urn:opencode-a2a:extension:interactive-interrupt:v1` | [Shared Interactive Interrupt v1](#shared-interactive-interrupt-v1) | +| OpenCode Session Management v1 | Provider-private JSON-RPC methods | Extended only | `urn:opencode-a2a:extension:session-management:v1` | [OpenCode Session Management v1](#opencode-session-management-v1) | +| OpenCode Provider Discovery v1 | Provider-private JSON-RPC methods | Extended only | `urn:opencode-a2a:extension:provider-discovery:v1` | [OpenCode Provider Discovery v1](#opencode-provider-discovery-v1) | +| OpenCode Workspace Control v1 | Provider-private JSON-RPC methods | Extended only | `urn:opencode-a2a:extension:workspace-control:v1` | [OpenCode Workspace Control v1](#opencode-workspace-control-v1) | +| OpenCode Interrupt Recovery v1 | Provider-private JSON-RPC methods | Extended only | `urn:opencode-a2a:extension:interrupt-recovery:v1` | [OpenCode Interrupt Recovery v1](#opencode-interrupt-recovery-v1) | +| A2A Compatibility Profile v1 | Authenticated discovery metadata | Extended only | `urn:opencode-a2a:extension:compatibility-profile:v1` | [A2A Compatibility Profile v1](#a2a-compatibility-profile-v1) | +| A2A Wire Contract v1 | Authenticated discovery metadata | Extended only | `urn:opencode-a2a:extension:wire-contract:v1` | [A2A Wire Contract v1](#a2a-wire-contract-v1) | ## Shared Session Binding v1 -Extension URI: `urn:opencode-a2a:extension:shared:session-binding:v1` +Extension URI: `urn:opencode-a2a:extension:session-binding:v1` - Scope: shared A2A request metadata for rebinding to an existing upstream OpenCode session, plus negotiated response/task session metadata - Disclosure: public Agent Card and authenticated extended Agent Card @@ -51,7 +52,7 @@ Extension URI: `urn:opencode-a2a:extension:shared:session-binding:v1` ## Shared Model Selection v1 -Extension URI: `urn:opencode-a2a:extension:shared:model-selection:v1` +Extension URI: `urn:opencode-a2a:extension:model-selection:v1` - Scope: shared request-scoped model override for the main chat path - Disclosure: public Agent Card and authenticated extended Agent Card @@ -64,7 +65,7 @@ Extension URI: `urn:opencode-a2a:extension:shared:model-selection:v1` ## Shared Stream Hints v1 -Extension URI: `urn:opencode-a2a:extension:shared:stream-hints:v1` +Extension URI: `urn:opencode-a2a:extension:stream-hints:v1` - Scope: shared response/task/stream metadata for block identity, progress, and usage hints - Disclosure: public Agent Card and authenticated extended Agent Card @@ -84,7 +85,7 @@ Extension URI: `urn:opencode-a2a:extension:shared:stream-hints:v1` ## Shared Interactive Interrupt v1 -Extension URI: `urn:opencode-a2a:extension:shared:interactive-interrupt:v1` +Extension URI: `urn:opencode-a2a:extension:interactive-interrupt:v1` - Scope: shared JSON-RPC callback methods used to answer interactive permission and question interrupts - Disclosure: public Agent Card and authenticated extended Agent Card @@ -97,7 +98,7 @@ Extension URI: `urn:opencode-a2a:extension:shared:interactive-interrupt:v1` ## OpenCode Session Management v1 -Extension URI: `urn:opencode-a2a:extension:private:session-management:v1` +Extension URI: `urn:opencode-a2a:extension:session-management:v1` - Scope: OpenCode session read, mutation, and control methods exposed as A2A JSON-RPC extension methods - Disclosure: authenticated extended Agent Card only @@ -109,7 +110,7 @@ Extension URI: `urn:opencode-a2a:extension:private:session-management:v1` ## OpenCode Provider Discovery v1 -Extension URI: `urn:opencode-a2a:extension:private:provider-discovery:v1` +Extension URI: `urn:opencode-a2a:extension:provider-discovery:v1` - Scope: OpenCode provider and model discovery methods exposed as A2A JSON-RPC extension methods - Disclosure: authenticated extended Agent Card only @@ -121,7 +122,7 @@ Extension URI: `urn:opencode-a2a:extension:private:provider-discovery:v1` ## OpenCode Workspace Control v1 -Extension URI: `urn:opencode-a2a:extension:private:workspace-control:v1` +Extension URI: `urn:opencode-a2a:extension:workspace-control:v1` - Scope: OpenCode project, workspace, and worktree discovery/control methods exposed as A2A JSON-RPC extension methods - Disclosure: authenticated extended Agent Card only @@ -133,7 +134,7 @@ Extension URI: `urn:opencode-a2a:extension:private:workspace-control:v1` ## OpenCode Interrupt Recovery v1 -Extension URI: `urn:opencode-a2a:extension:private:interrupt-recovery:v1` +Extension URI: `urn:opencode-a2a:extension:interrupt-recovery:v1` - Scope: local interrupt recovery methods exposed as A2A JSON-RPC extension methods - Disclosure: authenticated extended Agent Card only @@ -145,7 +146,7 @@ Extension URI: `urn:opencode-a2a:extension:private:interrupt-recovery:v1` ## A2A Compatibility Profile v1 -Extension URI: `urn:opencode-a2a:extension:private:compatibility-profile:v1` +Extension URI: `urn:opencode-a2a:extension:compatibility-profile:v1` - Scope: authenticated discovery metadata describing protocol support, extension retention, and stable service behaviors - Disclosure: authenticated extended Agent Card only @@ -157,7 +158,7 @@ Extension URI: `urn:opencode-a2a:extension:private:compatibility-profile:v1` ## A2A Wire Contract v1 -Extension URI: `urn:opencode-a2a:extension:private:wire-contract:v1` +Extension URI: `urn:opencode-a2a:extension:wire-contract:v1` - Scope: authenticated discovery metadata describing supported methods, HTTP endpoints, extension URIs, and unified error semantics - Disclosure: authenticated extended Agent Card only diff --git a/docs/guide.md b/docs/guide.md index cd17576..2177f57 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -241,12 +241,14 @@ If one deployment works while another fails against the same upstream provider, - When a request restricts `acceptedOutputModes`, the stream applies the same output filtering before persistence so later task snapshots do not re-expose filtered structured blocks. - Persistence is canonicalized separately from transport: stream subscribers still receive incremental artifact updates, while task-store persistence rewrites those updates into compact per-artifact snapshots so `GetTask` and terminal replay do not accumulate token-level fragments. - The shared stream-hints v1 contract declares normalized usage fields `input_tokens`, `output_tokens`, and `total_tokens` at `metadata.shared.usage`. -- Progress metadata at `metadata.shared.progress` is emitted only when the client negotiated `urn:opencode-a2a:extension:shared:stream-hints:v1`; baseline streams do not emit duplicate generic `working` status updates just to carry progress hints. +- Progress metadata at `metadata.shared.progress` is emitted only when the client negotiated `urn:opencode-a2a:extension:stream-hints:v1`; baseline streams do not emit duplicate generic `working` status updates just to carry progress hints. - Usage is extracted from documented info payloads and supported usage parts such as `step-finish`; non-usage parts with similar fields are ignored. -- Interrupt events (`permission.asked` / `question.asked`) are mapped to `TaskStatusUpdateEvent(final=false, state=input-required)` with details at `metadata.shared.interrupt` when the client negotiated `urn:opencode-a2a:extension:shared:interactive-interrupt:v1`. +- Interrupt events (`permission.asked` / `question.asked`) are mapped to `TaskStatusUpdateEvent(final=false, state=input-required)` with details at `metadata.shared.interrupt` when the client negotiated `urn:opencode-a2a:extension:interactive-interrupt:v1`. - Resolved interrupt events (`permission.replied` / `question.replied` / `question.rejected`) are emitted as `TaskStatusUpdateEvent(final=false, state=working)` with `metadata.shared.interrupt.phase=resolved` only when the same interactive interrupt extension is negotiated. - Duplicate or unknown resolved events are suppressed unless the matching request is still pending. - Non-streaming requests return a `Task` directly. When `configuration.returnImmediately=true`, the initial response is a working `Task` snapshot and completion continues in the background for later `GetTask` reads. +- For successful non-streaming `message:send` completions, `Task.artifacts` is the canonical carrier for the assistant result text. +- The terminal `Task.status.message` may carry a short completion status such as `Completed.`, but it does not duplicate the full result text. - Non-streaming `message:send` responses may include normalized token usage at `Task.metadata.shared.usage` with the same field schema. ## Auth, Limits, and Failure Contract @@ -457,7 +459,7 @@ Important distinction: Stable specification URI: -- `urn:opencode-a2a:extension:shared:session-binding:v1` +- `urn:opencode-a2a:extension:session-binding:v1` This section focuses on how clients should use the binding at runtime. For the stable URI record and public-vs-extended disclosure policy, see [`extension-specifications.md`](./extension-specifications.md). @@ -504,7 +506,7 @@ curl -sS http://127.0.0.1:8000/v1/message:send \ Stable specification URI: -- `urn:opencode-a2a:extension:shared:model-selection:v1` +- `urn:opencode-a2a:extension:model-selection:v1` This section focuses on request-scoped usage. For the stable URI record and public-vs-extended disclosure policy, see [`extension-specifications.md`](./extension-specifications.md). @@ -557,7 +559,7 @@ curl -sS http://127.0.0.1:8000/v1/message:send \ Stable specification URI: -- `urn:opencode-a2a:extension:shared:stream-hints:v1` +- `urn:opencode-a2a:extension:stream-hints:v1` This section focuses on how clients should interpret runtime metadata. For the stable URI record and public-vs-extended disclosure policy, see [`extension-specifications.md`](./extension-specifications.md). diff --git a/src/opencode_a2a/client/payload_text.py b/src/opencode_a2a/client/payload_text.py index 7501be1..13f0197 100644 --- a/src/opencode_a2a/client/payload_text.py +++ b/src/opencode_a2a/client/payload_text.py @@ -2,8 +2,8 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any +from collections.abc import Iterable, Mapping +from typing import Any, cast from a2a.types import Message, Part, StreamResponse from google.protobuf.json_format import MessageToDict @@ -11,8 +11,13 @@ def extract_text(payload: Any) -> str | None: + def is_item_sequence(value: Any) -> bool: + return isinstance(value, Iterable) and not isinstance( + value, (str, bytes, bytearray, Mapping) + ) + def extract_from_iterable(items: Any) -> str | None: - if not isinstance(items, (list, tuple)): + if not is_item_sequence(items): return None for item in items: extracted = extract_text(item) @@ -21,7 +26,7 @@ def extract_from_iterable(items: Any) -> str | None: return None def extract_from_parts(parts: Any) -> str | None: - if not isinstance(parts, (list, tuple)): + if not is_item_sequence(parts): return None collected: list[str] = [] for part in parts: @@ -47,14 +52,14 @@ def extract_from_parts(parts: Any) -> str | None: def extract_from_mapping(payload_map: Mapping[str, Any]) -> str | None: for key in ( "content", - "message", - "messages", "result", - "status", "text", "parts", "artifact", "artifacts", + "message", + "messages", + "status", "history", "events", "root", @@ -76,7 +81,7 @@ def extract_from_mapping(payload_map: Mapping[str, Any]) -> str | None: artifact_text = extract_text(value) if artifact_text: return artifact_text - if isinstance(value, (list, tuple)) and key in ( + if is_item_sequence(value) and key in ( "messages", "artifacts", "history", @@ -90,7 +95,7 @@ def extract_from_mapping(payload_map: Mapping[str, Any]) -> str | None: return nested_text return None - if isinstance(payload, (list, tuple)): + if is_item_sequence(payload): return extract_from_iterable(payload) if isinstance(payload, Message): @@ -110,21 +115,30 @@ def extract_from_mapping(payload_map: Mapping[str, Any]) -> str | None: if isinstance(payload, str): return payload.strip() or None - status_payload = getattr(payload, "status", None) - if status_payload is not None: - text = extract_text(status_payload) + artifact_payload = getattr(payload, "artifact", None) + if artifact_payload is not None: + text = extract_text(artifact_payload) if text: return text + artifacts = getattr(payload, "artifacts", None) + if is_item_sequence(artifacts): + for artifact in cast(Iterable[Any], artifacts): + artifact_parts = getattr(artifact, "parts", None) + if is_item_sequence(artifact_parts): + text = extract_from_parts(artifact_parts) + if text: + return text + message_payload = getattr(payload, "message", None) if message_payload is not None: text = extract_text(message_payload) if text: return text - artifact_payload = getattr(payload, "artifact", None) - if artifact_payload is not None: - text = extract_text(artifact_payload) + status_payload = getattr(payload, "status", None) + if status_payload is not None: + text = extract_text(status_payload) if text: return text @@ -135,21 +149,12 @@ def extract_from_mapping(payload_map: Mapping[str, Any]) -> str | None: return text history = getattr(payload, "history", None) - if isinstance(history, (list, tuple)) and history: - for item in reversed(history): + if is_item_sequence(history) and history: + for item in reversed(list(cast(Iterable[Any], history))): text = extract_text(item) if text: return text - artifacts = getattr(payload, "artifacts", None) - if isinstance(artifacts, (list, tuple)): - for artifact in artifacts: - artifact_parts = getattr(artifact, "parts", None) - if isinstance(artifact_parts, (list, tuple)): - text = extract_from_parts(artifact_parts) - if text: - return text - text = extract_from_parts(getattr(payload, "parts", None)) if text: return text diff --git a/src/opencode_a2a/contracts/extensions/identifiers.py b/src/opencode_a2a/contracts/extensions/identifiers.py index 5c56031..26f81fa 100644 --- a/src/opencode_a2a/contracts/extensions/identifiers.py +++ b/src/opencode_a2a/contracts/extensions/identifiers.py @@ -15,62 +15,16 @@ EXTENSION_URI_NAMESPACE = "urn:opencode-a2a:extension:" EXTENSION_SPEC_INDEX_DOCUMENT_PATH = "docs/extension-specifications.md" - -def _extension_uri(*segments: str) -> str: - normalized_segments = [segment.strip("/") for segment in segments if segment.strip("/")] - return f"{EXTENSION_URI_NAMESPACE}{':'.join(normalized_segments)}" - - -SESSION_BINDING_EXTENSION_URI = _extension_uri( - "shared", - "session-binding", - "v1", -) -MODEL_SELECTION_EXTENSION_URI = _extension_uri( - "shared", - "model-selection", - "v1", -) -STREAMING_EXTENSION_URI = _extension_uri( - "shared", - "stream-hints", - "v1", -) -SESSION_MANAGEMENT_EXTENSION_URI = _extension_uri( - "private", - "session-management", - "v1", -) -PROVIDER_DISCOVERY_EXTENSION_URI = _extension_uri( - "private", - "provider-discovery", - "v1", -) -INTERRUPT_CALLBACK_EXTENSION_URI = _extension_uri( - "shared", - "interactive-interrupt", - "v1", -) -INTERRUPT_RECOVERY_EXTENSION_URI = _extension_uri( - "private", - "interrupt-recovery", - "v1", -) -WORKSPACE_CONTROL_EXTENSION_URI = _extension_uri( - "private", - "workspace-control", - "v1", -) -COMPATIBILITY_PROFILE_EXTENSION_URI = _extension_uri( - "private", - "compatibility-profile", - "v1", -) -WIRE_CONTRACT_EXTENSION_URI = _extension_uri( - "private", - "wire-contract", - "v1", -) +SESSION_BINDING_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}session-binding:v1" +MODEL_SELECTION_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}model-selection:v1" +STREAMING_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}stream-hints:v1" +SESSION_MANAGEMENT_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}session-management:v1" +PROVIDER_DISCOVERY_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}provider-discovery:v1" +INTERRUPT_CALLBACK_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}interactive-interrupt:v1" +INTERRUPT_RECOVERY_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}interrupt-recovery:v1" +WORKSPACE_CONTROL_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}workspace-control:v1" +COMPATIBILITY_PROFILE_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}compatibility-profile:v1" +WIRE_CONTRACT_EXTENSION_URI = f"{EXTENSION_URI_NAMESPACE}wire-contract:v1" PUBLIC_EXTENSION_URIS: tuple[str, ...] = ( SESSION_BINDING_EXTENSION_URI, MODEL_SELECTION_EXTENSION_URI, diff --git a/src/opencode_a2a/execution/coordinator.py b/src/opencode_a2a/execution/coordinator.py index d0f5e20..d1f0b2d 100644 --- a/src/opencode_a2a/execution/coordinator.py +++ b/src/opencode_a2a/execution/coordinator.py @@ -445,10 +445,10 @@ async def _handle_non_streaming_response( resolved_token_usage: Mapping[str, Any] | None, ) -> None: response_text = response_text or "(No text content returned by OpenCode.)" - assistant_message = Message( + status_message = Message( message_id=resolved_message_id or str(uuid.uuid4()), role=Role.ROLE_AGENT, - parts=[Part(text=response_text)], + parts=[Part(text="Completed.")], task_id=self._task_id, context_id=self._context_id, ) @@ -477,7 +477,7 @@ async def _handle_non_streaming_response( include_interrupt_metadata=self._prepared.emit_interrupt_metadata, ), ) - task.status.message.CopyFrom(assistant_message) + task.status.message.CopyFrom(status_message) await self._event_queue.enqueue_event(task) def _build_initial_task_snapshot(self) -> Task: diff --git a/src/opencode_a2a/server/openapi.py b/src/opencode_a2a/server/openapi.py index 5e56a1c..5e96800 100644 --- a/src/opencode_a2a/server/openapi.py +++ b/src/opencode_a2a/server/openapi.py @@ -6,7 +6,11 @@ from ..config import Settings from ..contracts.extensions import ( + INTERRUPT_CALLBACK_EXTENSION_URI, INTERRUPT_CALLBACK_METHODS, + MODEL_SELECTION_EXTENSION_URI, + SESSION_BINDING_EXTENSION_URI, + STREAMING_EXTENSION_URI, build_interrupt_callback_extension_params, build_model_selection_extension_params, build_public_interrupt_callback_extension_params, @@ -272,31 +276,43 @@ def custom_openapi() -> dict[str, Any]: post["summary"] = "Handle A2A JSON-RPC Requests" post["description"] = _build_jsonrpc_extension_openapi_description() post["x-a2a-extension-contracts"] = { - "session_binding": build_public_session_binding_extension_params( - session_binding - ), - "model_selection": select_public_extension_params( - model_selection, - keys=( - "metadata_field", - "behavior", - "applies_to_methods", - "supported_metadata", - "provider_private_metadata", - "fields", + "session_binding": { + "extension_uri": SESSION_BINDING_EXTENSION_URI, + **build_public_session_binding_extension_params(session_binding), + }, + "model_selection": { + "extension_uri": MODEL_SELECTION_EXTENSION_URI, + **select_public_extension_params( + model_selection, + keys=( + "metadata_field", + "behavior", + "applies_to_methods", + "supported_metadata", + "provider_private_metadata", + "fields", + ), ), - ), - "streaming": build_public_streaming_extension_params(streaming), - "interrupt_callback": select_public_extension_params( - build_public_interrupt_callback_extension_params(interrupt_callback), - keys=( - "methods", - "supported_interrupt_events", - "request_id_field", - "interrupt_metadata_field", - "interrupt_fields", + }, + "streaming": { + "extension_uri": STREAMING_EXTENSION_URI, + **build_public_streaming_extension_params(streaming), + }, + "interrupt_callback": { + "extension_uri": INTERRUPT_CALLBACK_EXTENSION_URI, + **select_public_extension_params( + build_public_interrupt_callback_extension_params( + interrupt_callback + ), + keys=( + "methods", + "supported_interrupt_events", + "request_id_field", + "interrupt_metadata_field", + "interrupt_fields", + ), ), - ), + }, } request_body = post.setdefault("requestBody", {}) diff --git a/tests/client/test_payload_text.py b/tests/client/test_payload_text.py index 580a234..7455546 100644 --- a/tests/client/test_payload_text.py +++ b/tests/client/test_payload_text.py @@ -46,6 +46,30 @@ def test_extract_text_reads_task_status_message() -> None: assert extract_text(task) == "status message text" +def test_extract_text_prefers_task_artifacts_over_completion_status() -> None: + task = Task( + id="remote-task", + context_id="remote-context", + status=TaskStatus( + state=TaskState.TASK_STATE_COMPLETED, + message=Message( + role=Role.ROLE_AGENT, + message_id="m1", + parts=[Part(text="Completed.")], + ), + ), + artifacts=[ + Artifact( + artifact_id="artifact-1", + name="response", + parts=[Part(text="artifact result text")], + ) + ], + ) + + assert extract_text(task) == "artifact result text" + + def test_extract_text_reads_nested_mapping_payload() -> None: payload = { "result": { diff --git a/tests/contracts/test_extension_contract_consistency.py b/tests/contracts/test_extension_contract_consistency.py index 0a2835c..a2ed109 100644 --- a/tests/contracts/test_extension_contract_consistency.py +++ b/tests/contracts/test_extension_contract_consistency.py @@ -142,6 +142,18 @@ def test_extension_uris_map_to_repository_spec_documents() -> None: index_path = repo_root / "docs" / "extension-specifications.md" index_text = index_path.read_text(encoding="utf-8") + assert ALL_EXTENSION_URIS == ( + "urn:opencode-a2a:extension:session-binding:v1", + "urn:opencode-a2a:extension:model-selection:v1", + "urn:opencode-a2a:extension:stream-hints:v1", + "urn:opencode-a2a:extension:interactive-interrupt:v1", + "urn:opencode-a2a:extension:session-management:v1", + "urn:opencode-a2a:extension:provider-discovery:v1", + "urn:opencode-a2a:extension:workspace-control:v1", + "urn:opencode-a2a:extension:interrupt-recovery:v1", + "urn:opencode-a2a:extension:compatibility-profile:v1", + "urn:opencode-a2a:extension:wire-contract:v1", + ) spec_paths = {repo_root / path for path in EXTENSION_SPEC_DOCUMENT_PATHS_BY_URI.values()} assert spec_paths == {index_path} @@ -149,12 +161,17 @@ def test_extension_uris_map_to_repository_spec_documents() -> None: assert uri.startswith(EXTENSION_URI_NAMESPACE), ( "Extension URI drifted away from the permanent URN namespace." ) + assert ":shared:" not in uri + assert ":private:" not in uri local_spec_path = repo_root / EXTENSION_SPEC_DOCUMENT_PATHS_BY_URI[uri] assert local_spec_path.is_file(), ( f"Extension URI {uri!r} does not map to a checked-in spec document." ) assert uri in index_text + assert "urn:opencode-a2a:extension:shared:" not in index_text + assert "urn:opencode-a2a:extension:private:" not in index_text + def test_extension_ssot_matches_agent_card_contracts() -> None: card = build_agent_card( @@ -270,6 +287,10 @@ def test_openapi_jsonrpc_contract_extension_matches_public_disclosure_policy() - expected_session_binding = build_public_session_binding_extension_params( build_session_binding_extension_params(runtime_profile=runtime_profile), ) + expected_session_binding = { + "extension_uri": SESSION_BINDING_EXTENSION_URI, + **expected_session_binding, + } expected_model_selection = select_public_extension_params( build_model_selection_extension_params(runtime_profile=runtime_profile), keys=( @@ -281,10 +302,22 @@ def test_openapi_jsonrpc_contract_extension_matches_public_disclosure_policy() - "fields", ), ) + expected_model_selection = { + "extension_uri": MODEL_SELECTION_EXTENSION_URI, + **expected_model_selection, + } expected_streaming = build_public_streaming_extension_params(build_streaming_extension_params()) + expected_streaming = { + "extension_uri": STREAMING_EXTENSION_URI, + **expected_streaming, + } expected_interrupt_callback = build_public_interrupt_callback_extension_params( build_interrupt_callback_extension_params(runtime_profile=runtime_profile), ) + expected_interrupt_callback = { + "extension_uri": INTERRUPT_CALLBACK_EXTENSION_URI, + **expected_interrupt_callback, + } assert session_binding == expected_session_binding, ( "OpenAPI public session binding contract drifted from disclosure policy." diff --git a/tests/execution/test_opencode_agent_session_binding.py b/tests/execution/test_opencode_agent_session_binding.py index f5c0b8d..ad9a8c6 100644 --- a/tests/execution/test_opencode_agent_session_binding.py +++ b/tests/execution/test_opencode_agent_session_binding.py @@ -233,6 +233,8 @@ async def send_message( task = next(event for event in q.events if isinstance(event, Task)) assert "message_id" not in task.metadata["shared"]["session"] assert task.status.message.message_id == "t-fallback:c-fallback:assistant" + assert task.status.message.parts[0].text == "Completed." + assert task.artifacts[0].parts[0].text == "echo:hello" @pytest.mark.asyncio @@ -435,9 +437,10 @@ def borrow_client(self, url: str): assert client.call_count == 2 task = next(event for event in q.events if isinstance(event, Task)) + assert task.status.message.parts[0].text == "Completed." assert ( - getattr(task.status.message.parts[0], "text", None) - or getattr(getattr(task.status.message.parts[0], "root", None), "text", "") + getattr(task.artifacts[0].parts[0], "text", None) + or getattr(getattr(task.artifacts[0].parts[0], "root", None), "text", "") ) == "done" diff --git a/tests/execution/test_streaming_output_contract_core.py b/tests/execution/test_streaming_output_contract_core.py index 7b45b04..997c48d 100644 --- a/tests/execution/test_streaming_output_contract_core.py +++ b/tests/execution/test_streaming_output_contract_core.py @@ -972,7 +972,10 @@ async def test_non_streaming_response_task_state_is_completed() -> None: tasks = [event for event in queue.events if isinstance(event, Task)] assert tasks - assert tasks[-1].status.state == TaskState.TASK_STATE_COMPLETED + task = tasks[-1] + assert task.status.state == TaskState.TASK_STATE_COMPLETED + assert task.status.message.parts[0].text == "Completed." + assert task.artifacts[0].parts[0].text == "answer" @pytest.mark.asyncio