From 24a888254c87e3c74704825d22a064418d9e1c89 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:06:23 -0400 Subject: [PATCH 1/8] Run CLI probes in their existing selected-runtime interpreter --- src/agent_comms/cli.py | 65 ++++++++++++------- .../installer/interpreter_operation.py | 11 ++-- src/agent_comms/installer/probes/commands.py | 32 ++++----- .../installer/probes/owner_actions.py | 33 ---------- src/agent_comms/installer/probes/owners.py | 28 ++++---- tests/test_cli_error_family.py | 4 +- tests/test_install_worker_holds.py | 45 +++++++++++-- 7 files changed, 117 insertions(+), 101 deletions(-) delete mode 100644 src/agent_comms/installer/probes/owner_actions.py diff --git a/src/agent_comms/cli.py b/src/agent_comms/cli.py index 9086dae635..3ae5245901 100644 --- a/src/agent_comms/cli.py +++ b/src/agent_comms/cli.py @@ -22,9 +22,9 @@ from agent_comms.launch_root import LaunchRoot -def _fail(error: BaseException) -> int: +def _fail(error: BaseException, output: TextIO) -> int: """The declared wire family owns classification, causes and JSON encoding.""" - print(CoreError.from_exception(error).model_dump_json(), flush=True) + print(CoreError.from_exception(error).model_dump_json(), file=output, flush=True) return 1 @@ -38,32 +38,47 @@ def build_parser(arguments: Sequence[str] = ()) -> argparse.ArgumentParser: return parser -def execute(arguments: Sequence[str], output: TextIO) -> int: - """Own parsing, root selection and dispatch for every registered command.""" - arguments = list(arguments) - args, unknown = build_parser(arguments).parse_known_args(arguments, namespace=CliArguments()) - if unknown and (len(unknown) != 2 or unknown[0] != LaunchRoot.argument): - raise ValueError(f"unrecognized arguments: {' '.join(unknown)}") - declaration = args.command_declaration - LaunchRoot.startup( - arguments, - lambda: args.root.resolve(declaration.published_launch), - ) - route = resolve_comms_route(args.root.resolve(LaunchRoot.current).path) - with LaunchRoot.from_path(route.root).selected(): - command = declaration.from_arguments(args) - return command.execute(route, output) +class CliInvocation: + """The CLI boundary shared by console entry and selected-runtime probes.""" + + def execute(self, arguments: Sequence[str], output: TextIO) -> int: + """Own parsing, root selection and dispatch for every registered command.""" + arguments = list(arguments) + args, unknown = build_parser(arguments).parse_known_args(arguments, namespace=CliArguments()) + if unknown and (len(unknown) != 2 or unknown[0] != LaunchRoot.argument): + raise ValueError(f"unrecognized arguments: {' '.join(unknown)}") + declaration = args.command_declaration + LaunchRoot.startup( + arguments, + lambda: args.root.resolve(declaration.published_launch), + ) + route = resolve_comms_route(args.root.resolve(LaunchRoot.current).path) + with LaunchRoot.from_path(route.root).selected(): + command = declaration.from_arguments(args) + return command.execute(route, output) + + def invoke(self, arguments: Sequence[str], output: TextIO) -> int: + """A call borrows its root; success, refusal and parser exit all restore it.""" + with LaunchRoot.unstarted().selected(): + try: + return self.execute(arguments, output) + except KeyboardInterrupt: + return 130 + except CLI_FAILURES as exc: + return _fail(exc, output) + except SystemExit as exited: + # CPython's process boundary accepts an integer, None, or error text. + if isinstance(exited.code, int): + return exited.code + if exited.code is None: + return 0 + print(exited.code, file=sys.stderr) + return 1 def main(argv: Sequence[str] | None = None) -> int: - try: - arguments = argv if isinstance(argv, Sequence) else sys.argv[1:] - return execute(arguments, sys.stdout) - except KeyboardInterrupt: - return 130 - except CLI_FAILURES as exc: - return _fail(exc) - return 0 + arguments = argv if isinstance(argv, Sequence) else sys.argv[1:] + return CliInvocation().invoke(arguments, sys.stdout) if __name__ == "__main__": diff --git a/src/agent_comms/installer/interpreter_operation.py b/src/agent_comms/installer/interpreter_operation.py index 08a9195719..8bf78c3190 100644 --- a/src/agent_comms/installer/interpreter_operation.py +++ b/src/agent_comms/installer/interpreter_operation.py @@ -501,11 +501,14 @@ def answer(self) -> ProbeReply: def serve() -> None: """Answer declared operations from stdin, one encoded answer per request line, until it closes. An operation that raises ends the worker: its traceback goes to stderr, and the - caller reads the exit in place of an answer. The answers own stdout's descriptor; every - other write, an operation's exit handlers included, lands on stderr.""" - with os.fdopen(os.dup(sys.stdout.fileno()), "w") as answers: + caller reads the exit in place of an answer. The transport owns its descriptors: + operations read EOF on stdin, and their stdout lands on stderr.""" + with (os.fdopen(os.dup(sys.stdin.fileno())) as requests, + os.fdopen(os.dup(sys.stdout.fileno()), "w") as answers, + open(os.devnull) as empty_input): + os.dup2(empty_input.fileno(), sys.stdin.fileno()) os.dup2(sys.stderr.fileno(), sys.stdout.fileno()) - for line in sys.stdin: + for line in requests: print( FieldCodec.compact( FieldCodec.encode(FieldCodec.decode(ProbeRequest, FieldCodec.parse(line)).answer()) diff --git a/src/agent_comms/installer/probes/commands.py b/src/agent_comms/installer/probes/commands.py index bf2c757590..391495d2c7 100644 --- a/src/agent_comms/installer/probes/commands.py +++ b/src/agent_comms/installer/probes/commands.py @@ -1,11 +1,12 @@ from __future__ import annotations -import subprocess -import sys +from contextlib import redirect_stderr, redirect_stdout from dataclasses import dataclass +from io import StringIO from pydantic import TypeAdapter, ValidationError +from agent_comms.cli import CliInvocation from agent_comms.cli_protocol import CoreError from agent_comms.cli_result import CliResult from agent_comms.field_codec import FieldCodec, MalformedRecord @@ -68,28 +69,19 @@ def decoded[Answer](self, result: type[Answer]) -> Answer: @dataclass(frozen=True) -class CliCommandProbe(RuntimeProbe[CliOutput]): - """One command-line call, answered by the selected runtime's own command line in its - own process of this interpreter.""" +class CliCommandProbe(CliInvocation, RuntimeProbe[CliOutput]): + """One CLI invocation in the selected runtime's existing interpreter worker. + + The worker key owns cwd and environment; CliInvocation scopes each call's root. + """ arguments: tuple[str, ...] def install_phase(self) -> CliProbePhase: return CliProbePhase(probe=type(self), arguments=self.arguments) - def entry(self) -> tuple[str, ...]: - return "-m", "agent_comms.cli" - - def source(self) -> str: - """Ordinary CLI invocations supply no stdin; scripted probes override it.""" - return "" - def run(self) -> CliOutput: - completed = subprocess.run( - (sys.executable, *self.entry(), *self.arguments), - capture_output=True, - input=self.source(), - text=True, - check=False, - ) - return CliOutput(completed.returncode, completed.stdout, completed.stderr) + output, errors = StringIO(), StringIO() + with redirect_stdout(output), redirect_stderr(errors): + returncode = self.invoke(self.arguments, output) + return CliOutput(returncode, output.getvalue(), errors.getvalue()) diff --git a/src/agent_comms/installer/probes/owner_actions.py b/src/agent_comms/installer/probes/owner_actions.py deleted file mode 100644 index f93075ecf1..0000000000 --- a/src/agent_comms/installer/probes/owner_actions.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The selected runtime reads its own CLI result, then emits an installer report. - -OwnerActionsProbe supplies this module's source to an isolated interpreter. Do -not import the installing Core here: both CLI execution and batch decoding must -use the selected runtime, whose result shape may differ from the installing one. -""" - -from __future__ import annotations - -import sys -from io import StringIO - -from agent_comms.cli import _fail, execute -from agent_comms.cli_command import TargetBatchResult -from agent_comms.cli_failures import CLI_FAILURES -from agent_comms.field_codec import FieldCodec -from agent_comms.installer.owner_starts import OwnerStart - - -def main(arguments: list[str]) -> int: - output = StringIO() - execute(arguments, output) - batch = FieldCodec.decode(TargetBatchResult, FieldCodec.parse(output.getvalue())) - owners = tuple(outcome.owner_start() for outcome in batch.outcomes) - print(FieldCodec.compact(FieldCodec.encode(owners, tuple[OwnerStart, ...]))) - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main(sys.argv[1:])) - except CLI_FAILURES as error: - raise SystemExit(_fail(error)) diff --git a/src/agent_comms/installer/probes/owners.py b/src/agent_comms/installer/probes/owners.py index 14da5e6ed1..72d6d68dff 100644 --- a/src/agent_comms/installer/probes/owners.py +++ b/src/agent_comms/installer/probes/owners.py @@ -1,28 +1,30 @@ from __future__ import annotations from dataclasses import dataclass -from importlib.resources import files +from collections.abc import Sequence +from io import StringIO from pathlib import Path -from typing import Annotated +from typing import Annotated, TextIO +from agent_comms.cli_command import TargetBatchResult from agent_comms.comms import wire -from agent_comms.field_codec import PathText +from agent_comms.field_codec import FieldCodec, PathText from agent_comms.installer.interpreter_operation import RuntimeProbe from agent_comms.installer.probes.commands import CliCommandProbe +from agent_comms.installer.owner_starts import OwnerStart from agent_comms.owner_restart import OwnerRestartRequest class OwnerActionsProbe(CliCommandProbe): - """Execute and decode the batch in one isolated, selected-runtime process. - - The entry source ships with the selected runtime's own installer. - """ - - def entry(self) -> tuple[str, ...]: - return ("-",) - - def source(self) -> str: - return files(__package__).joinpath("owner_actions.py").read_text() + """The selected runtime decodes its own batch before emitting owner reports.""" + + def execute(self, arguments: Sequence[str], output: TextIO) -> int: + command_output = StringIO() + super().execute(arguments, command_output) + batch = FieldCodec.decode(TargetBatchResult, FieldCodec.parse(command_output.getvalue())) + owners = tuple(outcome.owner_start() for outcome in batch.outcomes) + print(FieldCodec.compact(FieldCodec.encode(owners, tuple[OwnerStart, ...])), file=output) + return 0 @dataclass(frozen=True) diff --git a/tests/test_cli_error_family.py b/tests/test_cli_error_family.py index 0e07e02d72..3dfaea4629 100644 --- a/tests/test_cli_error_family.py +++ b/tests/test_cli_error_family.py @@ -27,7 +27,7 @@ (ValueError('first\nsecond'), FailedCoreError), ]) def test_exception_declaration_encodes_one_line(error, expected, capsys): - assert _fail(error) == 1 + assert _fail(error, sys.stdout) == 1 output = capsys.readouterr().out assert len(output.splitlines()) == 1 decoded = WATCH_LINE.validate_json(output) @@ -42,7 +42,7 @@ def test_error_keeps_cause_without_using_cause_text_as_policy(capsys): except ValueError as cause: error = CoordinationReadUnavailable('not ready') error.__cause__ = cause - _fail(error) + _fail(error, sys.stdout) decoded = WATCH_LINE.validate_json(capsys.readouterr().out) assert isinstance(decoded, UnavailableCoreError) assert 'ValueError: bad\ninput' in decoded.error diff --git a/tests/test_install_worker_holds.py b/tests/test_install_worker_holds.py index 25c7df4de4..9348cad81f 100644 --- a/tests/test_install_worker_holds.py +++ b/tests/test_install_worker_holds.py @@ -1,11 +1,12 @@ -import io import os +import sys from dataclasses import dataclass from functools import partial from pathlib import Path import pytest +from agent_comms.cli_verbs.read_back import ReadBackCliCommand from agent_comms.cli_verbs.start import StartCliCommand from agent_comms.cli_verbs.stop import StopCliCommand from agent_comms.field_codec import ClaimId, FieldCodec @@ -22,7 +23,10 @@ from agent_comms.installer.probes.packages import DistributionVersionsProbe, InstalledPackagesProbe from agent_comms.installer.refusals import InterpreterWorkerDied, UpgradeRefused from agent_comms.installer.upgrade import Stack +from agent_comms.launch_root import LaunchRoot from agent_comms.launch_runtime import LaunchRuntime +from agent_comms.repo import Checkout +from agent_comms.threads import Thread from agent_comms.maintenance_barrier import HOLDER_NONCE, MaintenanceClaimHolder @@ -49,6 +53,37 @@ def test_cli_reply_preserves_declared_failure_from_each_entry(comms, probe_type) assert isinstance(refused.value.__cause__, CliRefusedError) +def test_cli_probes_share_worker_and_keep_roots_and_refusals_separate(comms, tmp_path): + comms.registry.declare(Thread("only-first-root", frozenset(), Checkout(tmp_path))) + runtime = LaunchRuntime.running() + with InstallCommands() as commands: + stack = Stack(Path(__file__).resolve().parents[1], commands, runtime=runtime) + stack.probe(DistributionVersionsProbe()) + (worker,) = commands.workers.values() + pid = worker.child.pid + assert stack.cli(comms.root, ReadBackCliCommand).threads == 1 + assert stack.cli(tmp_path / "other-root", ReadBackCliCommand).threads == 0 + help_output = stack.probe(CliCommandProbe(("--help",))) + assert help_output.returncode == 0 + assert help_output.stdout + for probe_type in (CliCommandProbe, OwnerActionsProbe): + with pytest.raises(UpgradeRefused): + stack.cli_reply(probe_type(("--root", str(comms.root), "not-a-command")), + partial(CliOutput.decoded, result=tuple[OwnerStart, ...])) + assert stack.cli(comms.root, ReadBackCliCommand).threads == 1 + stack.probe(DistributionVersionsProbe()) + assert tuple(commands.workers.values()) == (worker,) + assert worker.child.pid == pid + + +@pytest.mark.parametrize("arguments", [("read-back",), ("not-a-command",), ("--help",)]) +def test_cli_invocation_restores_the_callers_root(comms, tmp_path, arguments): + previous = LaunchRoot.from_path(tmp_path / "caller-root") + with previous.selected(): + CliCommandProbe(("--root", str(comms.root), *arguments)).run() + assert LaunchRoot.current() == previous + + def test_owner_reports_own_their_refusal(): StartedOwner("ready").require_success(UpgradeRefused) with pytest.raises(UpgradeRefused): @@ -73,6 +108,7 @@ def test_worker_answers_each_request_as_that_request_s_holder(monkeypatch, tmp_p @dataclass(frozen=True) class HolderSeenProbe(RuntimeProbe[str]): def run(self) -> str: + assert sys.stdin.read() == "", "Operations must not read the request transport" return os.environ.get(HOLDER_NONCE, "") try: @@ -81,9 +117,10 @@ def run(self) -> str: FieldCodec.compact(FieldCodec.encode(ProbeRequest(HolderSeenProbe(), holder))) + "\n" for holder in holders ) - # serve() takes over the descriptor of its stdout, so the answers need a real file. - answers = tmp_path / "answers" - monkeypatch.setattr("sys.stdin", io.StringIO(lines)) + # serve() owns both transport descriptors; operations inherit EOF, not requests. + requests, answers = tmp_path / "requests", tmp_path / "answers" + requests.write_text(lines) + monkeypatch.setattr("sys.stdin", requests.open()) monkeypatch.setattr("sys.stdout", answers.open("w")) monkeypatch.setenv(HOLDER_NONCE, str(ClaimId.mint())) serve() From c3324c6fa253f5dc676d7dff46a02b9ec26c3097 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:15:27 -0400 Subject: [PATCH 2/8] Let the parser declare its exit status and fold duplicate refusal controls --- src/agent_comms/cli.py | 12 +++--------- src/agent_comms/cli_arguments.py | 14 ++++++++++++++ tests/test_install_worker_holds.py | 14 ++------------ 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/agent_comms/cli.py b/src/agent_comms/cli.py index 3ae5245901..25af2074d5 100644 --- a/src/agent_comms/cli.py +++ b/src/agent_comms/cli.py @@ -9,7 +9,7 @@ from __future__ import annotations -from agent_comms.cli_arguments import CliArgumentParser, CliArguments +from agent_comms.cli_arguments import CliArgumentParser, CliArguments, CliParserExit import argparse import sys @@ -66,14 +66,8 @@ def invoke(self, arguments: Sequence[str], output: TextIO) -> int: return 130 except CLI_FAILURES as exc: return _fail(exc, output) - except SystemExit as exited: - # CPython's process boundary accepts an integer, None, or error text. - if isinstance(exited.code, int): - return exited.code - if exited.code is None: - return 0 - print(exited.code, file=sys.stderr) - return 1 + except CliParserExit as exited: + return exited.code def main(argv: Sequence[str] | None = None) -> int: diff --git a/src/agent_comms/cli_arguments.py b/src/agent_comms/cli_arguments.py index 04584982a0..9f90d90207 100644 --- a/src/agent_comms/cli_arguments.py +++ b/src/agent_comms/cli_arguments.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import sys from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Never, Self @@ -44,6 +45,15 @@ class CliArguments(argparse.Namespace): binding_thread: BindingThread +class CliParserExit(SystemExit): + """A parser exit carries its declared integer status, not arbitrary process state.""" + + code: int + + def __init__(self, code: int) -> None: + super().__init__(code) + + class CliArgumentParser(argparse.ArgumentParser): """Argument errors use Core's JSON failure boundary, including subcommands.""" @@ -64,5 +74,9 @@ def command_parsing(cls, arguments: Sequence[str]) -> CommandParsing: args, _ = selection.parse_known_args(arguments, namespace=_CommandParsingArguments()) return args.command + def exit(self, status: int = 0, message: str | None = None) -> Never: + self._print_message(message, sys.stderr) + raise CliParserExit(status) + def error(self, message: str) -> Never: raise ValueError(message) diff --git a/tests/test_install_worker_holds.py b/tests/test_install_worker_holds.py index 9348cad81f..d44d0ff4f3 100644 --- a/tests/test_install_worker_holds.py +++ b/tests/test_install_worker_holds.py @@ -42,17 +42,6 @@ def test_owner_actions_decode_in_the_selected_worker(comms, command, source_repo outcomes[0].require_success(UpgradeRefused) -@pytest.mark.parametrize("probe_type", [CliCommandProbe, OwnerActionsProbe]) -def test_cli_reply_preserves_declared_failure_from_each_entry(comms, probe_type): - runtime = LaunchRuntime.running() - with InstallCommands() as commands: - stack = Stack(Path(__file__).resolve().parents[1], commands, runtime=runtime) - probe = probe_type(("--root", str(comms.root), "not-a-command")) - with pytest.raises(UpgradeRefused) as refused: - stack.cli_reply(probe, partial(CliOutput.decoded, result=tuple[OwnerStart, ...])) - assert isinstance(refused.value.__cause__, CliRefusedError) - - def test_cli_probes_share_worker_and_keep_roots_and_refusals_separate(comms, tmp_path): comms.registry.declare(Thread("only-first-root", frozenset(), Checkout(tmp_path))) runtime = LaunchRuntime.running() @@ -67,9 +56,10 @@ def test_cli_probes_share_worker_and_keep_roots_and_refusals_separate(comms, tmp assert help_output.returncode == 0 assert help_output.stdout for probe_type in (CliCommandProbe, OwnerActionsProbe): - with pytest.raises(UpgradeRefused): + with pytest.raises(UpgradeRefused) as refused: stack.cli_reply(probe_type(("--root", str(comms.root), "not-a-command")), partial(CliOutput.decoded, result=tuple[OwnerStart, ...])) + assert isinstance(refused.value.__cause__, CliRefusedError) assert stack.cli(comms.root, ReadBackCliCommand).threads == 1 stack.probe(DistributionVersionsProbe()) assert tuple(commands.workers.values()) == (worker,) From 78c4bcf3c9e3f5681c3548911308ea73a638ece5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:20:24 -0400 Subject: [PATCH 3/8] Derive CLI probe controls from the declared family and type their fixtures --- tests/test_install_worker_holds.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_install_worker_holds.py b/tests/test_install_worker_holds.py index d44d0ff4f3..5672dfa933 100644 --- a/tests/test_install_worker_holds.py +++ b/tests/test_install_worker_holds.py @@ -9,6 +9,7 @@ from agent_comms.cli_verbs.read_back import ReadBackCliCommand from agent_comms.cli_verbs.start import StartCliCommand from agent_comms.cli_verbs.stop import StopCliCommand +from agent_comms.comms import Comms from agent_comms.field_codec import ClaimId, FieldCodec from agent_comms.installer.commands import InstallCommands from agent_comms.installer.interpreter_operation import ( @@ -19,7 +20,6 @@ ) from agent_comms.installer.owner_starts import OwnerStart, RefusedOwner, StartedOwner from agent_comms.installer.probes.commands import CliCommandProbe, CliOutput, CliRefusedError -from agent_comms.installer.probes.owners import OwnerActionsProbe from agent_comms.installer.probes.packages import DistributionVersionsProbe, InstalledPackagesProbe from agent_comms.installer.refusals import InterpreterWorkerDied, UpgradeRefused from agent_comms.installer.upgrade import Stack @@ -42,7 +42,9 @@ def test_owner_actions_decode_in_the_selected_worker(comms, command, source_repo outcomes[0].require_success(UpgradeRefused) -def test_cli_probes_share_worker_and_keep_roots_and_refusals_separate(comms, tmp_path): +def test_cli_probes_share_worker_and_keep_roots_and_refusals_separate( + comms: Comms, tmp_path: Path, +) -> None: comms.registry.declare(Thread("only-first-root", frozenset(), Checkout(tmp_path))) runtime = LaunchRuntime.running() with InstallCommands() as commands: @@ -55,7 +57,7 @@ def test_cli_probes_share_worker_and_keep_roots_and_refusals_separate(comms, tmp help_output = stack.probe(CliCommandProbe(("--help",))) assert help_output.returncode == 0 assert help_output.stdout - for probe_type in (CliCommandProbe, OwnerActionsProbe): + for probe_type in CliCommandProbe.members_with(CliCommandProbe): with pytest.raises(UpgradeRefused) as refused: stack.cli_reply(probe_type(("--root", str(comms.root), "not-a-command")), partial(CliOutput.decoded, result=tuple[OwnerStart, ...])) @@ -67,7 +69,9 @@ def test_cli_probes_share_worker_and_keep_roots_and_refusals_separate(comms, tmp @pytest.mark.parametrize("arguments", [("read-back",), ("not-a-command",), ("--help",)]) -def test_cli_invocation_restores_the_callers_root(comms, tmp_path, arguments): +def test_cli_invocation_restores_the_callers_root( + comms: Comms, tmp_path: Path, arguments: tuple[str, ...], +) -> None: previous = LaunchRoot.from_path(tmp_path / "caller-root") with previous.selected(): CliCommandProbe(("--root", str(comms.root), *arguments)).run() From 7979727578db83afbd7c2f285adfb0f9d5c02515 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:48:24 -0400 Subject: [PATCH 4/8] Give CLI parsers one output owner and leave interrupts to console --- src/agent_comms/cli.py | 19 +++++---- src/agent_comms/cli_arguments.py | 42 ++++++++++++++++---- src/agent_comms/installer/probes/commands.py | 8 ++-- tests/test_cli.py | 2 +- tests/test_command_families.py | 5 ++- tests/test_install_announcements.py | 2 +- tests/test_install_worker_holds.py | 34 ++++++++++++++++ tests/test_page_cursor.py | 3 +- tests/test_writer_owned_stores.py | 3 +- 9 files changed, 94 insertions(+), 24 deletions(-) diff --git a/src/agent_comms/cli.py b/src/agent_comms/cli.py index 25af2074d5..ceb5ad59e7 100644 --- a/src/agent_comms/cli.py +++ b/src/agent_comms/cli.py @@ -28,10 +28,11 @@ def _fail(error: BaseException, output: TextIO) -> int: return 1 -def build_parser(arguments: Sequence[str] = ()) -> argparse.ArgumentParser: - parser = CliArgumentParser(prog="agent-comms", description=__doc__) +def build_parser(arguments: Sequence[str] = (), *, output: TextIO) -> argparse.ArgumentParser: + parser_type = CliArgumentParser.writing_to(output) + parser = parser_type(prog="agent-comms", description=__doc__) parser.add_root_options() - selection = CliArgumentParser.command_parsing(arguments) + selection = parser_type.command_parsing(arguments) subparsers = parser.add_subparsers(required=True) for declaration in selection.declarations(): declaration.add_parser(subparsers) @@ -44,7 +45,8 @@ class CliInvocation: def execute(self, arguments: Sequence[str], output: TextIO) -> int: """Own parsing, root selection and dispatch for every registered command.""" arguments = list(arguments) - args, unknown = build_parser(arguments).parse_known_args(arguments, namespace=CliArguments()) + args, unknown = build_parser(arguments, output=output).parse_known_args( + arguments, namespace=CliArguments()) if unknown and (len(unknown) != 2 or unknown[0] != LaunchRoot.argument): raise ValueError(f"unrecognized arguments: {' '.join(unknown)}") declaration = args.command_declaration @@ -62,8 +64,6 @@ def invoke(self, arguments: Sequence[str], output: TextIO) -> int: with LaunchRoot.unstarted().selected(): try: return self.execute(arguments, output) - except KeyboardInterrupt: - return 130 except CLI_FAILURES as exc: return _fail(exc, output) except CliParserExit as exited: @@ -71,8 +71,11 @@ def invoke(self, arguments: Sequence[str], output: TextIO) -> int: def main(argv: Sequence[str] | None = None) -> int: - arguments = argv if isinstance(argv, Sequence) else sys.argv[1:] - return CliInvocation().invoke(arguments, sys.stdout) + try: + arguments = argv if isinstance(argv, Sequence) else sys.argv[1:] + return CliInvocation().invoke(arguments, sys.stdout) + except KeyboardInterrupt: + return 130 if __name__ == "__main__": diff --git a/src/agent_comms/cli_arguments.py b/src/agent_comms/cli_arguments.py index 9f90d90207..1c5267d670 100644 --- a/src/agent_comms/cli_arguments.py +++ b/src/agent_comms/cli_arguments.py @@ -3,13 +3,15 @@ from __future__ import annotations import argparse -import sys +from abc import ABC, abstractmethod from collections.abc import Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Never, Self +from typing import TYPE_CHECKING, Never, Self, TextIO from agent_comms.cli_command import CliCommand from agent_comms.launch_root import DefaultLaunch, LaunchRequest, LaunchRoot +from agent_comms.field_codec import FieldCodec, SubclassUnion +from agent_comms.thread_text import AbsentText, GivenText, TextChoice if TYPE_CHECKING: from agent_comms.cli_bridges.shared import BindingThread @@ -50,13 +52,39 @@ class CliParserExit(SystemExit): code: int - def __init__(self, code: int) -> None: - super().__init__(code) +class CliParserText(SubclassUnion, TextChoice, affix="CliParserText"): + """Argparse's printed text, decoded once at the parser boundary.""" -class CliArgumentParser(argparse.ArgumentParser): + +@dataclass(frozen=True) +class GivenCliParserText(GivenText, CliParserText): + text: str + + +@dataclass(frozen=True) +class NoCliParserText(AbsentText, CliParserText): + pass + + +class CliArgumentParser(argparse.ArgumentParser, ABC): """Argument errors use Core's JSON failure boundary, including subcommands.""" + @property + @abstractmethod + def output(self) -> TextIO: ... + + @staticmethod + def writing_to(stream: TextIO) -> type[CliArgumentParser]: + class WritingParser(CliArgumentParser): + output = stream + + return WritingParser + + def _print_message(self, message: object, file: object = None) -> None: + for text in FieldCodec.decode(CliParserText, message).texts(): + self.output.write(text) + def add_root_options(self) -> None: self.add_argument( "--root", type=LaunchRoot.from_path, default=DefaultLaunch(), @@ -74,8 +102,8 @@ def command_parsing(cls, arguments: Sequence[str]) -> CommandParsing: args, _ = selection.parse_known_args(arguments, namespace=_CommandParsingArguments()) return args.command - def exit(self, status: int = 0, message: str | None = None) -> Never: - self._print_message(message, sys.stderr) + def exit(self, status: int = 0, message: object = None) -> Never: + self._print_message(message) raise CliParserExit(status) def error(self, message: str) -> Never: diff --git a/src/agent_comms/installer/probes/commands.py b/src/agent_comms/installer/probes/commands.py index 391495d2c7..05e8bae90a 100644 --- a/src/agent_comms/installer/probes/commands.py +++ b/src/agent_comms/installer/probes/commands.py @@ -1,6 +1,6 @@ from __future__ import annotations -from contextlib import redirect_stderr, redirect_stdout +from contextlib import redirect_stderr from dataclasses import dataclass from io import StringIO @@ -32,7 +32,9 @@ class CliUndecodableError(Exception): class CliOutput: """What a command line printed. The worker encodes it on the runtime that ran the command (an older Core than the installer's, during an upgrade), so it stays plain - text; the installer decodes it into the command's answer here, once.""" + text; the installer decodes it into the command's answer here, once. stderr contains + Python writes during the call; native writes and pre-bound logging go to worker diagnostics. + """ returncode: int stdout: str @@ -82,6 +84,6 @@ def install_phase(self) -> CliProbePhase: def run(self) -> CliOutput: output, errors = StringIO(), StringIO() - with redirect_stdout(output), redirect_stderr(errors): + with redirect_stderr(errors): returncode = self.invoke(self.arguments, output) return CliOutput(returncode, output.getvalue(), errors.getvalue()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 73d5586a73..6d781ea50d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -353,7 +353,7 @@ def test_restart_catalog_queues_for_the_original_owner_even_while_busy(self, tmp explicit = RestartCliCommand(name=OneOwner('alpha'), agent_bin=GivenBinary('/operator/native'), agent_args=GivenArguments(('--no-extensions',))) from agent_comms.cli import build_parser - options = build_parser().parse_args([ + options = build_parser(output=sys.stdout).parse_args([ 'restart', '--name', 'alpha', '--agent-bin', '/operator/native', '--agent-args=--no-extensions']) parsed = options.command_declaration.from_arguments(options) diff --git a/tests/test_command_families.py b/tests/test_command_families.py index 99ce697d4b..1b30080456 100644 --- a/tests/test_command_families.py +++ b/tests/test_command_families.py @@ -4,6 +4,7 @@ """External contracts and adding commands without editing any consumer catalog.""" import json +from io import StringIO import pytest from acp.schema import PromptRequest, TextContentBlock @@ -34,7 +35,7 @@ def test_cli_decodes_tag_sets_enums_json_and_shell_words_once(monkeypatch, tmp_path): monkeypatch.setenv("AGENT_COMMS_AGENT_BIN", "/a pi") monkeypatch.setenv("AGENT_COMMS_AGENT_ARGS", '--label "two words"') - parser = build_parser() + parser = build_parser(output=StringIO()) def parse(args): options = parser.parse_args(args) @@ -68,7 +69,7 @@ def parse(args): assert explicit.agent_bin == GivenBinary("/a pi") assert explicit.agent_args == GivenArguments(("--label", "two words")) monkeypatch.setenv("AGENT_COMMS_AGENT_BIN", "/next pi") - fresh = build_parser().parse_args(["restart", "--all"]) + fresh = build_parser(output=StringIO()).parse_args(["restart", "--all"]) assert fresh.command_declaration.from_arguments(fresh).agent_bin == ConfiguredBinary() diff --git a/tests/test_install_announcements.py b/tests/test_install_announcements.py index 05736a1fb5..e72b56c17e 100644 --- a/tests/test_install_announcements.py +++ b/tests/test_install_announcements.py @@ -45,7 +45,7 @@ ((), LiveMode, False), (("--rehearse",), RehearsalMode, True), )) def test_install_mode_decodes_the_launchers_single_boolean_wire_form(flags, mode, serialized): - arguments = build_parser().parse_args(("install", *flags)) + arguments = build_parser(output=StringIO()).parse_args(("install", *flags)) command = arguments.command_declaration.from_arguments(arguments) assert type(command.mode) is mode encoded = FieldCodec.encode(command) diff --git a/tests/test_install_worker_holds.py b/tests/test_install_worker_holds.py index 5672dfa933..3d91e27351 100644 --- a/tests/test_install_worker_holds.py +++ b/tests/test_install_worker_holds.py @@ -1,11 +1,16 @@ import os import sys +from collections.abc import Sequence from dataclasses import dataclass from functools import partial +from io import StringIO from pathlib import Path +from typing import TextIO import pytest +from agent_comms.cli import CliInvocation +from agent_comms.cli_arguments import CliArgumentParser, CliParserExit from agent_comms.cli_verbs.read_back import ReadBackCliCommand from agent_comms.cli_verbs.start import StartCliCommand from agent_comms.cli_verbs.stop import StopCliCommand @@ -78,6 +83,35 @@ def test_cli_invocation_restores_the_callers_root( assert LaunchRoot.current() == previous +@pytest.mark.parametrize("arguments", [("--help",), ("read-back", "--help")]) +def test_parser_help_uses_only_the_invocations_output( + arguments: tuple[str, ...], capsys: pytest.CaptureFixture[str], +) -> None: + output = StringIO() + assert CliInvocation().invoke(arguments, output) == 0 + assert output.getvalue() + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize("message,expected", [(None, ""), ("", ""), ("parser text\n", "parser text\n")]) +def test_parser_exit_text_is_decoded_at_its_boundary(message: object, expected: str) -> None: + output = StringIO() + parser = CliArgumentParser.writing_to(output)() + with pytest.raises(CliParserExit) as exited: + parser.exit(2, message) + assert exited.value.code == 2 + assert output.getvalue() == expected + + +def test_cli_invocation_does_not_turn_interrupt_into_an_answer() -> None: + class InterruptedInvocation(CliInvocation): + def execute(self, arguments: Sequence[str], output: TextIO) -> int: + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + InterruptedInvocation().invoke((), StringIO()) + + def test_owner_reports_own_their_refusal(): StartedOwner("ready").require_success(UpgradeRefused) with pytest.raises(UpgradeRefused): diff --git a/tests/test_page_cursor.py b/tests/test_page_cursor.py index c12e7aaa9f..9aff70818e 100644 --- a/tests/test_page_cursor.py +++ b/tests/test_page_cursor.py @@ -1,5 +1,6 @@ """Cursor owners preserve source identity and the existing CLI JSON forms.""" import json +from io import StringIO import pytest @@ -26,7 +27,7 @@ def test_cursor_owns_traversal_and_cli_json(cursor, wire, source): assert cursor.newer().source == source assert FieldCodec.encode(cursor) == wire assert FieldCodec.decode(PageCursor, wire) == cursor - args = build_parser().parse_args(['history', '--before', json.dumps(wire)]) + args = build_parser(output=StringIO()).parse_args(['history', '--before', json.dumps(wire)]) command = CliCommand.member_named(args.command).from_arguments(args) assert command.before == cursor diff --git a/tests/test_writer_owned_stores.py b/tests/test_writer_owned_stores.py index 3731718c26..b5de82b424 100644 --- a/tests/test_writer_owned_stores.py +++ b/tests/test_writer_owned_stores.py @@ -3,6 +3,7 @@ from contextlib import closing from dataclasses import dataclass, replace from pathlib import Path +from io import StringIO from typing import Annotated, ClassVar import pytest @@ -211,7 +212,7 @@ def test_writer_owned_carry_preserves_its_declaration_across_cli_and_worker_boun writer_owned_store, tmp_path, ): store, row = writer_owned_store - parser = build_parser() + parser = build_parser(output=StringIO()) command = PlannedReconcileCliCommand.from_arguments(parser.parse_args([ "planned-reconcile", "--carrying", store.declared_name, ])) From 2191a0b071441f3da6135862eca0d07ae2588479 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 04:42:07 -0400 Subject: [PATCH 5/8] Bind CLI parser output to its instance and child factory --- src/agent_comms/cli.py | 5 ++--- src/agent_comms/cli_arguments.py | 24 ++++++++++-------------- tests/test_install_worker_holds.py | 2 +- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/agent_comms/cli.py b/src/agent_comms/cli.py index ceb5ad59e7..29511171f3 100644 --- a/src/agent_comms/cli.py +++ b/src/agent_comms/cli.py @@ -29,10 +29,9 @@ def _fail(error: BaseException, output: TextIO) -> int: def build_parser(arguments: Sequence[str] = (), *, output: TextIO) -> argparse.ArgumentParser: - parser_type = CliArgumentParser.writing_to(output) - parser = parser_type(prog="agent-comms", description=__doc__) + parser = CliArgumentParser(prog="agent-comms", description=__doc__, output=output) parser.add_root_options() - selection = parser_type.command_parsing(arguments) + selection = parser.command_parsing(arguments) subparsers = parser.add_subparsers(required=True) for declaration in selection.declarations(): declaration.add_parser(subparsers) diff --git a/src/agent_comms/cli_arguments.py b/src/agent_comms/cli_arguments.py index 1c5267d670..9245b3d9a3 100644 --- a/src/agent_comms/cli_arguments.py +++ b/src/agent_comms/cli_arguments.py @@ -3,9 +3,9 @@ from __future__ import annotations import argparse -from abc import ABC, abstractmethod from collections.abc import Sequence from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING, Never, Self, TextIO from agent_comms.cli_command import CliCommand @@ -67,19 +67,16 @@ class NoCliParserText(AbsentText, CliParserText): pass -class CliArgumentParser(argparse.ArgumentParser, ABC): +class CliArgumentParser(argparse.ArgumentParser): """Argument errors use Core's JSON failure boundary, including subcommands.""" - @property - @abstractmethod - def output(self) -> TextIO: ... + def __init__(self, *args: object, output: TextIO, **kwargs: object) -> None: + self.output = output + super().__init__(*args, **kwargs) - @staticmethod - def writing_to(stream: TextIO) -> type[CliArgumentParser]: - class WritingParser(CliArgumentParser): - output = stream - - return WritingParser + def add_subparsers(self, **kwargs: object) -> argparse._SubParsersAction[argparse.ArgumentParser]: + return super().add_subparsers( + parser_class=partial(type(self), output=self.output), **kwargs) def _print_message(self, message: object, file: object = None) -> None: for text in FieldCodec.decode(CliParserText, message).texts(): @@ -92,9 +89,8 @@ def add_root_options(self) -> None: ) self.add_argument(LaunchRoot.argument, help=argparse.SUPPRESS) - @classmethod - def command_parsing(cls, arguments: Sequence[str]) -> CommandParsing: - selection = cls(add_help=False) + def command_parsing(self, arguments: Sequence[str]) -> CommandParsing: + selection = type(self)(output=self.output, add_help=False) selection.add_root_options() selection.add_argument( "command", nargs="?", type=SelectedCommand.from_name, default=CommandParsing(), diff --git a/tests/test_install_worker_holds.py b/tests/test_install_worker_holds.py index 3d91e27351..feb5b5942c 100644 --- a/tests/test_install_worker_holds.py +++ b/tests/test_install_worker_holds.py @@ -96,7 +96,7 @@ def test_parser_help_uses_only_the_invocations_output( @pytest.mark.parametrize("message,expected", [(None, ""), ("", ""), ("parser text\n", "parser text\n")]) def test_parser_exit_text_is_decoded_at_its_boundary(message: object, expected: str) -> None: output = StringIO() - parser = CliArgumentParser.writing_to(output)() + parser = CliArgumentParser(output=output) with pytest.raises(CliParserExit) as exited: parser.exit(2, message) assert exited.value.code == 2 From 77a665d67d5cce5697e40b6e73bbb6d80541254b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 06:32:36 -0400 Subject: [PATCH 6/8] Carry parser text to the invocation output boundary --- src/agent_comms/cli.py | 8 ++++--- src/agent_comms/cli_arguments.py | 33 +++++++++++++---------------- tests/test_cli.py | 2 +- tests/test_command_families.py | 5 ++--- tests/test_install_announcements.py | 2 +- tests/test_install_worker_holds.py | 19 ++++++++++------- tests/test_page_cursor.py | 3 +-- tests/test_writer_owned_stores.py | 3 +-- 8 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/agent_comms/cli.py b/src/agent_comms/cli.py index 29511171f3..8a3f20802f 100644 --- a/src/agent_comms/cli.py +++ b/src/agent_comms/cli.py @@ -28,8 +28,8 @@ def _fail(error: BaseException, output: TextIO) -> int: return 1 -def build_parser(arguments: Sequence[str] = (), *, output: TextIO) -> argparse.ArgumentParser: - parser = CliArgumentParser(prog="agent-comms", description=__doc__, output=output) +def build_parser(arguments: Sequence[str] = ()) -> argparse.ArgumentParser: + parser = CliArgumentParser(prog="agent-comms", description=__doc__) parser.add_root_options() selection = parser.command_parsing(arguments) subparsers = parser.add_subparsers(required=True) @@ -44,7 +44,7 @@ class CliInvocation: def execute(self, arguments: Sequence[str], output: TextIO) -> int: """Own parsing, root selection and dispatch for every registered command.""" arguments = list(arguments) - args, unknown = build_parser(arguments, output=output).parse_known_args( + args, unknown = build_parser(arguments).parse_known_args( arguments, namespace=CliArguments()) if unknown and (len(unknown) != 2 or unknown[0] != LaunchRoot.argument): raise ValueError(f"unrecognized arguments: {' '.join(unknown)}") @@ -66,6 +66,8 @@ def invoke(self, arguments: Sequence[str], output: TextIO) -> int: except CLI_FAILURES as exc: return _fail(exc, output) except CliParserExit as exited: + for text in exited.text.texts(): + output.write(text) return exited.code diff --git a/src/agent_comms/cli_arguments.py b/src/agent_comms/cli_arguments.py index 9245b3d9a3..a661e8b078 100644 --- a/src/agent_comms/cli_arguments.py +++ b/src/agent_comms/cli_arguments.py @@ -5,12 +5,11 @@ import argparse from collections.abc import Sequence from dataclasses import dataclass -from functools import partial -from typing import TYPE_CHECKING, Never, Self, TextIO +from typing import TYPE_CHECKING, Never, Self from agent_comms.cli_command import CliCommand from agent_comms.launch_root import DefaultLaunch, LaunchRequest, LaunchRoot -from agent_comms.field_codec import FieldCodec, SubclassUnion +from agent_comms.field_codec import SubclassUnion from agent_comms.thread_text import AbsentText, GivenText, TextChoice if TYPE_CHECKING: @@ -51,10 +50,15 @@ class CliParserExit(SystemExit): """A parser exit carries its declared integer status, not arbitrary process state.""" code: int + text: CliParserText + + def __init__(self, status: int, text: CliParserText) -> None: + super().__init__(status) + self.text = text class CliParserText(SubclassUnion, TextChoice, affix="CliParserText"): - """Argparse's printed text, decoded once at the parser boundary.""" + """The parser's text carried to the invocation that owns its output.""" @dataclass(frozen=True) @@ -70,17 +74,11 @@ class NoCliParserText(AbsentText, CliParserText): class CliArgumentParser(argparse.ArgumentParser): """Argument errors use Core's JSON failure boundary, including subcommands.""" - def __init__(self, *args: object, output: TextIO, **kwargs: object) -> None: - self.output = output - super().__init__(*args, **kwargs) - - def add_subparsers(self, **kwargs: object) -> argparse._SubParsersAction[argparse.ArgumentParser]: - return super().add_subparsers( - parser_class=partial(type(self), output=self.output), **kwargs) + def print_help(self, file: object = None) -> Never: + raise CliParserExit(0, GivenCliParserText(self.format_help())) - def _print_message(self, message: object, file: object = None) -> None: - for text in FieldCodec.decode(CliParserText, message).texts(): - self.output.write(text) + def print_usage(self, file: object = None) -> Never: + raise CliParserExit(0, GivenCliParserText(self.format_usage())) def add_root_options(self) -> None: self.add_argument( @@ -90,7 +88,7 @@ def add_root_options(self) -> None: self.add_argument(LaunchRoot.argument, help=argparse.SUPPRESS) def command_parsing(self, arguments: Sequence[str]) -> CommandParsing: - selection = type(self)(output=self.output, add_help=False) + selection = type(self)(add_help=False) selection.add_root_options() selection.add_argument( "command", nargs="?", type=SelectedCommand.from_name, default=CommandParsing(), @@ -98,9 +96,8 @@ def command_parsing(self, arguments: Sequence[str]) -> CommandParsing: args, _ = selection.parse_known_args(arguments, namespace=_CommandParsingArguments()) return args.command - def exit(self, status: int = 0, message: object = None) -> Never: - self._print_message(message) - raise CliParserExit(status) + def exit(self, status: int = 0, message: str | None = None) -> Never: + raise CliParserExit(status, NoCliParserText()) def error(self, message: str) -> Never: raise ValueError(message) diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d781ea50d..73d5586a73 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -353,7 +353,7 @@ def test_restart_catalog_queues_for_the_original_owner_even_while_busy(self, tmp explicit = RestartCliCommand(name=OneOwner('alpha'), agent_bin=GivenBinary('/operator/native'), agent_args=GivenArguments(('--no-extensions',))) from agent_comms.cli import build_parser - options = build_parser(output=sys.stdout).parse_args([ + options = build_parser().parse_args([ 'restart', '--name', 'alpha', '--agent-bin', '/operator/native', '--agent-args=--no-extensions']) parsed = options.command_declaration.from_arguments(options) diff --git a/tests/test_command_families.py b/tests/test_command_families.py index 1b30080456..99ce697d4b 100644 --- a/tests/test_command_families.py +++ b/tests/test_command_families.py @@ -4,7 +4,6 @@ """External contracts and adding commands without editing any consumer catalog.""" import json -from io import StringIO import pytest from acp.schema import PromptRequest, TextContentBlock @@ -35,7 +34,7 @@ def test_cli_decodes_tag_sets_enums_json_and_shell_words_once(monkeypatch, tmp_path): monkeypatch.setenv("AGENT_COMMS_AGENT_BIN", "/a pi") monkeypatch.setenv("AGENT_COMMS_AGENT_ARGS", '--label "two words"') - parser = build_parser(output=StringIO()) + parser = build_parser() def parse(args): options = parser.parse_args(args) @@ -69,7 +68,7 @@ def parse(args): assert explicit.agent_bin == GivenBinary("/a pi") assert explicit.agent_args == GivenArguments(("--label", "two words")) monkeypatch.setenv("AGENT_COMMS_AGENT_BIN", "/next pi") - fresh = build_parser(output=StringIO()).parse_args(["restart", "--all"]) + fresh = build_parser().parse_args(["restart", "--all"]) assert fresh.command_declaration.from_arguments(fresh).agent_bin == ConfiguredBinary() diff --git a/tests/test_install_announcements.py b/tests/test_install_announcements.py index e72b56c17e..05736a1fb5 100644 --- a/tests/test_install_announcements.py +++ b/tests/test_install_announcements.py @@ -45,7 +45,7 @@ ((), LiveMode, False), (("--rehearse",), RehearsalMode, True), )) def test_install_mode_decodes_the_launchers_single_boolean_wire_form(flags, mode, serialized): - arguments = build_parser(output=StringIO()).parse_args(("install", *flags)) + arguments = build_parser().parse_args(("install", *flags)) command = arguments.command_declaration.from_arguments(arguments) assert type(command.mode) is mode encoded = FieldCodec.encode(command) diff --git a/tests/test_install_worker_holds.py b/tests/test_install_worker_holds.py index feb5b5942c..c29fba781f 100644 --- a/tests/test_install_worker_holds.py +++ b/tests/test_install_worker_holds.py @@ -10,7 +10,7 @@ import pytest from agent_comms.cli import CliInvocation -from agent_comms.cli_arguments import CliArgumentParser, CliParserExit +from agent_comms.cli_arguments import CliArgumentParser from agent_comms.cli_verbs.read_back import ReadBackCliCommand from agent_comms.cli_verbs.start import StartCliCommand from agent_comms.cli_verbs.stop import StopCliCommand @@ -93,14 +93,17 @@ def test_parser_help_uses_only_the_invocations_output( assert capsys.readouterr() == ("", "") -@pytest.mark.parametrize("message,expected", [(None, ""), ("", ""), ("parser text\n", "parser text\n")]) -def test_parser_exit_text_is_decoded_at_its_boundary(message: object, expected: str) -> None: +def test_cli_invocation_preserves_parser_exit_status_without_console_output( + capsys: pytest.CaptureFixture[str], +) -> None: + class ExitingInvocation(CliInvocation): + def execute(self, arguments: Sequence[str], output: TextIO) -> int: + CliArgumentParser().exit(2) + output = StringIO() - parser = CliArgumentParser(output=output) - with pytest.raises(CliParserExit) as exited: - parser.exit(2, message) - assert exited.value.code == 2 - assert output.getvalue() == expected + assert ExitingInvocation().invoke((), output) == 2 + assert output.getvalue() == "" + assert capsys.readouterr() == ("", "") def test_cli_invocation_does_not_turn_interrupt_into_an_answer() -> None: diff --git a/tests/test_page_cursor.py b/tests/test_page_cursor.py index 9aff70818e..c12e7aaa9f 100644 --- a/tests/test_page_cursor.py +++ b/tests/test_page_cursor.py @@ -1,6 +1,5 @@ """Cursor owners preserve source identity and the existing CLI JSON forms.""" import json -from io import StringIO import pytest @@ -27,7 +26,7 @@ def test_cursor_owns_traversal_and_cli_json(cursor, wire, source): assert cursor.newer().source == source assert FieldCodec.encode(cursor) == wire assert FieldCodec.decode(PageCursor, wire) == cursor - args = build_parser(output=StringIO()).parse_args(['history', '--before', json.dumps(wire)]) + args = build_parser().parse_args(['history', '--before', json.dumps(wire)]) command = CliCommand.member_named(args.command).from_arguments(args) assert command.before == cursor diff --git a/tests/test_writer_owned_stores.py b/tests/test_writer_owned_stores.py index b5de82b424..3731718c26 100644 --- a/tests/test_writer_owned_stores.py +++ b/tests/test_writer_owned_stores.py @@ -3,7 +3,6 @@ from contextlib import closing from dataclasses import dataclass, replace from pathlib import Path -from io import StringIO from typing import Annotated, ClassVar import pytest @@ -212,7 +211,7 @@ def test_writer_owned_carry_preserves_its_declaration_across_cli_and_worker_boun writer_owned_store, tmp_path, ): store, row = writer_owned_store - parser = build_parser(output=StringIO()) + parser = build_parser() command = PlannedReconcileCliCommand.from_arguments(parser.parse_args([ "planned-reconcile", "--carrying", store.declared_name, ])) From 8da2be796742aaf06c2f060d9298e86303082a4e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 06:39:44 -0400 Subject: [PATCH 7/8] Keep parser fixtures on declared commands and checkouts --- tests/test_command_families.py | 5 +++-- tests/test_page_cursor.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_command_families.py b/tests/test_command_families.py index 99ce697d4b..e241ada8bb 100644 --- a/tests/test_command_families.py +++ b/tests/test_command_families.py @@ -1,4 +1,4 @@ -from agent_comms.repo import Checkout +from agent_comms.repo import Checkout, Repo from agent_comms.field_codec import FieldCodec, GoalId """External contracts and adding commands without editing any consumer catalog.""" @@ -46,7 +46,8 @@ def parse(args): send_arguments = ["user-send", "--to", "a", "--body", "hello"] with pytest.raises(ValueError): parse(send_arguments) - assert parse([*send_arguments, "--worktree", str(tmp_path)]).worktree == str(tmp_path) + Repo.git(tmp_path, "init", "--quiet") + assert parse([*send_arguments, "--worktree", str(tmp_path)]).worktree == Checkout.checked(tmp_path) invoke = parse(["tool", "--tool", "comms_threads"]) assert invoke.arguments == {} send = parse(["send", "--from", "a", "--to", "b", "--body", "hello"]) diff --git a/tests/test_page_cursor.py b/tests/test_page_cursor.py index c12e7aaa9f..53ab53d973 100644 --- a/tests/test_page_cursor.py +++ b/tests/test_page_cursor.py @@ -4,7 +4,6 @@ import pytest from agent_comms.cli import build_parser -from agent_comms.cli_command import CliCommand from agent_comms.field_codec import FieldCodec from agent_comms.page_traversal import ( HistoryCursor, @@ -27,7 +26,7 @@ def test_cursor_owns_traversal_and_cli_json(cursor, wire, source): assert FieldCodec.encode(cursor) == wire assert FieldCodec.decode(PageCursor, wire) == cursor args = build_parser().parse_args(['history', '--before', json.dumps(wire)]) - command = CliCommand.member_named(args.command).from_arguments(args) + command = args.command_declaration.from_arguments(args) assert command.before == cursor From b94cfe573573f5fb8b49609b1479998ca43bcf93 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 06:47:38 -0400 Subject: [PATCH 8/8] Leave the unused argparse exit input at its raw boundary --- src/agent_comms/cli_arguments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/cli_arguments.py b/src/agent_comms/cli_arguments.py index a661e8b078..4fa63717af 100644 --- a/src/agent_comms/cli_arguments.py +++ b/src/agent_comms/cli_arguments.py @@ -96,7 +96,7 @@ def command_parsing(self, arguments: Sequence[str]) -> CommandParsing: args, _ = selection.parse_known_args(arguments, namespace=_CommandParsingArguments()) return args.command - def exit(self, status: int = 0, message: str | None = None) -> Never: + def exit(self, status: int = 0, message: object = None) -> Never: raise CliParserExit(status, NoCliParserText()) def error(self, message: str) -> Never: