Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 22 additions & 21 deletions docs/extension-specifications.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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).

Expand Down
57 changes: 31 additions & 26 deletions src/opencode_a2a/client/payload_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

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
from google.protobuf.message import Message as ProtoMessage


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)
Expand All @@ -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:
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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):
Expand All @@ -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

Expand All @@ -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
Expand Down
Loading
Loading