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
4 changes: 3 additions & 1 deletion docs/extension-specifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extens

- Scope: provider-private OpenCode session lifecycle, history, and low-risk control methods
- Public Agent Card: capability declaration only
- Authenticated extended card: full method matrix, pagination rules, errors, and context semantics
- Authenticated extended card: full method matrix, pagination rules, errors, context semantics, and existing `opencode.sessions.prompt_async` input-part contracts
- Transport: A2A JSON-RPC extension methods
- `opencode.sessions.prompt_async` includes a provider-private `request.parts[]` compatibility surface for upstream OpenCode part types `text`, `file`, `agent`, and `subtask`
- `subtask` support is declared as passthrough-compatible only: subagent selection and task-tool execution remain upstream OpenCode runtime behavior, not a separate `opencode-a2a` orchestration API

## OpenCode Provider Discovery v1

Expand Down
52 changes: 52 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,20 @@ Retention guidance:
- Treat `opencode.sessions.shell` as deployment-conditional and discover it from the declared profile and current wire contract before calling it.
- Treat `protocol_compatibility` as the runtime truth for which protocol line is fully supported versus only partially adapted.

Extension boundary principles:

- Expose OpenCode-specific capabilities through A2A only when they fit the adapter boundary: the adapter may document, validate, route, and normalize stable upstream-facing behavior, but it should not become a general replacement for upstream private runtime internals or host-level control planes.
- Default new `opencode.*` methods to provider-private extension status. Do not present them as portable A2A baseline capabilities unless they truly align with shared protocol semantics.
- Prefer read-only discovery, stable compatibility surfaces, and low-risk control methods before introducing stronger mutating or destructive operations.
- Map results to A2A core objects only when the upstream payload is a stable, low-ambiguity read projection such as session-to-`Task` or message-to-`Message`. Otherwise prefer provider-private summary/result envelopes.
- Treat upstream internal execution mechanisms, including subtask/subagent fan-out and task-tool internals, as provider-private runtime behavior. The adapter may expose passthrough compatibility and observable output metadata, but should not promote those internals into a first-class A2A orchestration API by default.
- For any new extension proposal, require an explicit answer to all of the following before implementation:
- What client value is added beyond the existing chat/session flow?
- Is the upstream behavior stable enough to document as a maintained contract?
- Should the surface remain provider-private, deployment-conditional, or not be exposed at all?
- Are authorization, workspace/session ownership, and destructive-side-effect boundaries clear enough to enforce?
- Can the result shape be expressed without overfitting OpenCode internals into fake A2A core semantics?

## Multipart Input Example

Minimal JSON-RPC example with text + file input:
Expand Down Expand Up @@ -669,6 +683,14 @@ curl -sS http://127.0.0.1:8000/ \

### Session Prompt Async (`opencode.sessions.prompt_async`)

Topology note:

- `A2A Task` remains the protocol-level execution object exposed by the adapter.
- `opencode.sessions.prompt_async` is a provider-private extension method, not part of the A2A core baseline.
- `request.parts[].type=subtask` is an upstream-compatible OpenCode input shape carried through that extension method.
- Downstream execution may fan out into upstream OpenCode task-tool / subagent runtime behavior, but that internal orchestration remains provider-private.
- The adapter documents passthrough compatibility and observable `tool_call` output blocks; it does not promote subtask/subagent execution into a first-class A2A orchestration API.

