From b6c2b32cad15fb92f4850a32c9ffcfd8be765cf1 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 16:18:28 +0800 Subject: [PATCH 01/13] Improve CLI help and startup ergonomics --- README.md | 11 +++- docs/guide.md | 4 +- scripts/smoke_test_built_cli.sh | 2 +- src/opencode_a2a/cli.py | 63 +++++++++++++++++--- tests/scripts/test_script_health_contract.py | 1 + tests/server/test_cli.py | 35 +++++++---- 6 files changed, 92 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index aed6bc4f..5e37ae9b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # opencode-a2a +```text + ___ ____ _ _ _ + / _ \ _ __ ___ _ __/ ___|___ __| | ___ / \ | |_ +| | | | '_ \ / _ \ '_ \ | / _ \ / _` |/ _ \/ _ \ | __| +| |_| | |_) | __/ | | | |__| (_) | (_| | __/ ___ \ | |_ + \___/| .__/ \___|_| |_|\____\___/ \__,_|\___/_/ \_\ \__| + |_| A2A Runtime +``` + > Expose OpenCode through A2A. `opencode-a2a` adds an A2A runtime layer to `opencode serve`, with auth, streaming, session continuity, interrupt handling, and a clear deployment boundary. @@ -71,7 +80,7 @@ A2A_HOST=127.0.0.1 \ A2A_PORT=8000 \ A2A_PUBLIC_URL=http://127.0.0.1:8000 \ OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \ -opencode-a2a +opencode-a2a serve ``` Verify that the service is up: diff --git a/docs/guide.md b/docs/guide.md index 9dd70f5f..be31a62a 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -144,7 +144,7 @@ A2A_HOST=127.0.0.1 \ A2A_PORT=8000 \ A2A_PUBLIC_URL=http://127.0.0.1:8000 \ OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \ -opencode-a2a +opencode-a2a serve ``` By default, the service uses a SQLite-backed durable state store: @@ -154,7 +154,7 @@ DEMO_BEARER_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(24))')" OPENCODE_BASE_URL=http://127.0.0.1:4096 \ A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"'"${DEMO_BEARER_TOKEN}"'","principal":"automation"}]' \ A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \ -opencode-a2a +opencode-a2a serve ``` With the default `database` backend, the unified lightweight persistence layer persists: diff --git a/scripts/smoke_test_built_cli.sh b/scripts/smoke_test_built_cli.sh index 0d8bee9b..57d8d5f1 100644 --- a/scripts/smoke_test_built_cli.sh +++ b/scripts/smoke_test_built_cli.sh @@ -104,7 +104,7 @@ bearer_token="smoke-test-token" A2A_STATIC_AUTH_CREDENTIALS="[{\"scheme\":\"bearer\",\"token\":\"${bearer_token}\",\"principal\":\"automation\"}]" \ A2A_PORT="${port}" \ A2A_HOST="127.0.0.1" \ -"${tool_bin_dir}/opencode-a2a" >"${server_log}" 2>&1 & +"${tool_bin_dir}/opencode-a2a" serve >"${server_log}" 2>&1 & server_pid="$!" health_url="http://127.0.0.1:${port}/health" diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 6193a73d..96fa41d1 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -12,6 +12,34 @@ from .client import A2AClient, load_settings from .server.application import main as serve_main +CLI_BRAND_BANNER = ( + " ___ ____ _ _ _ \n" + " / _ \\ _ __ ___ _ __/ ___|___ __| | ___ / \\ | |_ \n" + "| | | | '_ \\ / _ \\ '_ \\ | / _ \\ / _` |/ _ \\/ _ \\ | __| \n" + "| |_| | |_) | __/ | | | |__| (_) | (_| | __/ ___ \\ | |_ \n" + " \\___/| .__/ \\___|_| |_|\\____\\___/ \\__,_|\\___/_/ \\_\\ \\__| \n" + " |_| A2A Runtime" +) +PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" + + +class RootHelpFormatter( + argparse.RawDescriptionHelpFormatter, + argparse.ArgumentDefaultsHelpFormatter, +): + """Preserve banner formatting while keeping argparse defaults.""" + + +class TopLevelArgumentParser(argparse.ArgumentParser): + """Drop the generated usage line from the top-level help output only.""" + + def format_help(self) -> str: + help_text = super().format_help() + lines = help_text.splitlines(keepends=True) + if lines and lines[0].startswith("usage:"): + help_text = "".join(lines[1:]).lstrip("\n") + return help_text.replace("\ncommands:\n command\n", "\ncommands:\n", 1) + async def run_call(agent_url: str, text: str) -> int: settings = load_settings(os.environ) @@ -47,21 +75,38 @@ async def run_call(agent_url: str, text: str) -> int: def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( + parser = TopLevelArgumentParser( prog="opencode-a2a", description=( - "OpenCode A2A runtime. Run without a subcommand to start the service." - " Deployment supervision is intentionally left to the operator." + CLI_BRAND_BANNER + + "\n\n" + + f"repo: {PROJECT_REPOSITORY_URL}\n" + + "uv tool install --upgrade opencode-a2a\n\n" + + "OpenCode A2A runtime for explicit service startup and peer calls.\n" + + " opencode-a2a [arguments] [options]" ), - formatter_class=argparse.ArgumentDefaultsHelpFormatter, + formatter_class=RootHelpFormatter, ) parser.add_argument( + "-v", "--version", action="version", version=f"%(prog)s {__version__}", + help="show program's version number and exit", + ) + + subparsers = parser.add_subparsers( + dest="command", + title="commands", + metavar="command", + parser_class=argparse.ArgumentParser, ) - subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser( + "serve", + help="Run the A2A service.", + description="Run the OpenCode A2A service.", + ) call_parser = subparsers.add_parser( "call", @@ -79,15 +124,19 @@ def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() if not args: - serve_main() + parser.print_help() return 0 namespace = parser.parse_args(args) + if namespace.command == "serve": + serve_main() + return 0 + if namespace.command == "call": return asyncio.run(run_call(namespace.agent_url, namespace.text)) if namespace.command is None: - serve_main() + parser.print_help() return 0 parser.error(f"Unknown command: {namespace.command}") diff --git a/tests/scripts/test_script_health_contract.py b/tests/scripts/test_script_health_contract.py index 6ec68830..114546aa 100644 --- a/tests/scripts/test_script_health_contract.py +++ b/tests/scripts/test_script_health_contract.py @@ -89,6 +89,7 @@ def test_smoke_test_imports_installed_package_before_health_check() -> None: def test_smoke_test_waits_quietly_for_health_and_surfaces_early_exit() -> None: + assert '"${tool_bin_dir}/opencode-a2a" serve >"${server_log}" 2>&1 &' in SMOKE_TEST_TEXT assert "wait_for_health()" in SMOKE_TEST_TEXT assert 'if ! kill -0 "${server_pid}" >/dev/null 2>&1; then' in SMOKE_TEST_TEXT assert "curl --silent --fail --output /dev/null \\" in SMOKE_TEST_TEXT diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index b04d3ca0..305ec669 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -14,37 +14,46 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert excinfo.value.code == 0 help_text = capsys.readouterr().out - assert "Run without a subcommand to start the service." in help_text - assert "{call}" in help_text - assert "serve" not in help_text + assert "OpenCode A2A runtime for explicit service startup and peer calls." in help_text + assert "A2A Runtime" in help_text + assert "opencode-a2a [arguments] [options]" in help_text + assert "{call}" not in help_text + assert "serve" in help_text assert "deploy-release" not in help_text assert "init-release-system" not in help_text assert "uninstall-instance" not in help_text serve_mock.assert_not_called() -def test_cli_serve_subcommand_is_rejected() -> None: - with pytest.raises(SystemExit) as excinfo: - cli.main(["serve"]) - - assert excinfo.value.code == 2 - - +@pytest.mark.parametrize("flag", ["-v", "--version"]) def test_cli_version_does_not_require_runtime_settings( + flag: str, capsys: pytest.CaptureFixture[str], ) -> None: with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: with pytest.raises(SystemExit) as excinfo: - cli.main(["--version"]) + cli.main([flag]) assert excinfo.value.code == 0 assert __version__ in capsys.readouterr().out serve_mock.assert_not_called() -def test_cli_defaults_to_serve_when_no_subcommand() -> None: +def test_cli_without_arguments_prints_help() -> None: + with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: + with mock.patch("opencode_a2a.cli.build_parser") as build_parser_mock: + parser = mock.MagicMock() + build_parser_mock.return_value = parser + + assert cli.main([]) == 0 + + parser.print_help.assert_called_once_with() + serve_mock.assert_not_called() + + +def test_cli_serve_subcommand_runs_service() -> None: with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: - assert cli.main([]) == 0 + assert cli.main(["serve"]) == 0 serve_mock.assert_called_once_with() From bb50037320f087e3cc8345ed6622bbdcb7dcebdc Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 16:27:37 +0800 Subject: [PATCH 02/13] Clean up legacy pending claim fallback --- src/opencode_a2a/server/migrations.py | 18 +++++- src/opencode_a2a/server/state_store.py | 39 ++--------- tests/server/test_state_store.py | 90 ++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 36 deletions(-) diff --git a/src/opencode_a2a/server/migrations.py b/src/opencode_a2a/server/migrations.py index d41ce42e..e956c35f 100644 --- a/src/opencode_a2a/server/migrations.py +++ b/src/opencode_a2a/server/migrations.py @@ -22,7 +22,7 @@ from sqlalchemy.engine import Connection STATE_STORE_SCHEMA_NAME = "state_store" -CURRENT_STATE_STORE_SCHEMA_VERSION = 4 +CURRENT_STATE_STORE_SCHEMA_VERSION = 5 _SCHEMA_VERSION_METADATA = MetaData() @@ -124,6 +124,18 @@ def _migration_4_add_interrupt_credential_id( ) +def _migration_5_drop_legacy_pending_claim_rows( + connection: Connection, + *, + pending_session_claims_table: Table, +) -> None: + connection.execute( + pending_session_claims_table.delete().where( + pending_session_claims_table.c.expires_at.is_(None) + ) + ) + + def _read_schema_version( connection: Connection, *, @@ -230,6 +242,10 @@ def migrate_state_store_schema( conn, interrupt_requests_table=interrupt_requests_table, ), + 5: lambda conn: _migration_5_drop_legacy_pending_claim_rows( + conn, + pending_session_claims_table=pending_session_claims_table, + ), } return _apply_schema_migrations( connection, diff --git a/src/opencode_a2a/server/state_store.py b/src/opencode_a2a/server/state_store.py index 3c42b48f..74118d28 100644 --- a/src/opencode_a2a/server/state_store.py +++ b/src/opencode_a2a/server/state_store.py @@ -16,7 +16,6 @@ and_, delete, insert, - or_, select, update, ) @@ -122,20 +121,6 @@ def _initialize_state_store_schema(connection) -> None: # noqa: ANN001 ) -def _pending_claim_expires_at( - row: Mapping[str, Any], - *, - ttl_seconds: float, -) -> float | None: - expires_at = row.get("expires_at") - if expires_at is not None: - return float(expires_at) - updated_at = row.get("updated_at") - if updated_at is None: - return None - return float(updated_at) + max(0.0, ttl_seconds) - - class SessionStateRepository(ABC): @abstractmethod async def get_session(self, *, identity: str, context_id: str) -> str | None: ... @@ -304,21 +289,9 @@ async def _prune_expired_pending_claims( return await session.execute( delete(_PENDING_SESSION_CLAIMS).where( - or_( - and_( - _PENDING_SESSION_CLAIMS.c.expires_at.is_not(None), - _PENDING_SESSION_CLAIMS.c.expires_at <= now, - ), - and_( - _PENDING_SESSION_CLAIMS.c.expires_at.is_(None), - _PENDING_SESSION_CLAIMS.c.updated_at.is_not(None), - _PENDING_SESSION_CLAIMS.c.updated_at - <= now - self._pending_claim_ttl_seconds, - ), - and_( - _PENDING_SESSION_CLAIMS.c.expires_at.is_(None), - _PENDING_SESSION_CLAIMS.c.updated_at.is_(None), - ), + and_( + _PENDING_SESSION_CLAIMS.c.expires_at.is_not(None), + _PENDING_SESSION_CLAIMS.c.expires_at <= now, ) ) ) @@ -392,10 +365,7 @@ async def get_pending_claim(self, *, session_id: str) -> str | None: row = cast("Mapping[str, Any] | None", result.mappings().one_or_none()) if row is None: return None - expires_at = _pending_claim_expires_at( - row, - ttl_seconds=self._pending_claim_ttl_seconds, - ) + expires_at = row.get("expires_at") if expires_at is None or expires_at <= now: await session.execute( delete(_PENDING_SESSION_CLAIMS).where( @@ -423,7 +393,6 @@ async def set_pending_claim(self, *, session_id: str, identity: str) -> None: key_values={"session_id": session_id}, update_values={ "identity": identity, - "updated_at": now, "expires_at": now + self._pending_claim_ttl_seconds, }, ) diff --git a/tests/server/test_state_store.py b/tests/server/test_state_store.py index c040191f..6732b570 100644 --- a/tests/server/test_state_store.py +++ b/tests/server/test_state_store.py @@ -256,6 +256,7 @@ def _now() -> float: stored_row = await _read_pending_claim_row(engine, "ses-1") assert stored_row is not None + assert stored_row["updated_at"] is None assert stored_row["expires_at"] == pytest.approx(105.0) reader = DatabaseSessionStateRepository( @@ -274,6 +275,95 @@ def _now() -> float: await engine.dispose() +@pytest.mark.asyncio +async def test_database_state_store_drops_legacy_pending_claim_rows_during_migration( + tmp_path: Path, +) -> None: + database_url = f"sqlite+aiosqlite:///{tmp_path / 'legacy-pending-claim.db'}" + settings = make_settings( + test_bearer_token="test-token", + a2a_task_store_database_url=database_url, + ) + engine = build_database_engine(settings) + + async with engine.begin() as conn: + await conn.execute( + text( + """ + CREATE TABLE a2a_session_bindings ( + identity VARCHAR NOT NULL, + context_id VARCHAR NOT NULL, + session_id VARCHAR NOT NULL, + PRIMARY KEY (identity, context_id) + ) + """ + ) + ) + await conn.execute( + text( + """ + CREATE TABLE a2a_session_owners ( + session_id VARCHAR NOT NULL PRIMARY KEY, + identity VARCHAR NOT NULL + ) + """ + ) + ) + await conn.execute( + text( + """ + CREATE TABLE a2a_pending_session_claims ( + session_id VARCHAR NOT NULL PRIMARY KEY, + identity VARCHAR NOT NULL, + updated_at FLOAT NOT NULL + ) + """ + ) + ) + await conn.execute( + text( + """ + INSERT INTO a2a_pending_session_claims ( + session_id, + identity, + updated_at + ) VALUES ( + 'ses-legacy', + 'user-legacy', + 100.0 + ) + """ + ) + ) + await conn.execute( + text( + """ + CREATE TABLE a2a_interrupt_requests ( + request_id VARCHAR NOT NULL PRIMARY KEY, + session_id VARCHAR, + interrupt_type VARCHAR, + identity VARCHAR, + credential_id VARCHAR, + task_id VARCHAR, + context_id VARCHAR, + details_json VARCHAR, + expires_at FLOAT, + tombstone_expires_at FLOAT + ) + """ + ) + ) + + repository = build_session_state_repository(settings, engine=engine) + await initialize_state_repository(repository) + + assert await repository.get_pending_claim(session_id="ses-legacy") is None + assert await _read_pending_claim_row(engine, "ses-legacy") is None + assert await _read_state_store_schema_version(engine) == CURRENT_STATE_STORE_SCHEMA_VERSION + + await engine.dispose() + + @pytest.mark.asyncio async def test_database_session_binding_and_owner_do_not_expire_with_time(tmp_path: Path) -> None: now = 100.0 From 9171b4a29cf34d36420dbc372ebafcaff8c353cb Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 16:35:35 +0800 Subject: [PATCH 03/13] Fix ASCII logo alignment --- README.md | 8 ++++---- src/opencode_a2a/cli.py | 16 +++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5e37ae9b..f8fca51c 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # opencode-a2a ```text - ___ ____ _ _ _ - / _ \ _ __ ___ _ __/ ___|___ __| | ___ / \ | |_ +___ ____ _ _ _ +/ _ \ _ __ ___ _ __/ ___|___ __| | ___ / \ | |_ | | | | '_ \ / _ \ '_ \ | / _ \ / _` |/ _ \/ _ \ | __| | |_| | |_) | __/ | | | |__| (_) | (_| | __/ ___ \ | |_ - \___/| .__/ \___|_| |_|\____\___/ \__,_|\___/_/ \_\ \__| - |_| A2A Runtime +\___/| .__/ \___|_| |_|\____\___/ \__,_|\___/_/ \_\ \__| + |_| A2A Runtime ``` > Expose OpenCode through A2A. diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 96fa41d1..e36bf0df 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -12,13 +12,15 @@ from .client import A2AClient, load_settings from .server.application import main as serve_main -CLI_BRAND_BANNER = ( - " ___ ____ _ _ _ \n" - " / _ \\ _ __ ___ _ __/ ___|___ __| | ___ / \\ | |_ \n" - "| | | | '_ \\ / _ \\ '_ \\ | / _ \\ / _` |/ _ \\/ _ \\ | __| \n" - "| |_| | |_) | __/ | | | |__| (_) | (_| | __/ ___ \\ | |_ \n" - " \\___/| .__/ \\___|_| |_|\\____\\___/ \\__,_|\\___/_/ \\_\\ \\__| \n" - " |_| A2A Runtime" +CLI_BRAND_BANNER = "\n".join( + [ + "___ ____ _ _ _", + "/ _ \\ _ __ ___ _ __/ ___|___ __| | ___ / \\ | |_", + "| | | | '_ \\ / _ \\ '_ \\ | / _ \\ / _` |/ _ \\/ _ \\ | __|", + "| |_| | |_) | __/ | | | |__| (_) | (_| | __/ ___ \\ | |_", + "\\___/| .__/ \\___|_| |_|\\____\\___/ \\__,_|\\___/_/ \\_\\ \\__|", + " |_| A2A Runtime", + ] ) PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" From 92ca08c9c2c954747692f08b7acac6b48d254f3e Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 16:41:33 +0800 Subject: [PATCH 04/13] Refine CLI banner and protocol warning --- README.md | 20 ++++++++++++++------ src/opencode_a2a/cli.py | 28 ++++++++++++++++++---------- tests/server/test_cli.py | 4 ++++ 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f8fca51c..e845afdf 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ # opencode-a2a ```text -___ ____ _ _ _ -/ _ \ _ __ ___ _ __/ ___|___ __| | ___ / \ | |_ -| | | | '_ \ / _ \ '_ \ | / _ \ / _` |/ _ \/ _ \ | __| -| |_| | |_) | __/ | | | |__| (_) | (_| | __/ ___ \ | |_ -\___/| .__/ \___|_| |_|\____\___/ \__,_|\___/_/ \_\ \__| - |_| A2A Runtime + ___ ____ _ + / _ \ _ __ ___ _ __ / ___|___ __| | ___ +| | | | '_ \ / _ \ '_ \\___ / _ \ / _` |/ _ \ +| |_| | |_) | __/ | | |__) | (_) | (_| | __/ + \___/| .__/ \___|_| |_|____/ \___/ \__,_|\___| + |_| + _ ____ _ + / \ |___ \ / \ + / _ \ __) |/ _ \ + / ___ \ / __// ___ \ +/_/ \_\_____/_/ \_\ + A2A Runtime ``` > Expose OpenCode through A2A. `opencode-a2a` adds an A2A runtime layer to `opencode serve`, with auth, streaming, session continuity, interrupt handling, and a clear deployment boundary. +> Protocol boundary: A2A `1.0` only. Legacy `0.3` clients, method aliases, and payload shapes are not supported. + ## What This Is - An A2A adapter service built on `opencode serve`, with inbound runtime exposure plus outbound peer calling. diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index e36bf0df..3b7cc76a 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -12,15 +12,19 @@ from .client import A2AClient, load_settings from .server.application import main as serve_main -CLI_BRAND_BANNER = "\n".join( - [ - "___ ____ _ _ _", - "/ _ \\ _ __ ___ _ __/ ___|___ __| | ___ / \\ | |_", - "| | | | '_ \\ / _ \\ '_ \\ | / _ \\ / _` |/ _ \\/ _ \\ | __|", - "| |_| | |_) | __/ | | | |__| (_) | (_| | __/ ___ \\ | |_", - "\\___/| .__/ \\___|_| |_|\\____\\___/ \\__,_|\\___/_/ \\_\\ \\__|", - " |_| A2A Runtime", - ] +CLI_BRAND_BANNER = ( + " ___ ____ _\n" + " / _ \\ _ __ ___ _ __ / ___|___ __| | ___\n" + "| | | | '_ \\ / _ \\ '_ \\\\___ / _ \\ / _` |/ _ \\\n" + "| |_| | |_) | __/ | | |__) | (_) | (_| | __/\n" + " \\___/| .__/ \\___|_| |_|____/ \\___/ \\__,_|\\___|\n" + " |_|\n" + " _ ____ _\n" + " / \\ |___ \\ / \\\n" + " / _ \\ __) |/ _ \\\n" + " / ___ \\ / __// ___ \\\n" + "/_/ \\_\\_____/_/ \\_\\\n" + " A2A Runtime" ) PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" @@ -83,7 +87,11 @@ def build_parser() -> argparse.ArgumentParser: CLI_BRAND_BANNER + "\n\n" + f"repo: {PROJECT_REPOSITORY_URL}\n" - + "uv tool install --upgrade opencode-a2a\n\n" + + "uv tool install --upgrade opencode-a2a\n" + + ( + "protocol: A2A 1.0 only; " + "not compatible with legacy 0.3 clients, methods, or payloads\n\n" + ) + "OpenCode A2A runtime for explicit service startup and peer calls.\n" + " opencode-a2a [arguments] [options]" ), diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index 305ec669..d3ca9ba4 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -16,6 +16,10 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur help_text = capsys.readouterr().out assert "OpenCode A2A runtime for explicit service startup and peer calls." in help_text assert "A2A Runtime" in help_text + assert ( + "protocol: A2A 1.0 only; not compatible with legacy 0.3 clients, methods, or payloads" + in help_text + ) assert "opencode-a2a [arguments] [options]" in help_text assert "{call}" not in help_text assert "serve" in help_text From 300c5e36be12969cce06e27eff4fae059d8bafc2 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 16:46:04 +0800 Subject: [PATCH 05/13] Simplify CLI banner and protocol copy --- README.md | 15 ++------------- src/opencode_a2a/cli.py | 20 ++------------------ tests/server/test_cli.py | 7 ++----- 3 files changed, 6 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index e845afdf..1b93faba 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,14 @@ # opencode-a2a ```text - ___ ____ _ - / _ \ _ __ ___ _ __ / ___|___ __| | ___ -| | | | '_ \ / _ \ '_ \\___ / _ \ / _` |/ _ \ -| |_| | |_) | __/ | | |__) | (_) | (_| | __/ - \___/| .__/ \___|_| |_|____/ \___/ \__,_|\___| - |_| - _ ____ _ - / \ |___ \ / \ - / _ \ __) |/ _ \ - / ___ \ / __// ___ \ -/_/ \_\_____/_/ \_\ - A2A Runtime +OpenCode-A2A ``` > Expose OpenCode through A2A. `opencode-a2a` adds an A2A runtime layer to `opencode serve`, with auth, streaming, session continuity, interrupt handling, and a clear deployment boundary. -> Protocol boundary: A2A `1.0` only. Legacy `0.3` clients, method aliases, and payload shapes are not supported. +> A2A Protocol `1.0` only. ## What This Is diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 3b7cc76a..53d4a0b6 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -12,20 +12,7 @@ from .client import A2AClient, load_settings from .server.application import main as serve_main -CLI_BRAND_BANNER = ( - " ___ ____ _\n" - " / _ \\ _ __ ___ _ __ / ___|___ __| | ___\n" - "| | | | '_ \\ / _ \\ '_ \\\\___ / _ \\ / _` |/ _ \\\n" - "| |_| | |_) | __/ | | |__) | (_) | (_| | __/\n" - " \\___/| .__/ \\___|_| |_|____/ \\___/ \\__,_|\\___|\n" - " |_|\n" - " _ ____ _\n" - " / \\ |___ \\ / \\\n" - " / _ \\ __) |/ _ \\\n" - " / ___ \\ / __// ___ \\\n" - "/_/ \\_\\_____/_/ \\_\\\n" - " A2A Runtime" -) +CLI_BRAND_BANNER = "OpenCode-A2A" PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" @@ -88,10 +75,7 @@ def build_parser() -> argparse.ArgumentParser: + "\n\n" + f"repo: {PROJECT_REPOSITORY_URL}\n" + "uv tool install --upgrade opencode-a2a\n" - + ( - "protocol: A2A 1.0 only; " - "not compatible with legacy 0.3 clients, methods, or payloads\n\n" - ) + + "A2A Protocol 1.0 only.\n\n" + "OpenCode A2A runtime for explicit service startup and peer calls.\n" + " opencode-a2a [arguments] [options]" ), diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index d3ca9ba4..42f4a50a 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -15,11 +15,8 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert excinfo.value.code == 0 help_text = capsys.readouterr().out assert "OpenCode A2A runtime for explicit service startup and peer calls." in help_text - assert "A2A Runtime" in help_text - assert ( - "protocol: A2A 1.0 only; not compatible with legacy 0.3 clients, methods, or payloads" - in help_text - ) + assert "OpenCode-A2A" in help_text + assert "A2A Protocol 1.0 only." in help_text assert "opencode-a2a [arguments] [options]" in help_text assert "{call}" not in help_text assert "serve" in help_text From 8279213926a7d135a2a17b44c8cdc4e0ac783383 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 16:48:35 +0800 Subject: [PATCH 06/13] Restore concatenated CLI logo string --- src/opencode_a2a/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 53d4a0b6..73e605a7 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -12,7 +12,7 @@ from .client import A2AClient, load_settings from .server.application import main as serve_main -CLI_BRAND_BANNER = "OpenCode-A2A" +CLI_BRAND_BANNER = "".join(("OpenCode", "-", "A2A")) PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" From a17e9659fc941f9dd7ba2496074ee89bf5fbb04d Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 16:53:17 +0800 Subject: [PATCH 07/13] Restore ASCII CLI logo banner --- README.md | 6 +++++- src/opencode_a2a/cli.py | 8 +++++++- tests/server/test_cli.py | 3 ++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1b93faba..6e23e7c5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ # opencode-a2a ```text -OpenCode-A2A + ___ ___ _ _ ___ _ + / _ \ _ __ ___ _ _ / __|___ __| |___ ___ /_\ |_ ) /_\ +| (_) | '_ \/ -_) ' \\ (__/ _ \/ _` / -_)___/ _ \ / / / _ \ + \___/| .__/\___|_||_\___\___/\__,_\___| /_/ \_\/___/_/ \_\ + |_| ``` > Expose OpenCode through A2A. diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 73e605a7..7d223884 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -12,7 +12,13 @@ from .client import A2AClient, load_settings from .server.application import main as serve_main -CLI_BRAND_BANNER = "".join(("OpenCode", "-", "A2A")) +CLI_BRAND_BANNER = ( + " ___ ___ _ _ ___ _ \n" + " / _ \\ _ __ ___ _ _ / __|___ __| |___ ___ /_\\ |_ ) /_\\ \n" + "| (_) | '_ \\/ -_) ' \\\\ (__/ _ \\/ _` / -_)___/ _ \\ / / / _ \\ \n" + " \\___/| .__/\\___|_||_\\___\\___/\\__,_\\___| /_/ \\_\\/___/_/ \\_\\\n" + " |_| " +) PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index 42f4a50a..e60ecec3 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -15,7 +15,8 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert excinfo.value.code == 0 help_text = capsys.readouterr().out assert "OpenCode A2A runtime for explicit service startup and peer calls." in help_text - assert "OpenCode-A2A" in help_text + assert "___ ___" in help_text + assert "| (_) | '_ \\/ -_) ' \\\\ (__/ _ \\/ _` / -_)___/ _ \\ / / / _ \\" in help_text assert "A2A Protocol 1.0 only." in help_text assert "opencode-a2a [arguments] [options]" in help_text assert "{call}" not in help_text From f0c8ab95474650f5b63b953085ff7968ae72fa19 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 16:59:21 +0800 Subject: [PATCH 08/13] Improve ASCII logo readability --- README.md | 9 +++++---- src/opencode_a2a/cli.py | 11 ++++++----- tests/server/test_cli.py | 4 ++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6e23e7c5..72b3a569 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # opencode-a2a ```text - ___ ___ _ _ ___ _ - / _ \ _ __ ___ _ _ / __|___ __| |___ ___ /_\ |_ ) /_\ -| (_) | '_ \/ -_) ' \\ (__/ _ \/ _` / -_)___/ _ \ / / / _ \ - \___/| .__/\___|_||_\___\___/\__,_\___| /_/ \_\/___/_/ \_\ + ___ ____ _ _ ____ _ + / _ \ _ __ ___ _ __ / ___|___ __| | ___ / \ |___ \ / \ +| | | | '_ \ / _ \ '_ \| | / _ \ / _` |/ _ \_____ / _ \ __) | / _ \ +| |_| | |_) | __/ | | | |__| (_) | (_| | __/_____/ ___ \ / __/ / ___ \ + \___/| .__/ \___|_| |_|\____\___/ \__,_|\___| /_/ \_\_____/_/ \_\ |_| ``` diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 7d223884..40e0c883 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -13,11 +13,12 @@ from .server.application import main as serve_main CLI_BRAND_BANNER = ( - " ___ ___ _ _ ___ _ \n" - " / _ \\ _ __ ___ _ _ / __|___ __| |___ ___ /_\\ |_ ) /_\\ \n" - "| (_) | '_ \\/ -_) ' \\\\ (__/ _ \\/ _` / -_)___/ _ \\ / / / _ \\ \n" - " \\___/| .__/\\___|_||_\\___\\___/\\__,_\\___| /_/ \\_\\/___/_/ \\_\\\n" - " |_| " + " ___ ____ _ _ ____ _ \n" + " / _ \\ _ __ ___ _ __ / ___|___ __| | ___ / \\ |___ \\ / \\ \n" + "| | | | '_ \\ / _ \\ '_ \\| | / _ \\ / _` |/ _ \\_____ / _ \\ __) | / _ \\ \n" + "| |_| | |_) | __/ | | | |__| (_) | (_| | __/_____/ ___ \\ / __/ / ___ \\ \n" + " \\___/| .__/ \\___|_| |_|\\____\\___/ \\__,_|\\___| /_/ \\_\\_____/_/ \\_\\\n" + " |_| " ) PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index e60ecec3..490ae6f9 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -15,8 +15,8 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert excinfo.value.code == 0 help_text = capsys.readouterr().out assert "OpenCode A2A runtime for explicit service startup and peer calls." in help_text - assert "___ ___" in help_text - assert "| (_) | '_ \\/ -_) ' \\\\ (__/ _ \\/ _` / -_)___/ _ \\ / / / _ \\" in help_text + assert "___ ____" in help_text + assert "| | | | '_ \\ / _ \\ '_ \\| | / _ \\ / _` |/ _ \\_____ / _ \\" in help_text assert "A2A Protocol 1.0 only." in help_text assert "opencode-a2a [arguments] [options]" in help_text assert "{call}" not in help_text From e651dd51fbb066f8f9615ab3d6efb6828c0a1754 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 17:08:56 +0800 Subject: [PATCH 09/13] Expand CLI help and guard missing args --- src/opencode_a2a/cli.py | 153 +++++++++++++++++++++++++++++++++++++-- tests/server/test_cli.py | 70 ++++++++++++++++-- 2 files changed, 208 insertions(+), 15 deletions(-) diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index 40e0c883..e1ff2712 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -7,9 +7,11 @@ from collections.abc import Sequence from a2a.types import TaskState +from pydantic import ValidationError from . import __version__ from .client import A2AClient, load_settings +from .config import Settings from .server.application import main as serve_main CLI_BRAND_BANNER = ( @@ -21,6 +23,80 @@ " |_| " ) PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a" +HELP_FLAGS = frozenset({"-h", "--help"}) + +ROOT_DESCRIPTION = ( + "OpenCode A2A runtime for explicit service startup and peer calls. " + "A2A Protocol 1.0 only.\n" + " opencode-a2a [arguments] [options]" +) + +OPENCODE_SETUP_HELP = ( + "OpenCode upstream quick start:\n" + " opencode auth login\n" + " opencode models\n" + " opencode serve --hostname 127.0.0.1 --port 4096\n" + "\n" + "OpenCode note:\n" + " Configure provider auth and a default model before starting opencode serve.\n" + " If provider auth comes from environment variables, export them before launch." +) + +SERVE_ENVIRONMENT_HELP = ( + "Serve required environment:\n" + " A2A_STATIC_AUTH_CREDENTIALS\n" + " JSON array with at least one enabled bearer/basic credential.\n" + "\n" + "Serve common environment:\n" + " OPENCODE_BASE_URL\n" + " Upstream opencode serve URL. Default: http://127.0.0.1:4096\n" + " A2A_HOST\n" + " Bind host. Default: 127.0.0.1\n" + " A2A_PORT\n" + " Bind port. Default: 8000\n" + " A2A_PUBLIC_URL\n" + " Public base URL advertised in the agent card. Default: http://127.0.0.1:8000\n" + " A2A_TASK_STORE_BACKEND\n" + " database or memory. Default: database\n" + " A2A_TASK_STORE_DATABASE_URL\n" + " SQLAlchemy database URL. Default: sqlite+aiosqlite:///./opencode-a2a.db\n" + " OPENCODE_WORKSPACE_ROOT\n" + " Workspace root exposed to OpenCode tool execution.\n" + "\n" + "Serve minimal example:\n" + " DEMO_BEARER_TOKEN=\"$(python3 -c 'import secrets; print(secrets.token_hex(24))')\"\n" + " A2A_STATIC_AUTH_CREDENTIALS=" + '\'[{"scheme":"bearer","token":"\'"${DEMO_BEARER_TOKEN}"\'","principal":"automation"}]\'' + " \\\n" + " OPENCODE_BASE_URL=http://127.0.0.1:4096 \\\n" + " opencode-a2a serve\n" + "\n" + "Serve durable SQLite example:\n" + " DEMO_BEARER_TOKEN=\"$(python3 -c 'import secrets; print(secrets.token_hex(24))')\"\n" + " A2A_STATIC_AUTH_CREDENTIALS=" + '\'[{"scheme":"bearer","token":"\'"${DEMO_BEARER_TOKEN}"\'","principal":"automation"}]\'' + " \\\n" + " OPENCODE_BASE_URL=http://127.0.0.1:4096 \\\n" + " A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \\\n" + " OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \\\n" + " opencode-a2a serve" +) + +CALL_HELP = ( + "Call examples:\n" + " A2A_CLIENT_BEARER_TOKEN=peer-token \\\n" + ' opencode-a2a call http://other-agent:8000 "How are you?"\n' + "\n" + ' A2A_CLIENT_BASIC_AUTH="user:pass" \\\n' + ' opencode-a2a call http://other-agent:8000 "How are you?"\n' + "\n" + "Call note:\n" + " Outbound peer credentials are read from environment variables only." +) + +ROOT_HELP_EPILOG = f"{OPENCODE_SETUP_HELP}\n\n{SERVE_ENVIRONMENT_HELP}\n\n{CALL_HELP}" +SERVE_HELP_EPILOG = f"{OPENCODE_SETUP_HELP}\n\n{SERVE_ENVIRONMENT_HELP}" +CALL_HELP_EPILOG = CALL_HELP class RootHelpFormatter( @@ -41,6 +117,56 @@ def format_help(self) -> str: return help_text.replace("\ncommands:\n command\n", "\ncommands:\n", 1) +def _find_subparser( + parser: argparse.ArgumentParser, + name: str, +) -> argparse.ArgumentParser | None: + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + return action.choices.get(name) + return None + + +def _format_settings_errors(exc: ValidationError) -> list[str]: + errors: list[str] = [] + for error in exc.errors(include_url=False): + message = str(error.get("msg", "Invalid configuration")) + if message.startswith("Value error, "): + message = message.removeprefix("Value error, ") + location = ".".join(str(part) for part in error.get("loc", ()) if str(part) != "__root__") + errors.append(f"{location}: {message}" if location else message) + return errors or [str(exc)] + + +def validate_serve_configuration() -> list[str]: + try: + Settings() + except ValidationError as exc: + return _format_settings_errors(exc) + return [] + + +def print_help_with_details( + parser: argparse.ArgumentParser, + *, + errors: Sequence[str] = (), +) -> None: + parser.print_help() + if errors: + print("\nconfiguration errors:") + for error in errors: + print(f" - {error}") + + +def should_show_call_help(args: Sequence[str]) -> bool: + if not args or args[0] != "call": + return False + if any(token in HELP_FLAGS for token in args[1:]): + return False + positional_count = sum(1 for token in args[1:] if not token.startswith("-")) + return positional_count < 2 + + async def run_call(agent_url: str, text: str) -> int: settings = load_settings(os.environ) client = A2AClient(agent_url, settings=settings) @@ -82,11 +208,10 @@ def build_parser() -> argparse.ArgumentParser: + "\n\n" + f"repo: {PROJECT_REPOSITORY_URL}\n" + "uv tool install --upgrade opencode-a2a\n" - + "A2A Protocol 1.0 only.\n\n" - + "OpenCode A2A runtime for explicit service startup and peer calls.\n" - + " opencode-a2a [arguments] [options]" + + ROOT_DESCRIPTION ), formatter_class=RootHelpFormatter, + epilog=ROOT_HELP_EPILOG, ) parser.add_argument( "-v", @@ -106,13 +231,17 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser( "serve", help="Run the A2A service.", - description="Run the OpenCode A2A service.", + description="Run the OpenCode A2A service. A2A Protocol 1.0 only.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=SERVE_HELP_EPILOG, ) call_parser = subparsers.add_parser( "call", help="Call an A2A agent.", - description="Call an A2A agent using the A2A protocol.", + description="Call an A2A agent using the A2A protocol. A2A Protocol 1.0 only.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=CALL_HELP_EPILOG, ) call_parser.add_argument("agent_url", help="URL of the agent to call.") call_parser.add_argument("text", help="Text message to send.") @@ -123,13 +252,23 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: args = list(sys.argv[1:] if argv is None else argv) parser = build_parser() + call_parser = _find_subparser(parser, "call") + serve_parser = _find_subparser(parser, "serve") if not args: - parser.print_help() + print_help_with_details(parser) + return 0 + + if should_show_call_help(args) and call_parser is not None: + print_help_with_details(call_parser) return 0 namespace = parser.parse_args(args) if namespace.command == "serve": + configuration_errors = validate_serve_configuration() + if configuration_errors and serve_parser is not None: + print_help_with_details(serve_parser, errors=configuration_errors) + return 0 serve_main() return 0 @@ -137,7 +276,7 @@ def main(argv: Sequence[str] | None = None) -> int: return asyncio.run(run_call(namespace.agent_url, namespace.text)) if namespace.command is None: - parser.print_help() + print_help_with_details(parser) return 0 parser.error(f"Unknown command: {namespace.command}") diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index 490ae6f9..c182dd02 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -14,11 +14,15 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur assert excinfo.value.code == 0 help_text = capsys.readouterr().out - assert "OpenCode A2A runtime for explicit service startup and peer calls." in help_text + 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 "A2A Protocol 1.0 only." 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 "{call}" not in help_text assert "serve" in help_text assert "deploy-release" not in help_text @@ -44,22 +48,72 @@ def test_cli_version_does_not_require_runtime_settings( def test_cli_without_arguments_prints_help() -> None: with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: with mock.patch("opencode_a2a.cli.build_parser") as build_parser_mock: - parser = mock.MagicMock() - build_parser_mock.return_value = parser + with mock.patch("opencode_a2a.cli.print_help_with_details") as print_help_mock: + parser = mock.MagicMock() + build_parser_mock.return_value = parser - assert cli.main([]) == 0 + assert cli.main([]) == 0 - parser.print_help.assert_called_once_with() + print_help_mock.assert_called_once_with(parser) serve_mock.assert_not_called() def test_cli_serve_subcommand_runs_service() -> None: - with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: - assert cli.main(["serve"]) == 0 + with mock.patch("opencode_a2a.cli.validate_serve_configuration", return_value=[]): + with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: + assert cli.main(["serve"]) == 0 serve_mock.assert_called_once_with() +def test_cli_serve_subcommand_with_invalid_configuration_prints_help( + capsys: pytest.CaptureFixture[str], +) -> None: + with mock.patch( + "opencode_a2a.cli.validate_serve_configuration", + return_value=["Configure runtime authentication via A2A_STATIC_AUTH_CREDENTIALS"], + ): + with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: + assert cli.main(["serve"]) == 0 + + help_text = capsys.readouterr().out + assert "Run the OpenCode A2A service. A2A Protocol 1.0 only." 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_call_without_required_arguments_prints_help( + capsys: pytest.CaptureFixture[str], +) -> None: + with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: + assert cli.main(["call"]) == 0 + + help_text = capsys.readouterr().out + assert "Call an A2A agent using the A2A protocol. A2A Protocol 1.0 only." in help_text + assert "A2A_CLIENT_BEARER_TOKEN=peer-token" in help_text + serve_mock.assert_not_called() + + +def test_cli_call_with_partial_required_arguments_prints_help( + capsys: pytest.CaptureFixture[str], +) -> None: + with mock.patch("opencode_a2a.cli.serve_main") as serve_mock: + assert cli.main(["call", "http://agent.example.com"]) == 0 + + help_text = capsys.readouterr().out + assert "usage: opencode-a2a call" in help_text + assert "Text message to send." in help_text + serve_mock.assert_not_called() + + +def test_cli_call_help_is_not_intercepted_when_explicit_flag_is_present() -> None: + with pytest.raises(SystemExit) as excinfo: + cli.main(["call", "--help"]) + + assert excinfo.value.code == 0 + + def test_cli_call_rejects_bearer_flag() -> None: parser = cli.build_parser() From d50d9b446b2d84f7d0c2d621a9ce0f52471b259f Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 17:25:14 +0800 Subject: [PATCH 10/13] Prefer agent card URLs in call examples --- README.md | 6 ++++-- src/opencode_a2a/cli.py | 12 ++++++++---- tests/server/test_cli.py | 4 +++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 72b3a569..22946d30 100644 --- a/README.md +++ b/README.md @@ -120,14 +120,16 @@ Interact with other A2A agents directly from the command line: ```bash # Using the target peer agent's Bearer token via environment injection A2A_CLIENT_BEARER_TOKEN=your-outbound-token \ -opencode-a2a call http://other-agent:8000 "How are you?" +opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?" # Using the target peer agent's Basic auth via environment injection # Accepts raw user:pass or its base64-encoded value A2A_CLIENT_BASIC_AUTH="user:pass" \ -opencode-a2a call http://other-agent:8000 "How are you?" +opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?" ``` +Service base URLs also work, but this README prefers Agent Card URLs in examples because they make the A2A discovery target explicit. + ### Outbound Agent Calls (Tools) The server can autonomously execute `a2a_call(url, message)` tool calls emitted by the OpenCode runtime. Results are fetched via A2A and returned to the model as tool results, enabling multi-agent orchestration. diff --git a/src/opencode_a2a/cli.py b/src/opencode_a2a/cli.py index e1ff2712..16848014 100644 --- a/src/opencode_a2a/cli.py +++ b/src/opencode_a2a/cli.py @@ -85,13 +85,14 @@ CALL_HELP = ( "Call examples:\n" " A2A_CLIENT_BEARER_TOKEN=peer-token \\\n" - ' opencode-a2a call http://other-agent:8000 "How are you?"\n' + ' opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?"\n' "\n" ' A2A_CLIENT_BASIC_AUTH="user:pass" \\\n' - ' opencode-a2a call http://other-agent:8000 "How are you?"\n' + ' opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?"\n' "\n" "Call note:\n" - " Outbound peer credentials are read from environment variables only." + " Outbound peer credentials are read from environment variables only.\n" + " 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}" @@ -243,7 +244,10 @@ def build_parser() -> argparse.ArgumentParser: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=CALL_HELP_EPILOG, ) - call_parser.add_argument("agent_url", help="URL of the agent to call.") + call_parser.add_argument( + "agent_url", + help="Agent card URL or service base URL of the agent to call.", + ) call_parser.add_argument("text", help="Text message to send.") return parser diff --git a/tests/server/test_cli.py b/tests/server/test_cli.py index c182dd02..371baafa 100644 --- a/tests/server/test_cli.py +++ b/tests/server/test_cli.py @@ -23,6 +23,7 @@ def test_cli_help_does_not_require_runtime_settings(capsys: pytest.CaptureFixtur 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 "{call}" not in help_text assert "serve" in help_text assert "deploy-release" not in help_text @@ -92,6 +93,7 @@ def test_cli_call_without_required_arguments_prints_help( help_text = capsys.readouterr().out assert "Call an A2A agent using the A2A protocol. A2A Protocol 1.0 only." in help_text assert "A2A_CLIENT_BEARER_TOKEN=peer-token" in help_text + assert "Service base URLs also work, but card URLs are the preferred example form." in help_text serve_mock.assert_not_called() @@ -103,7 +105,7 @@ def test_cli_call_with_partial_required_arguments_prints_help( help_text = capsys.readouterr().out assert "usage: opencode-a2a call" in help_text - assert "Text message to send." in help_text + assert "Agent card URL or service base URL of the agent to call." in help_text serve_mock.assert_not_called() From 04602c063090c9834484599b9fac97b508efb034 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 17:29:29 +0800 Subject: [PATCH 11/13] Document card URL call examples --- docs/guide.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/guide.md b/docs/guide.md index be31a62a..24006935 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -99,6 +99,15 @@ Current client facade API: Server-side outbound peer calls read outbound credentials from environment variables. Configure `A2A_CLIENT_BEARER_TOKEN` or `A2A_CLIENT_BASIC_AUTH` when the remote agent protects its runtime surface. CLI outbound calls follow the same environment-only model. +CLI outbound example: + +```bash +A2A_CLIENT_BEARER_TOKEN=peer-token \ +opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?" +``` + +Service base URLs also work, but this guide prefers Agent Card URLs in CLI examples because they make the A2A discovery target explicit. + `A2AClient.send()` returns the latest response event and keeps the default stream-first behavior. If a peer returns a non-terminal task snapshot and expects follow-up `GetTask` polling, enable the optional facade fallback with: - `A2A_CLIENT_POLLING_FALLBACK_ENABLED=true` From 93f9266dca2f5b045343388348f498a21e27b57c Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 17:37:15 +0800 Subject: [PATCH 12/13] Trim redundant README protocol notes --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 22946d30..1d35f896 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,6 @@ `opencode-a2a` adds an A2A runtime layer to `opencode serve`, with auth, streaming, session continuity, interrupt handling, and a clear deployment boundary. -> A2A Protocol `1.0` only. - ## What This Is - An A2A adapter service built on `opencode serve`, with inbound runtime exposure plus outbound peer calling. @@ -128,8 +126,6 @@ A2A_CLIENT_BASIC_AUTH="user:pass" \ opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?" ``` -Service base URLs also work, but this README prefers Agent Card URLs in examples because they make the A2A discovery target explicit. - ### Outbound Agent Calls (Tools) The server can autonomously execute `a2a_call(url, message)` tool calls emitted by the OpenCode runtime. Results are fetched via A2A and returned to the model as tool results, enabling multi-agent orchestration. From 17b4309c6c937d0ce9c87a0b69751e4f60d0f6de Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Tue, 28 Apr 2026 17:40:18 +0800 Subject: [PATCH 13/13] Align persistence docs with migration behavior --- docs/guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide.md b/docs/guide.md index 24006935..81749a7b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -175,7 +175,7 @@ With the default `database` backend, the unified lightweight persistence layer p This project is SQLite-first for local single-instance deployments. The runtime configures local durability-oriented SQLite connection settings (`WAL`, `busy_timeout`, `synchronous=NORMAL`) and creates missing parent directories for file-backed database paths. -The runtime automatically applies lightweight schema migrations for its custom state tables and records the applied version in `a2a_schema_version`. Schema-version writes are idempotent across concurrent first-start races, pending preferred-session claims now persist absolute `expires_at` timestamps while remaining backward-compatible with legacy `updated_at` rows, and the built-in path currently targets the local SQLite deployment profile without requiring Alembic. +The runtime automatically applies lightweight schema migrations for its custom state tables and records the applied version in `a2a_schema_version`. Schema-version writes are idempotent across concurrent first-start races, pending preferred-session claims now persist absolute `expires_at` timestamps, legacy rows without `expires_at` are pruned during migration instead of being reconstructed from historical TTL assumptions, and the built-in path currently targets the local SQLite deployment profile without requiring Alembic. Database-backed task persistence also keeps the existing first-terminal-state-wins contract while tightening the SQLite path with an atomic terminal-write guard instead of relying only on process-local read-before-write checks. Any wider SQLAlchemy dialect compatibility should be treated as incidental implementation latitude rather than a documented deployment target.