From 5d0e3a1a3109d822122d9a643a24924b1625e162 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 12 May 2026 11:29:58 +0800 Subject: [PATCH 1/9] refactor: prune thin helpers and refresh CLI text banner --- src/opencode_a2a/a2a_utils.py | 4 - src/opencode_a2a/cli.py | 19 ++-- .../jsonrpc/handlers/interrupt_queries.py | 27 ++--- src/opencode_a2a/output_modes.py | 55 +++------- src/opencode_a2a/server/application.py | 29 ++--- src/opencode_a2a/server/context_helpers.py | 87 ++++++--------- src/opencode_a2a/server/task_store.py | 5 +- tests/execution/test_agent_helpers.py | 33 ++++++ tests/server/test_cli.py | 6 +- tests/server/test_context_helpers.py | 61 +++++++++++ tests/server/test_output_negotiation.py | 61 +++++++++++ tests/test_a2a_utils.py | 100 ++++++++++++++++++ 12 files changed, 354 insertions(+), 133 deletions(-) create mode 100644 tests/server/test_context_helpers.py create mode 100644 tests/test_a2a_utils.py diff --git a/src/opencode_a2a/a2a_utils.py b/src/opencode_a2a/a2a_utils.py index b4d43ba..08eec32 100644 --- a/src/opencode_a2a/a2a_utils.py +++ b/src/opencode_a2a/a2a_utils.py @@ -16,10 +16,6 @@ def clone_proto(message: ProtoT) -> ProtoT: return cloned -def proto_equals(left: ProtoMessage, right: ProtoMessage) -> bool: - return bool(left == right) - - def _to_proto_value(value: Any) -> Value: proto_value = Value() if value is None: diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 9532ec0..70663d4 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -15,12 +15,19 @@ from .server.application import main as serve_main CLI_BRAND_BANNER = ( - " ___ ____ _ _ ____ _ \n" - " / _ \\ _ __ ___ _ __ / ___|___ __| | ___ / \\ |___ \\ / \\ \n" - "| | | | '_ \\ / _ \\ '_ \\| | / _ \\ / _` |/ _ \\_____ / _ \\ __) | / _ \\ \n" - "| |_| | |_) | __/ | | | |__| (_) | (_| | __/_____/ ___ \\ / __/ / ___ \\ \n" - " \\___/| .__/ \\___|_| |_|\\____\\___/ \\__,_|\\___| /_/ \\_\\_____/_/ \\_\\\n" - " |_| " + " ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗ \n" + "██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ \n" + "██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗█████╗\n" + "██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝╚════╝\n" + "╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗ \n" + " ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ \n" + " \n" + " █████╗ ██████╗ █████╗ \n" + "██╔══██╗╚════██╗██╔══██╗ \n" + "███████║ █████╔╝███████║ \n" + "██╔══██║██╔═══╝ ██╔══██║ \n" + "██║ ██║███████╗██║ ██║ \n" + "╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ " ) PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" HELP_FLAGS = frozenset({"-h", "--help"}) diff --git a/src/opencode_a2a/jsonrpc/handlers/interrupt_queries.py b/src/opencode_a2a/jsonrpc/handlers/interrupt_queries.py index 3403ac5..af97c89 100644 --- a/src/opencode_a2a/jsonrpc/handlers/interrupt_queries.py +++ b/src/opencode_a2a/jsonrpc/handlers/interrupt_queries.py @@ -10,18 +10,6 @@ from .common import build_internal_error_response, build_success_response, reject_unknown_fields -def _binding_to_result_item(binding: Any) -> dict[str, Any]: - return { - "request_id": binding.request_id, - "session_id": binding.session_id, - "interrupt_type": binding.interrupt_type, - "task_id": binding.task_id, - "context_id": binding.context_id, - "details": dict(binding.details) if isinstance(binding.details, dict) else None, - "expires_at": binding.expires_at, - } - - async def handle_interrupt_query_request( context: ExtensionHandlerContext, base_request: JSONRPCRequest, @@ -58,5 +46,18 @@ async def handle_interrupt_query_request( return build_success_response( context, base_request.id, - {"items": [_binding_to_result_item(item) for item in items]}, + { + "items": [ + { + "request_id": item.request_id, + "session_id": item.session_id, + "interrupt_type": item.interrupt_type, + "task_id": item.task_id, + "context_id": item.context_id, + "details": dict(item.details) if isinstance(item.details, dict) else None, + "expires_at": item.expires_at, + } + for item in items + ] + }, ) diff --git a/src/opencode_a2a/output_modes.py b/src/opencode_a2a/output_modes.py index 29d3a62..a4400ec 100644 --- a/src/opencode_a2a/output_modes.py +++ b/src/opencode_a2a/output_modes.py @@ -37,30 +37,19 @@ _STREAM_METADATA_BLOCK_TYPE_KEY = "block_type" -def _accepted_output_modes_source(source: Any) -> Iterable[str] | None: +def normalize_accepted_output_modes(source: Any) -> tuple[str, ...] | None: if source is None: return None - accepted = getattr(source, "accepted_output_modes", None) or getattr( source, "acceptedOutputModes", None ) if accepted is not None: source = accepted - - if isinstance(source, str | bytes | bytearray | dict): - return None - if not isinstance(source, Iterable): - return None - return cast(Iterable[str], source) - - -def normalize_accepted_output_modes(source: Any) -> tuple[str, ...] | None: - accepted = _accepted_output_modes_source(source) - if accepted is None: + if isinstance(source, str | bytes | bytearray | dict) or not isinstance(source, Iterable): return None normalized: list[str] = [] - for value in accepted: + for value in cast(Iterable[str], source): if not isinstance(value, str): continue mode = value.strip().lower() @@ -194,9 +183,11 @@ def apply_accepted_output_modes( return replace_artifact_event_artifact(payload, artifact) if isinstance(payload, TaskStatusUpdateEvent): - message = None - if payload.status.HasField("message"): - message = _filter_optional_message(payload.status.message, normalized) + message = ( + _filter_message(payload.status.message, normalized) + if payload.status.HasField("message") + else None + ) return replace_status_event_message(payload, message) if isinstance(payload, Task): @@ -375,7 +366,7 @@ async def _continue_consuming( def _filter_task(task: Task, accepted_output_modes: Collection[str]) -> Task: updated = clone_proto(task) if updated.status.HasField("message"): - filtered_message = _filter_optional_message(updated.status.message, accepted_output_modes) + filtered_message = _filter_message(updated.status.message, accepted_output_modes) if filtered_message is None: updated.status.ClearField("message") else: @@ -404,15 +395,6 @@ def _filter_task(task: Task, accepted_output_modes: Collection[str]) -> Task: return updated -def _filter_optional_message( - message: Message | None, - accepted_output_modes: Collection[str], -) -> Message | None: - if message is None: - return None - return _filter_message(message, accepted_output_modes) - - def _filter_message( message: Message, accepted_output_modes: Collection[str], @@ -439,7 +421,14 @@ def _filter_parts( ) -> list[Part]: filtered: list[Part] = [] for part in parts: - media_type = _part_media_type(part) + if part.HasField("text"): + media_type = _TEXT_PLAIN_MEDIA_TYPE + elif part.HasField("data"): + media_type = _APPLICATION_JSON_MEDIA_TYPE + elif part.HasField("raw") or part.HasField("url"): + media_type = part.media_type or "application/octet-stream" + else: + media_type = None if media_type is None or accepts_output_mode(accepted_output_modes, media_type): filtered.append(part) continue @@ -459,13 +448,3 @@ def _filter_parts( ) ) return filtered - - -def _part_media_type(part: Part) -> str | None: - if part.HasField("text"): - return _TEXT_PLAIN_MEDIA_TYPE - if part.HasField("data"): - return _APPLICATION_JSON_MEDIA_TYPE - if part.HasField("raw") or part.HasField("url"): - return part.media_type or "application/octet-stream" - return None diff --git a/src/opencode_a2a/server/application.py b/src/opencode_a2a/server/application.py index 41c9ba1..c8fbc6a 100644 --- a/src/opencode_a2a/server/application.py +++ b/src/opencode_a2a/server/application.py @@ -259,17 +259,6 @@ def _task_store_failure_message(operation: str) -> str: return "Task store unavailable while deleting task state." return "Task store unavailable." - @classmethod - def _task_store_failure_metadata(cls, operation: str) -> dict[str, dict[str, dict[str, str]]]: - return { - "opencode": { - "error": { - "type": TASK_STORE_ERROR_TYPE, - "operation": operation, - } - } - } - @classmethod def _task_store_server_error(cls, exc: TaskStoreOperationError) -> InternalError: return InternalError(message=cls._task_store_failure_message(exc.operation)) @@ -295,7 +284,14 @@ def _task_store_failure_task( context_id=context_id, status=TaskStatus(state=TaskState.TASK_STATE_FAILED, message=error_message), history=[error_message], - metadata=cls._task_store_failure_metadata(operation), + metadata={ + "opencode": { + "error": { + "type": TASK_STORE_ERROR_TYPE, + "operation": operation, + } + } + }, ) @classmethod @@ -322,7 +318,14 @@ def _task_store_failure_events( task_id=task_id, context_id=context_id, status=TaskStatus(state=TaskState.TASK_STATE_FAILED), - metadata=cls._task_store_failure_metadata(operation), + metadata={ + "opencode": { + "error": { + "type": TASK_STORE_ERROR_TYPE, + "operation": operation, + } + } + }, ), ) diff --git a/src/opencode_a2a/server/context_helpers.py b/src/opencode_a2a/server/context_helpers.py index f03016b..4dd69d3 100644 --- a/src/opencode_a2a/server/context_helpers.py +++ b/src/opencode_a2a/server/context_helpers.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Mapping, MutableMapping -from typing import Any from a2a.auth.user import UnauthenticatedUser, User from a2a.server.context import ServerCallContext @@ -24,20 +23,41 @@ def normalize_server_call_context(context: ServerCallContext | None) -> ServerCa if context is None: return ServerCallContext() - raw_state = _read_context_attribute(context, "state", default=None) - raw_requested_extensions = _read_context_attribute( - context, - "requested_extensions", - default=None, - ) - raw_tenant = _read_context_attribute(context, "tenant", default="") - raw_user = _read_context_attribute(context, "user", default=None) + raw_state = getattr(context, "state", None) + raw_requested_extensions = getattr(context, "requested_extensions", None) + raw_tenant = getattr(context, "tenant", "") + raw_user = getattr(context, "user", None) - state = _normalize_context_state(raw_state) - requested_extensions = _normalize_requested_extensions(raw_requested_extensions) - tenant = _normalize_tenant(raw_tenant) + if isinstance(raw_state, MutableMapping): + state = raw_state + elif isinstance(raw_state, Mapping): + state = dict(raw_state) + else: + state = {} + + if raw_requested_extensions is None: + requested_extensions: set[str] = set() + elif isinstance(raw_requested_extensions, set): + requested_extensions = raw_requested_extensions + elif isinstance(raw_requested_extensions, str): + requested_extensions = {raw_requested_extensions} + else: + try: + requested_extensions = {str(value) for value in raw_requested_extensions} + except TypeError: + requested_extensions = set() + + tenant = raw_tenant if isinstance(raw_tenant, str) else "" identity = state.get("identity") - user = _normalize_user(raw_user, identity=identity if isinstance(identity, str) else None) + normalized_identity = identity if isinstance(identity, str) else None + if normalized_identity and ( + not isinstance(raw_user, User) or isinstance(raw_user, UnauthenticatedUser) + ): + user: User = AuthenticatedIdentityUser(normalized_identity) + elif isinstance(raw_user, User): + user = raw_user + else: + user = UnauthenticatedUser() if ( isinstance(context, ServerCallContext) @@ -54,44 +74,3 @@ def normalize_server_call_context(context: ServerCallContext | None) -> ServerCa tenant=tenant, requested_extensions=requested_extensions, ) - - -def _read_context_attribute(context: Any, name: str, *, default: Any) -> Any: - try: - return getattr(context, name) - except AttributeError: - return default - - -def _normalize_context_state(raw_state: Any) -> MutableMapping[str, Any]: - if isinstance(raw_state, MutableMapping): - return raw_state - if isinstance(raw_state, Mapping): - return dict(raw_state) - return {} - - -def _normalize_requested_extensions(raw_extensions: Any) -> set[str]: - if raw_extensions is None: - return set() - if isinstance(raw_extensions, set): - return raw_extensions - if isinstance(raw_extensions, str): - return {raw_extensions} - try: - return {str(value) for value in raw_extensions} - except TypeError: - return set() - - -def _normalize_tenant(raw_tenant: Any) -> str: - return raw_tenant if isinstance(raw_tenant, str) else "" - - -def _normalize_user(raw_user: Any, *, identity: str | None) -> User: - if identity: - if not isinstance(raw_user, User) or isinstance(raw_user, UnauthenticatedUser): - return AuthenticatedIdentityUser(identity) - if isinstance(raw_user, User): - return raw_user - return UnauthenticatedUser() diff --git a/src/opencode_a2a/server/task_store.py b/src/opencode_a2a/server/task_store.py index 79516b2..be72d66 100644 --- a/src/opencode_a2a/server/task_store.py +++ b/src/opencode_a2a/server/task_store.py @@ -14,7 +14,6 @@ from a2a.types import ListTasksRequest, ListTasksResponse, Task from sqlalchemy.engine import make_url -from ..a2a_utils import proto_equals from ..config import Settings from ..task_states import TERMINAL_TASK_STATES from .context_helpers import normalize_server_call_context @@ -67,7 +66,7 @@ def evaluate( persist=False, reason="state_overwrite_after_terminal_persistence", ) - if not proto_equals(incoming, existing): + if incoming != existing: return TaskPersistenceDecision( persist=False, reason="late_mutation_after_terminal_persistence", @@ -238,7 +237,7 @@ async def _save_database_task( if ( existing is not None and existing.status.state in TERMINAL_TASK_STATES - and proto_equals(existing, task) + and existing == task ): return raise RuntimeError( diff --git a/tests/execution/test_agent_helpers.py b/tests/execution/test_agent_helpers.py index 9bf543d..19fff81 100644 --- a/tests/execution/test_agent_helpers.py +++ b/tests/execution/test_agent_helpers.py @@ -146,6 +146,39 @@ def test_format_upstream_errors_include_detail_and_provider_auth_paths() -> None assert "error=SomeOtherError" in inband.message +def test_format_upstream_error_helpers_cover_status_and_generic_paths() -> None: + terminal = _format_stream_terminal_error(detail=None, status=503, error_name=None) + assert terminal.state == TaskState.TASK_STATE_FAILED + assert terminal.error_type == "UPSTREAM_SERVER_ERROR" + assert terminal.upstream_status == 503 + + terminal_with_detail = _format_stream_terminal_error( + detail="tool crashed", + status=None, + error_name=None, + ) + assert terminal_with_detail.error_type == "UPSTREAM_EXECUTION_ERROR" + assert "detail=tool crashed" in terminal_with_detail.message + + inband_with_status = _format_inband_upstream_error( + source="message.updated", + detail=None, + status=429, + error_name=None, + ) + assert inband_with_status.error_type == "UPSTREAM_QUOTA_EXCEEDED" + assert "status=429" in inband_with_status.message + + provider_auth = _format_inband_upstream_error( + source="message.updated", + detail=None, + status=None, + error_name="ProviderAuthError", + ) + assert provider_auth.state == TaskState.TASK_STATE_AUTH_REQUIRED + assert provider_auth.error_type == "UPSTREAM_UNAUTHORIZED" + + @pytest.mark.asyncio async def test_await_stream_terminal_signal_handles_shortcuts_and_missing_signal() -> None: loop = asyncio.get_running_loop() diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index 3cbf07a..1920ad7 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -118,8 +118,10 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert ( "OpenCode A2A runtime for explicit service startup and peer calls. A2A Protocol 1.0 only." ) in help_text - assert "___ ____" in help_text - assert "| | | | '_ \\ / _ \\ '_ \\| | / _ \\ / _` |/ _ \\_____ / _ \\" in help_text + assert "██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗" in help_text + assert "██║ ██║██████╔╝█████╗" in help_text + assert "█████╗█████╗" in help_text + assert "█████╗ ██████╗ █████╗" in help_text assert "opencode-a2a [arguments] [options]" in help_text assert "A2A_STATIC_AUTH_CREDENTIALS" in help_text assert "opencode serve --hostname 127.0.0.1 --port 4096" in help_text diff --git a/tests/server/test_context_helpers.py b/tests/server/test_context_helpers.py new file mode 100644 index 0000000..43ed74c --- /dev/null +++ b/tests/server/test_context_helpers.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from types import MappingProxyType, SimpleNamespace + +from a2a.auth.user import UnauthenticatedUser +from a2a.server.context import ServerCallContext + +from opencode_a2a.server.context_helpers import ( + AuthenticatedIdentityUser, + normalize_server_call_context, +) + + +def test_normalize_server_call_context_preserves_existing_normalized_context() -> None: + context = ServerCallContext( + state={"identity": "opaque:test-user"}, + tenant="tenant-1", + requested_extensions={"ext-a"}, + user=AuthenticatedIdentityUser("opaque:test-user"), + ) + + normalized = normalize_server_call_context(context) + + assert normalized is context + assert normalized.user.is_authenticated is True + assert normalized.user.user_name == "opaque:test-user" + + +def test_normalize_server_call_context_coerces_mapping_and_promotes_identity_user() -> None: + context = SimpleNamespace( + state=MappingProxyType({"identity": "opaque:test-user", "trace_id": "abc123"}), + tenant=123, + requested_extensions=["ext-a", 7, "ext-a"], + user=UnauthenticatedUser(), + ) + + normalized = normalize_server_call_context(context) + + assert normalized is not context + assert normalized.state == {"identity": "opaque:test-user", "trace_id": "abc123"} + assert isinstance(normalized.state, dict) + assert normalized.tenant == "" + assert normalized.requested_extensions == {"ext-a", "7"} + assert normalized.user.is_authenticated is True + assert normalized.user.user_name == "opaque:test-user" + + +def test_normalize_server_call_context_falls_back_for_invalid_shapes() -> None: + context = SimpleNamespace( + state=object(), + tenant="tenant-3", + requested_extensions=object(), + user="not-a-user", + ) + + normalized = normalize_server_call_context(context) + + assert normalized.state == {} + assert normalized.tenant == "tenant-3" + assert normalized.requested_extensions == set() + assert isinstance(normalized.user, UnauthenticatedUser) diff --git a/tests/server/test_output_negotiation.py b/tests/server/test_output_negotiation.py index f6136a0..6b59d3f 100644 --- a/tests/server/test_output_negotiation.py +++ b/tests/server/test_output_negotiation.py @@ -32,6 +32,7 @@ ) from opencode_a2a.output_modes import ( NegotiatingResultAggregator, + annotate_output_negotiation_metadata, apply_accepted_output_modes, build_output_negotiation_metadata, extract_accepted_output_modes_from_metadata, @@ -133,6 +134,15 @@ def test_normalize_accepted_output_modes_treats_wildcards_as_unrestricted() -> N assert normalize_accepted_output_modes(["*"]) is None +def test_normalize_accepted_output_modes_reads_attribute_sources_and_rejects_scalars() -> None: + class _Config: + acceptedOutputModes = [" TEXT/PLAIN ", "application/json", "text/plain", 3] + + assert normalize_accepted_output_modes(_Config()) == ("text/plain", "application/json") + assert normalize_accepted_output_modes("text/plain") is None + assert normalize_accepted_output_modes({"accepted_output_modes": ["text/plain"]}) is None + + def test_apply_accepted_output_modes_downgrades_task_data_parts_to_text() -> None: task = Task( id="task-send", @@ -166,6 +176,57 @@ def test_apply_accepted_output_modes_downgrades_task_data_parts_to_text() -> Non assert downgraded.artifacts[0].parts[0].text == '{"status":"running","tool":"bash"}' +def test_apply_accepted_output_modes_can_drop_artifact_event_and_clear_message_parts() -> None: + artifact_event = TaskArtifactUpdateEvent( + task_id="task-1", + context_id="ctx-1", + artifact=Artifact( + artifact_id="artifact-1", + parts=[Part(text="hello")], + ), + append=False, + last_chunk=False, + ) + assert apply_accepted_output_modes(artifact_event, ["application/json"]) is None + + message = Message( + message_id="msg-1", + role=Role.ROLE_AGENT, + parts=[Part(text="hello")], + task_id="task-1", + context_id="ctx-1", + ) + cleared = apply_accepted_output_modes(message, ["application/json"]) + assert isinstance(cleared, Message) + assert len(cleared.parts) == 0 + + +def test_annotate_output_negotiation_metadata_preserves_existing_event_metadata() -> None: + status_event = TaskStatusUpdateEvent( + task_id="task-1", + context_id="ctx-1", + status=TaskStatus(state=TaskState.TASK_STATE_WORKING), + metadata={"shared": {"usage": {"total_tokens": 5}}}, + ) + annotated_status = annotate_output_negotiation_metadata(status_event, ["text/plain"]) + assert extract_accepted_output_modes_from_metadata(annotated_status.metadata) == ("text/plain",) + assert annotated_status.metadata["shared"]["usage"]["total_tokens"] == 5 + + artifact_event = TaskArtifactUpdateEvent( + task_id="task-1", + context_id="ctx-1", + artifact=Artifact(artifact_id="artifact-1", parts=[Part(text="hello")]), + append=False, + last_chunk=False, + metadata={"shared": {"stream": {"block_type": "text"}}}, + ) + annotated_artifact = annotate_output_negotiation_metadata(artifact_event, ["text/plain"]) + assert extract_accepted_output_modes_from_metadata(annotated_artifact.metadata) == ( + "text/plain", + ) + assert annotated_artifact.metadata["shared"]["stream"]["block_type"] == "text" + + @pytest.mark.asyncio async def test_negotiating_result_aggregator_persists_metadata_for_artifact_first_flow() -> None: store = _store() diff --git a/tests/test_a2a_utils.py b/tests/test_a2a_utils.py new file mode 100644 index 0000000..84150ba --- /dev/null +++ b/tests/test_a2a_utils.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import pytest +from a2a.types import ( + Artifact, + Message, + Part, + Role, + TaskArtifactUpdateEvent, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, +) +from google.protobuf.json_format import MessageToDict + +from opencode_a2a.a2a_utils import ( + clone_proto, + make_data_part, + replace_artifact_event_artifact, + replace_artifact_parts, + replace_message_parts, + replace_status_event_message, +) + + +def test_make_data_part_supports_nested_structured_payloads() -> None: + part = make_data_part( + { + "tool": "bash", + "ok": True, + "count": 2, + "items": [1, "two", None], + "nested": {"mode": "safe"}, + }, + metadata={"origin": "test"}, + ) + + assert MessageToDict(part.data) == { + "tool": "bash", + "ok": True, + "count": 2.0, + "items": [1.0, "two", None], + "nested": {"mode": "safe"}, + } + assert dict(part.metadata) == {"origin": "test"} + + +def test_make_data_part_rejects_unsupported_payload_type() -> None: + with pytest.raises(TypeError, match="Unsupported structured payload type"): + make_data_part(object()) + + +def test_proto_replacement_helpers_return_updated_copies() -> None: + original_message = Message( + message_id="msg-1", + role=Role.ROLE_AGENT, + parts=[Part(text="before")], + task_id="task-1", + context_id="ctx-1", + ) + cloned_message = clone_proto(original_message) + cloned_message.parts[0].text = "after" + + replaced_message = replace_message_parts(original_message, [Part(text="replacement")]) + assert original_message.parts[0].text == "before" + assert cloned_message.parts[0].text == "after" + assert replaced_message.parts[0].text == "replacement" + + original_artifact = Artifact(artifact_id="artifact-1", parts=[Part(text="draft")]) + replaced_artifact = replace_artifact_parts(original_artifact, [Part(text="final")]) + assert original_artifact.parts[0].text == "draft" + assert replaced_artifact.parts[0].text == "final" + + original_status_event = TaskStatusUpdateEvent( + task_id="task-1", + context_id="ctx-1", + status=TaskStatus(state=TaskState.TASK_STATE_WORKING, message=original_message), + ) + cleared_status_event = replace_status_event_message(original_status_event, None) + updated_status_event = replace_status_event_message( + original_status_event, + replaced_message, + ) + assert original_status_event.status.HasField("message") + assert cleared_status_event.status.HasField("message") is False + assert updated_status_event.status.message.parts[0].text == "replacement" + + original_artifact_event = TaskArtifactUpdateEvent( + task_id="task-1", + context_id="ctx-1", + artifact=original_artifact, + append=False, + last_chunk=False, + ) + updated_artifact_event = replace_artifact_event_artifact( + original_artifact_event, + replaced_artifact, + ) + assert original_artifact_event.artifact.parts[0].text == "draft" + assert updated_artifact_event.artifact.parts[0].text == "final" From 3a29a81804b1339f743ad6f22aae97983ad18150 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 12 May 2026 11:41:00 +0800 Subject: [PATCH 2/9] style: switch CLI text banner to single-line term logo --- src/opencode_a2a/cli.py | 16 +--------------- tests/server/test_cli.py | 5 +---- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 70663d4..b28b980 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -14,21 +14,7 @@ from .config import Settings from .server.application import main as serve_main -CLI_BRAND_BANNER = ( - " ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗ \n" - "██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ \n" - "██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗█████╗\n" - "██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝╚════╝\n" - "╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗ \n" - " ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ \n" - " \n" - " █████╗ ██████╗ █████╗ \n" - "██╔══██╗╚════██╗██╔══██╗ \n" - "███████║ █████╔╝███████║ \n" - "██╔══██║██╔═══╝ ██╔══██║ \n" - "██║ ██║███████╗██║ ██║ \n" - "╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ " -) +CLI_BRAND_BANNER = "OpenCode-A2A" PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" HELP_FLAGS = frozenset({"-h", "--help"}) diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index 1920ad7..4247a06 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -118,10 +118,7 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert ( "OpenCode A2A runtime for explicit service startup and peer calls. A2A Protocol 1.0 only." ) in help_text - assert "██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗" in help_text - assert "██║ ██║██████╔╝█████╗" in help_text - assert "█████╗█████╗" in help_text - assert "█████╗ ██████╗ █████╗" in help_text + assert "OpenCode-A2A" in help_text assert "opencode-a2a [arguments] [options]" in help_text assert "A2A_STATIC_AUTH_CREDENTIALS" in help_text assert "opencode serve --hostname 127.0.0.1 --port 4096" in help_text From 416be2b34e0198ee874e47f4f8e1d3e3e45c9466 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 12 May 2026 11:57:56 +0800 Subject: [PATCH 3/9] style: switch CLI banner to compact shadow logo --- src/opencode_a2a/cli.py | 8 +++++++- tests/server/test_cli.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index b28b980..069865c 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -14,7 +14,13 @@ from .config import Settings from .server.application import main as serve_main -CLI_BRAND_BANNER = "OpenCode-A2A" +CLI_BRAND_BANNER = ( + " _ \\\\ __| | \\\\ _ ) \\\\ \n" + " ( | _ \\\\ -_) \\\\ ( _ \\\\ _` | -_) ____| _ \\\\ / _ \\\\ \n" + " \\\\___/ .__/ \\\\___| _| _| \\\\___| \\\\___/ \\\\__,_|" + " \\\\___| _/ _\\\\ ___| _/ _\\\\ \n" + " _| " +) PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" HELP_FLAGS = frozenset({"-h", "--help"}) diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index 4247a06..2e45ebf 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -118,7 +118,7 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert ( "OpenCode A2A runtime for explicit service startup and peer calls. A2A Protocol 1.0 only." ) in help_text - assert "OpenCode-A2A" in help_text + assert " _ \\\\ __| |" in help_text assert "opencode-a2a [arguments] [options]" in help_text assert "A2A_STATIC_AUTH_CREDENTIALS" in help_text assert "opencode serve --hostname 127.0.0.1 --port 4096" in help_text From 040e709cfac9f9a6ce9c1f920bebe48233b6750a Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 12 May 2026 12:13:30 +0800 Subject: [PATCH 4/9] style: restore ANSI Shadow CLI banner --- src/opencode_a2a/cli.py | 17 ++++++++++++----- tests/server/test_cli.py | 3 ++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 069865c..d694f9e 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -15,11 +15,18 @@ from .server.application import main as serve_main CLI_BRAND_BANNER = ( - " _ \\\\ __| | \\\\ _ ) \\\\ \n" - " ( | _ \\\\ -_) \\\\ ( _ \\\\ _` | -_) ____| _ \\\\ / _ \\\\ \n" - " \\\\___/ .__/ \\\\___| _| _| \\\\___| \\\\___/ \\\\__,_|" - " \\\\___| _/ _\\\\ ___| _/ _\\\\ \n" - " _| " + " ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗ ██████╗" + " ██████╗ ███████╗ █████╗ ██████╗ █████╗ \n" + "██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔═══██╗" + "██╔══██╗██╔════╝ ██╔══██╗╚════██╗██╔══██╗\n" + "██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ██║" + "██║ ██║█████╗█████╗███████║ █████╔╝███████║\n" + "██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██║" + "██║ ██║██╔══╝╚════╝██╔══██║██╔═══╝ ██╔══██║\n" + "╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗╚██████╔╝" + "██████╔╝███████╗ ██║ ██║███████╗██║ ██║\n" + " ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝" + " ╚═════╝ ╚══════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝" ) PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" HELP_FLAGS = frozenset({"-h", "--help"}) diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index 2e45ebf..23c0b67 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -118,7 +118,8 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert ( "OpenCode A2A runtime for explicit service startup and peer calls. A2A Protocol 1.0 only." ) in help_text - assert " _ \\\\ __| |" in help_text + assert "██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗" in help_text + assert "███████║ █████╔╝███████║" in help_text assert "opencode-a2a [arguments] [options]" in help_text assert "A2A_STATIC_AUTH_CREDENTIALS" in help_text assert "opencode serve --hostname 127.0.0.1 --port 4096" in help_text From 9ebb8d11f251d0edbec98b83f1287a0764f5c41a Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 12 May 2026 13:46:00 +0800 Subject: [PATCH 5/9] docs: move serve help details under serve command --- src/opencode_a2a/cli.py | 2 +- tests/server/test_cli.py | 29 +++++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index d694f9e..747c063 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -101,7 +101,7 @@ " Service base URLs also work, but card URLs are the preferred example form." ) -ROOT_HELP_EPILOG = f"{OPENCODE_SETUP_HELP}\n\n{SERVE_ENVIRONMENT_HELP}\n\n{CALL_HELP}" +ROOT_HELP_EPILOG = "Command-specific help:\n opencode-a2a serve --help\n opencode-a2a call --help" SERVE_HELP_EPILOG = f"{OPENCODE_SETUP_HELP}\n\n{SERVE_ENVIRONMENT_HELP}" CALL_HELP_EPILOG = CALL_HELP diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index 23c0b67..f269085 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -121,10 +121,13 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert "██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗" in help_text assert "███████║ █████╔╝███████║" in help_text assert "opencode-a2a [arguments] [options]" in help_text - assert "A2A_STATIC_AUTH_CREDENTIALS" in help_text - assert "opencode serve --hostname 127.0.0.1 --port 4096" in help_text - assert "A2A_CLIENT_BEARER_TOKEN=peer-token" in help_text - assert "/.well-known/agent-card.json" in help_text + assert "Command-specific help:" in help_text + assert "opencode-a2a serve --help" in help_text + assert "opencode-a2a call --help" in help_text + assert "A2A_STATIC_AUTH_CREDENTIALS" not in help_text + assert "opencode serve --hostname 127.0.0.1 --port 4096" not in help_text + assert "A2A_CLIENT_BEARER_TOKEN=peer-token" not in help_text + assert "/.well-known/agent-card.json" not in help_text assert "{call}" not in help_text assert "serve" in help_text assert "deploy-release" not in help_text @@ -180,11 +183,29 @@ def test_cli_serve_subcommand_with_invalid_configuration_prints_help( help_text = capsys.readouterr().out assert "Run the OpenCode A2A service. A2A Protocol 1.0 only." in help_text + assert "A2A_STATIC_AUTH_CREDENTIALS" in help_text + assert "opencode serve --hostname 127.0.0.1 --port 4096" in help_text assert "configuration errors:" in help_text assert "Configure runtime authentication via A2A_STATIC_AUTH_CREDENTIALS" in help_text serve_mock.assert_not_called() +def test_cli_serve_help_prints_serve_specific_examples( + capsys: pytest.CaptureFixture[str], +) -> None: + with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: + with pytest.raises(SystemExit) as excinfo: + cli.main(["serve", "--help"]) + + assert excinfo.value.code == 0 + help_text = capsys.readouterr().out + assert "Run the OpenCode A2A service. A2A Protocol 1.0 only." in help_text + assert "A2A_STATIC_AUTH_CREDENTIALS" in help_text + assert "opencode serve --hostname 127.0.0.1 --port 4096" in help_text + assert "A2A_CLIENT_BEARER_TOKEN=peer-token" not in help_text + serve_mock.assert_not_called() + + def test_cli_call_without_required_arguments_prints_help( capsys: pytest.CaptureFixture[str], ) -> None: From b983294563cb597217fae9b94bbd3c95ff8f2d4d Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 12 May 2026 13:51:49 +0800 Subject: [PATCH 6/9] build: upgrade urllib3 to resolve runtime audit findings --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 6b091e4..deaed4a 100644 --- a/uv.lock +++ b/uv.lock @@ -1524,11 +1524,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From 451e3e4f602086cc430b6ab07848a55cd846a143 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 12 May 2026 14:11:00 +0800 Subject: [PATCH 7/9] docs: forbid Codex GitHub connector usage --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0b620f8..14061f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ The following rules apply to coding agent collaboration in this repository. Thes - Follow the Git, Issue, and PR workflow defined in [CONTRIBUTING.md](CONTRIBUTING.md). - Use `gh` CLI for all issue/PR operations (reading, writing, and comments). Do not edit through the web UI. +- Do not use the Codex GitHub connector for any repository operation. Use `gh` CLI exclusively for issue, PR, review, comment, and metadata workflows. +- If `gh` CLI cannot complete a required GitHub operation because of permissions, authentication, or environment limits, stop and ask the human to perform or unblock the operation. Do not fall back to the Codex GitHub connector. - Create a new tracking issue for any development task that does not already have one. - Link the issue explicitly in PR descriptions (e.g., `Closes #xx`). - Keep status updates synchronized to the relevant issue/PR to avoid duplicate manual work. From 7b23a1d636d25dd9c4297cc640004f896ae5f01a Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 12 May 2026 14:14:39 +0800 Subject: [PATCH 8/9] docs: sync README banner with CLI logo --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 21c53fa..2054fe4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # opencode-a2a ```text - ___ ____ _ _ ____ _ - / _ \ _ __ ___ _ __ / ___|___ __| | ___ / \ |___ \ / \ -| | | | '_ \ / _ \ '_ \| | / _ \ / _` |/ _ \_____ / _ \ __) | / _ \ -| |_| | |_) | __/ | | | |__| (_) | (_| | __/_____/ ___ \ / __/ / ___ \ - \___/| .__/ \___|_| |_|\____\___/ \__,_|\___| /_/ \_\_____/_/ \_\ - |_| + ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗ █████╗ ██████╗ █████╗ +██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔══██╗╚════██╗██╔══██╗ +██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗█████╗███████║ █████╔╝███████║ +██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝╚════╝██╔══██║██╔═══╝ ██╔══██║ +╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗ ██║ ██║███████╗██║ ██║ + ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ``` > Expose OpenCode through A2A. From c5c584268514fc6d761cbc94ff1dae842c4b9175 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Wed, 13 May 2026 08:28:33 +0800 Subject: [PATCH 9/9] style: add spacing before root CLI description --- src/opencode_a2a/cli.py | 2 +- tests/server/test_cli.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 747c063..8efacd6 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -230,7 +230,7 @@ def build_parser() -> argparse.ArgumentParser: CLI_BRAND_BANNER + "\n\n" + f"repo: {PROJECT_REPOSITORY_URL}\n" - + "uv tool install --upgrade opencode-a2a\n" + + "uv tool install --upgrade opencode-a2a\n\n" + ROOT_DESCRIPTION ), formatter_class=RootHelpFormatter, diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index f269085..25e6969 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -121,6 +121,7 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert "██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗" in help_text assert "███████║ █████╔╝███████║" in help_text assert "opencode-a2a [arguments] [options]" in help_text + assert "uv tool install --upgrade opencode-a2a\n\nOpenCode A2A runtime" in help_text assert "Command-specific help:" in help_text assert "opencode-a2a serve --help" in help_text assert "opencode-a2a call --help" in help_text