```bash
curl -sS http://127.0.0.1:8000/ \
-H 'content-type: application/json' \
Expand Down Expand Up @@ -713,8 +735,38 @@ Validation notes:
- `metadata.opencode.directory` follows the same normalization and boundary rules as message send (`realpath` + workspace boundary check).
- `metadata.opencode.workspace.id` is a provider-private routing hint. When it is present, the adapter routes the request to that workspace and does not apply directory override resolution for the same call.
- `request.model` uses the same shape as `metadata.shared.model` and is scoped only to the current session-control request.
- `request.parts[]` currently accepts upstream-compatible provider-private part types `text`, `file`, `agent`, and `subtask`.
- `subtask` parts require `prompt`, `description`, and `agent`; they may also include optional `model` and `command`.
- For `subtask` parts, `request.parts[].agent` is the upstream subagent selector. `opencode-a2a` validates and forwards the shape but does not define a separate subagent discovery or orchestration API.
- Control methods enforce session owner guard based on request identity.

Example (`opencode.sessions.prompt_async` with a provider-private `subtask` part):

```bash
curl -sS http://127.0.0.1:8000/ \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <your-token>' \
-d '{
"jsonrpc": "2.0",
"id": 211,
"method": "opencode.sessions.prompt_async",
"params": {
"session_id": "<session_id>",
"request": {
"parts": [
{
"type": "subtask",
"prompt": "Inspect the auth middleware and list the highest-risk gaps.",
"description": "Security-focused pass over request auth flow",
"agent": "explore",
"command": "review"
}
]
}
}
}'
```

### Session Command (`opencode.sessions.command`)

```bash
Expand Down
61 changes: 61 additions & 0 deletions src/opencode_a2a/contracts/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ class WorkspaceControlMethodContract:
*PROMPT_ASYNC_REQUEST_REQUIRED_FIELDS,
*PROMPT_ASYNC_REQUEST_OPTIONAL_FIELDS,
)
PROMPT_ASYNC_SUPPORTED_PART_TYPES: tuple[str, ...] = ("text", "file", "agent", "subtask")
PROMPT_ASYNC_PART_CONTRACTS: dict[str, dict[str, Any]] = {
"text": {
"required": ("type", "text"),
},
"file": {
"required": ("type", "mime", "url"),
},
"agent": {
"required": ("type", "name"),
},
"subtask": {
"required": ("type", "prompt", "description", "agent"),
"optional": ("model", "command"),
},
}
COMMAND_REQUEST_REQUIRED_FIELDS: tuple[str, ...] = ("command", "arguments")
COMMAND_REQUEST_OPTIONAL_FIELDS: tuple[str, ...] = (
"messageID",
Expand Down Expand Up @@ -777,6 +793,48 @@ def _build_method_contract_params(
return params


def _build_prompt_async_part_contracts() -> dict[str, Any]:
part_contracts: dict[str, Any] = {}
for part_type, contract in PROMPT_ASYNC_PART_CONTRACTS.items():
part_contract_doc: dict[str, Any] = {
"required": list(contract["required"]),
}
optional = contract.get("optional")
if optional:
part_contract_doc["optional"] = list(optional)
part_contracts[part_type] = part_contract_doc
return {
"items_type": "PromptAsyncPart[]",
"type_field": "type",
"accepted_types": list(PROMPT_ASYNC_SUPPORTED_PART_TYPES),
"part_contracts": part_contracts,
}


def _build_prompt_async_subtask_support() -> dict[str, Any]:
return {
"support_level": "passthrough-compatible",
"invocation_path": "request.parts[]",
"part_type": "subtask",
"subagent_selector_field": "request.parts[].agent",
"execution_model": "upstream-provider-private-subagent-runtime",
"notes": [
(
"opencode-a2a validates and forwards provider-private subtask parts to "
"the upstream OpenCode session runtime."
),
(
"The adapter does not define a separate subagent discovery or "
"orchestration JSON-RPC method surface."
),
(
"Subtask execution semantics, available subagent names, and any task-tool "
"fan-out remain upstream OpenCode behavior."
),
],
}


def build_session_binding_extension_params(
*,
runtime_profile: RuntimeProfile,
Expand Down Expand Up @@ -946,6 +1004,9 @@ def build_session_query_extension_params(
"params": params_contract,
"result": result_contract,
}
if method_contract.method == SESSION_QUERY_METHODS["prompt_async"]:
contract_doc["request_parts"] = _build_prompt_async_part_contracts()
contract_doc["subtask_support"] = _build_prompt_async_subtask_support()
if method_contract.notification_response_status is not None:
contract_doc["notification_response_status"] = (
method_contract.notification_response_status
Expand Down
5 changes: 4 additions & 1 deletion src/opencode_a2a/server/agent_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ def _build_session_query_skill_examples(
"Read one session diff (method opencode.sessions.diff).",
("List messages with cursor pagination (method opencode.sessions.messages.list)."),
"Get one session message (method opencode.sessions.messages.get).",
"Send async prompt to a session (method opencode.sessions.prompt_async).",
(
"Send async prompt to a session, including provider-private agent/subtask "
"parts (method opencode.sessions.prompt_async)."
),
"Send command to a session (method opencode.sessions.command).",
"Fork a session at a message boundary (method opencode.sessions.fork).",
"Share or unshare a session (methods opencode.sessions.share / opencode.sessions.unshare).",
Expand Down
26 changes: 26 additions & 0 deletions src/opencode_a2a/server/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def _build_jsonrpc_extension_openapi_description(
"plus shared model-selection metadata, OpenCode session/provider extensions, "
"interrupt recovery extensions, and shared interrupt callback methods.\n\n"
f"OpenCode session lifecycle/query/control methods: {', '.join(session_methods)}.\n"
"The existing prompt_async extension also accepts provider-private OpenCode "
"request.parts[] item types such as text, file, agent, and subtask; subtask "
"execution remains upstream runtime behavior rather than a separate A2A "
"orchestration API.\n"
f"OpenCode provider/model discovery methods: {provider_methods}.\n"
f"OpenCode project/workspace/worktree control methods: {workspace_methods}.\n"
f"OpenCode interrupt recovery methods: {interrupt_recovery_methods}.\n"
Expand Down Expand Up @@ -238,6 +242,28 @@ def _build_jsonrpc_extension_openapi_examples(
},
},
},
"session_prompt_async_subtask": {
"summary": "Send a provider-private subtask part to an existing session",
"value": {
"jsonrpc": "2.0",
"id": 211,
"method": SESSION_QUERY_METHODS["prompt_async"],
"params": {
"session_id": "s-1",
"request": {
"parts": [
{
"type": "subtask",
"prompt": "Inspect the auth middleware and list gaps.",
"description": "Security-focused pass over request auth flow",
"agent": "explore",
"command": "review",
}
]
},
},
},
},
"session_command": {
"summary": "Send command to an existing session",
"value": {
Expand Down
71 changes: 71 additions & 0 deletions tests/jsonrpc/test_opencode_session_extension_prompt_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,77 @@ async def test_session_prompt_async_extension_success(monkeypatch):
assert dummy.prompt_async_calls[0]["request"]["parts"][0]["text"] == "Continue the task"


@pytest.mark.asyncio
async def test_session_prompt_async_extension_accepts_subtask_parts(monkeypatch):
import opencode_a2a.server.application as app_module

dummy = DummyOpencodeUpstreamClient(
make_settings(
a2a_bearer_token="t-1",
a2a_log_payloads=False,
opencode_workspace_root="/workspace",
**_BASE_SETTINGS,
)
)
monkeypatch.setattr(app_module, "OpencodeUpstreamClient", lambda _settings: dummy)
app = app_module.create_app(
make_settings(
a2a_bearer_token="t-1",
a2a_log_payloads=False,
opencode_workspace_root="/workspace",
**_BASE_SETTINGS,
)
)

transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/",
headers={"Authorization": "Bearer t-1"},
json={
"jsonrpc": "2.0",
"id": 3012,
"method": "opencode.sessions.prompt_async",
"params": {
"session_id": "s-1",
"request": {
"parts": [
{
"type": "subtask",
"prompt": "Inspect auth middleware",
"description": "Security pass",
"agent": "explore",
"command": "review",
"model": {
"providerID": "openai",
"modelID": "gpt-5",
},
}
]
},
"metadata": {"opencode": {"directory": "/workspace"}},
},
},
)

assert response.status_code == 200
assert response.json()["result"] == {"ok": True, "session_id": "s-1"}
request = dummy.prompt_async_calls[0]["request"]
assert request["parts"] == [
{
"type": "subtask",
"prompt": "Inspect auth middleware",
"description": "Security pass",
"agent": "explore",
"command": "review",
"model": {
"providerID": "openai",
"modelID": "gpt-5",
},
}
]


@pytest.mark.asyncio
async def test_session_prompt_async_extension_prefers_workspace_metadata(monkeypatch):
import opencode_a2a.server.application as app_module
Expand Down
35 changes: 35 additions & 0 deletions tests/server/test_agent_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,41 @@ def test_agent_card_injects_profile_into_extensions() -> None:
assert revert_contract["result"]["items_type"] == "SessionSummary"
assert unrevert_contract["result"]["fields"] == ["item"]
assert prompt_contract["params"]["required"] == ["session_id", "request.parts"]
assert prompt_contract["request_parts"] == {
"items_type": "PromptAsyncPart[]",
"type_field": "type",
"accepted_types": ["text", "file", "agent", "subtask"],
"part_contracts": {
"text": {"required": ["type", "text"]},
"file": {"required": ["type", "mime", "url"]},
"agent": {"required": ["type", "name"]},
"subtask": {
"required": ["type", "prompt", "description", "agent"],
"optional": ["model", "command"],
},
},
}
assert prompt_contract["subtask_support"] == {
"support_level": "passthrough-compatible",
"invocation_path": "request.parts[]",
"part_type": "subtask",
"subagent_selector_field": "request.parts[].agent",
"execution_model": "upstream-provider-private-subagent-runtime",
"notes": [
(
"opencode-a2a validates and forwards provider-private subtask parts to "
"the upstream OpenCode session runtime."
),
(
"The adapter does not define a separate subagent discovery or "
"orchestration JSON-RPC method surface."
),
(
"Subtask execution semantics, available subagent names, and any task-tool "
"fan-out remain upstream OpenCode behavior."
),
],
}
assert prompt_contract["result"]["fields"] == ["ok", "session_id"]
assert command_contract["params"]["required"] == [
"session_id",
Expand Down