diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea64244..90ce4a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,30 +1,26 @@ -name: ci +name: CI on: pull_request: - push: + branches: [main] permissions: contents: read jobs: - tests: - name: Linux Python ${{ matrix.python-version }} + unit-tests: + name: Unit tests runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - run: python -m pip install ".[dev]" - - run: python scripts/green_gate.py + - run: python -m pytest -q - packaging: - name: Credential-free packaging and installer + installer: + name: Installer runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -32,11 +28,4 @@ jobs: with: python-version: "3.12" - run: python -m pip install ".[dev]" - - run: python scripts/prepare_release.py --output-dir "$RUNNER_TEMP/release" - - run: >- - python -m pytest -q - -m "integration or not integration" - tests/test_package.py - tests/test_installer.py - tests/test_platform.py - tests/test_workflow.py + - run: python -m pytest -q -m integration tests/test_installer.py diff --git a/README.md b/README.md index 63f857f..7bfc7a3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ An unofficial external role runner for the OpenAI Codex CLI. > OpenAI. Codex is a trademark of OpenAI. The project does not imitate OpenAI > branding. +Some current Codex runtimes can make configured custom-agent profiles +unreachable through the native spawn surface, leaving the requested role's +model, reasoning effort, sandbox, and instructions unapplied +([upstream issue #31097](https://github.com/openai/codex/issues/31097)). This +package is a pragmatic bridge while that behavior is repaired, not a +replacement for native subagents. + `codex-exec-subagents` launches independent `codex exec` sessions from global role files and gives each session a durable handle, captured transport artifacts, and exact-thread follow-up. It is useful when a developer wants @@ -169,8 +176,9 @@ six nonempty string fields: `name`, `description`, `model`, `read-only`, `workspace-write`, and `danger-full-access`. A small inspection role is maintained as -[`examples/agents/reviewer.toml`](examples/agents/reviewer.toml). Print the -same valid TOML from an installed command without changing configuration: +[`examples/agents/reviewer.toml`](https://github.com/antoinezambelli/codex-exec-subagents/blob/main/examples/agents/reviewer.toml). +Print the same valid TOML from an installed command without changing +configuration: ```bash codex-exec-subagents example-role reviewer @@ -239,6 +247,35 @@ remains usable. not prove that transport or verification succeeded, so inspect `status` and the receipt as well. +The status JSON includes `receiptPath`; open that file to inspect evidence for +the exact invocation. A completed receipt includes fields like: + +```json +{ + "state": "completed", + "role": "reviewer", + "requested": { + "model": "gpt-5.6-terra", + "effort": "medium" + }, + "declaredSandboxMode": "read-only", + "effectivePolicy": { + "executionMode": "sandboxed", + "effectiveSandbox": "read-only", + "approvalBehavior": "never" + }, + "threadId": "...", + "processExitStatus": 0, + "terminalEventType": "turn.completed", + "verificationErrors": [] +} +``` + +Together, `receipt.json`, `events.jsonl`, and `last-message.txt` record what the +runner requested, what Codex emitted, and whether transport-level verification +succeeded. They do not prove that the model semantically followed its role +instructions; inspect the result and any repository changes accordingly. + ## Lifecycle commands Tasks can come from exactly one explicit source, or from standard input: @@ -352,7 +389,7 @@ Each UUID handle contains `session.json` and numbered invocation directories. Each invocation retains `prompt.txt`, `events.jsonl`, `stderr.txt`, `last-message.txt`, `status.json`, and `receipt.json`. Handle and invocation directories are `0700`; owned artifact files are `0600`. See -[the artifact lifecycle and local security contract](docs/artifact-lifecycle.md) +[the artifact lifecycle and local security contract](https://github.com/antoinezambelli/codex-exec-subagents/blob/main/docs/artifact-lifecycle.md) for the exact schema, atomic publication, interruption, and pruning behavior. If a nonterminal worker disappears, `status` reports an inspectable @@ -423,5 +460,7 @@ Git-boundary, release, installer, and other host-level tests carry the `integration` marker. The green gate restores them and enforces one 180-second wall-clock limit across the complete suite. -See the [changelog](CHANGELOG.md). The project is available under the -[MIT License](LICENSE). +See the +[changelog](https://github.com/antoinezambelli/codex-exec-subagents/blob/main/CHANGELOG.md). +The project is available under the +[MIT License](https://github.com/antoinezambelli/codex-exec-subagents/blob/main/LICENSE). diff --git a/src/codex_exec_subagents/cli.py b/src/codex_exec_subagents/cli.py index 7f964f2..6df8998 100644 --- a/src/codex_exec_subagents/cli.py +++ b/src/codex_exec_subagents/cli.py @@ -220,20 +220,61 @@ def build_parser() -> argparse.ArgumentParser: ) status_parser = subparsers.add_parser( - "status", help="show the latest invocation state" + "status", + help="show the latest invocation state", + description=( + "Print JSON for the latest invocation, including its state, " + "policy, artifact paths, and verification evidence." + ), + ) + status_parser.add_argument( + "handle", + metavar="HANDLE", + help="UUID handle returned by run or follow-up", ) - status_parser.add_argument("handle") - wait_parser = subparsers.add_parser("wait", help="wait for the latest invocation") - wait_parser.add_argument("handle") - wait_parser.add_argument("--timeout", type=float, default=900) + wait_parser = subparsers.add_parser( + "wait", + help="wait for the latest invocation", + description=( + "Wait for the latest invocation to reach a terminal state, then " + "print its status JSON." + ), + ) + wait_parser.add_argument( + "handle", + metavar="HANDLE", + help="UUID handle returned by run or follow-up", + ) + wait_parser.add_argument( + "--timeout", + type=float, + default=900, + help=( + "seconds to wait; 0 waits indefinitely; timeout exits 124 without " + "stopping the worker (default: 900)" + ), + ) result_parser = subparsers.add_parser( "result", help="print a raw final-message artifact, including active or failed turns", + description=( + "Print a raw final-message artifact, including one retained from " + "an active or failed invocation." + ), + ) + result_parser.add_argument( + "handle", + metavar="HANDLE", + help="UUID handle returned by run or follow-up", + ) + result_parser.add_argument( + "--sequence", + type=int, + metavar="NUMBER", + help="positive one-based invocation number; omit to read the latest", ) - result_parser.add_argument("handle") - result_parser.add_argument("--sequence", type=int) prune_parser = subparsers.add_parser( "prune", help="remove terminal role artifacts while preserving the root" diff --git a/tests/test_docs.py b/tests/test_docs.py index cf97c63..0248ae7 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -113,6 +113,48 @@ def test_documented_lifecycle_syntax_is_accepted_without_dispatch( assert not run_root.exists() +@pytest.mark.parametrize( + ("command", "phrases"), + [ + ( + "status", + ( + "UUID handle returned by run or follow-up", + "artifact paths, and verification evidence", + ), + ), + ( + "wait", + ( + "UUID handle returned by run or follow-up", + "0 waits indefinitely", + "timeout exits 124 without stopping the worker", + ), + ), + ( + "result", + ( + "UUID handle returned by run or follow-up", + "positive one-based invocation number", + "omit to read the latest", + "active or failed invocation", + ), + ), + ], +) +def test_lifecycle_subcommand_help_describes_arguments( + command: str, + phrases: tuple[str, ...], + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as raised: + cli.build_parser().parse_args([command, "--help"]) + assert raised.value.code == 0 + output = " ".join(capsys.readouterr().out.split()) + for phrase in phrases: + assert phrase in output + + def test_readme_matches_public_platform_policy_and_security_contracts() -> None: text = README.read_text(encoding="utf-8") required = ( @@ -153,7 +195,8 @@ def test_reviewer_example_is_canonical_and_cli_discoverable() -> None: assert phrase in help_text readme = README.read_text(encoding="utf-8") assert ( - "[`examples/agents/reviewer.toml`](examples/agents/reviewer.toml)" + "[`examples/agents/reviewer.toml`](https://github.com/antoinezambelli/" + "codex-exec-subagents/blob/main/examples/agents/reviewer.toml)" in readme ) assert "codex-exec-subagents example-role reviewer" in readme diff --git a/tests/test_workflow.py b/tests/test_workflow.py index 6fb98f1..a778480 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -37,27 +37,32 @@ def _collected_tests(*arguments: str) -> set[str]: } -def test_ci_workflow_is_linux_model_free_and_covers_supported_matrix() -> None: +def test_ci_workflow_has_only_unit_and_installer_pr_gates() -> None: text = WORKFLOW.read_text(encoding="utf-8") assert text.count("runs-on: ubuntu-latest") == 2 - matrix = re.search( - r'python-version:\s*\[([^\]]+)\]', text - ) - assert matrix is not None - assert re.findall(r'"(3\.[0-9]+)"', matrix.group(1)) == [ - "3.11", - "3.12", - "3.13", + assert re.findall(r"^ ([a-z][a-z-]+):\n name:", text, re.MULTILINE) == [ + "unit-tests", + "installer", ] - assert "python scripts/green_gate.py" in text - assert "python scripts/prepare_release.py" in text - assert '-m "integration or not integration"' in text + assert text.count('python-version: "3.12"') == 2 + assert "name: Unit tests" in text + assert "name: Installer" in text + assert "python -m pytest -q\n" in text + assert "python -m pytest -q -m integration tests/test_installer.py" in text assert "tests/test_installer.py" in text - assert "tests/test_platform.py" in text assert "pull_request:" in text - assert "push:" in text + assert "branches: [main]" in text forbidden = ( + "push:", + "strategy:", + "python scripts/green_gate.py", + "python scripts/prepare_release.py", + 'python-version: "3.11"', + 'python-version: "3.13"', + "tests/test_package.py", + "tests/test_platform.py", + "tests/test_workflow.py", "confirm-live", "confirm-yolo", "codex exec",