Skip to content

feat(m3): GeminiCLIAdapter + CLISubscriptionAuthError base (M3 PR 16b)#16

Merged
suzuke merged 2 commits into
mainfrom
feat/m3-gemini-cli-adapter
Apr 26, 2026
Merged

feat(m3): GeminiCLIAdapter + CLISubscriptionAuthError base (M3 PR 16b)#16
suzuke merged 2 commits into
mainfrom
feat/m3-gemini-cli-adapter

Conversation

@suzuke
Copy link
Copy Markdown
Owner

@suzuke suzuke commented Apr 26, 2026

Summary

  • Replaces the PR 16 stub GeminiCLIAdapter with a real implementation wrapping gemini -p -o stream-json headless mode.
  • Carries forward all design pins from PR 16a (sandbox/approval, spike fixture, §INV-3 forbidden flags, typed auth error, stderr fallback, phantom-command-free messages).
  • Lands the CLISubscriptionAuthError base class refactor that PR 16a R2 deferred — GeminiCLIAdapter is the second concrete adapter, motivating the abstraction.

Stacked on PR 16a (#15)

This PR is stacked on feat/m3-codex-cli-adapter. It adds the GeminiCLIAdapter on top of PR 16a's CodexCLIAdapter so the backend's auth-classification pathway can be unified into a single CLISubscriptionAuthError base catch.

If PR 16a merges first, this PR's base will rebase cleanly onto main. If they merge in the opposite order, the only conflict surface is cli_subscription_backend.py's try/except clause (resolved by keeping the PR 16b version: catch the base class).

CLISubscriptionAuthError base class (PR 16a R2 follow-up #4)

  • Defined in src/crucible/agents/cli_subscription/base.py
  • CodexCLIAuthError and GeminiCLIAuthError both subclass it
  • SubscriptionCLIBackend.generate_edit catches the BASE class via isinstance — single catch handles all current and future adapters
  • Tests test_gemini_auth_error_subclasses_cli_subscription_auth_error and test_codex_auth_error_also_subclasses_cli_subscription_auth_error enforce the hierarchy

Carry-forward design pins

Pin Codex (PR 16a) Gemini (PR 16b)
Sandbox/approval --sandbox workspace-write --approval-mode default + --skip-trust
§INV-3 forbidden flags --full-auto, --dangerously-bypass-approvals-and-sandbox, hypothetical CodeAct/REPL/eval --yolo, -y, --raw-output, --accept-raw-output-risk + hypothetical
Reproducibility flags --ephemeral, --ignore-user-config, --ignore-rules (n/a — gemini doesn't have config-override knobs of equivalent severity)
Spike fixture codex_exec_quota_exceeded.jsonl gemini_stream_json_tool_call.jsonl (init/message/tool_use/tool_result/result)
Typed auth error CodexCLIAuthError(CLISubscriptionAuthError) GeminiCLIAuthError(CLISubscriptionAuthError)
Stderr fallback yes yes

Stats

git diff --stat HEAD~1 HEAD | tail -1768 insertions / 29 deletions, 7 files.

File +/-
src/crucible/agents/cli_subscription/base.py +22
src/crucible/agents/cli_subscription/codex_cli.py +9/-7
src/crucible/agents/cli_subscription/gemini_cli.py +273/-9
src/crucible/agents/cli_subscription_backend.py +12/-7
tests/test_cli_subscription.py +18/-6
tests/test_gemini_cli_adapter.py (new) +436
tests/fixtures/gemini_stream_json_tool_call.jsonl (new) +7

Test results

2786 passed, 4 skipped (pytest --ignore=tests/test_agents.py). Same pre-existing PR-16 regex case mismatch in test_create_agent_unknown_raises excluded; unrelated to this PR.

Test plan

  • argv shape: -p prompt, -o stream-json, --approval-mode default, --skip-trust
  • §INV-3: forbidden flags + forbidden approval-mode values absent; meta-test catches accidental removal
  • parser: spike fixture round-trip, assistant message aggregation across delta chunks, user echo excluded from description, tool_use → tool_was_called, unknown event type → schema drift, random non-JSON → schema drift
  • typed auth: result-event phrase → GeminiCLIAuthError, assistant-message phrase → GeminiCLIAuthError, stderr fallback, non-auth errors NOT misclassified, explicit-set sanity, phantom-command-free message
  • hierarchy: GeminiCLIAuthError + CodexCLIAuthError both subclass CLISubscriptionAuthError (sys.modules-eviction-tolerant)
  • backend integration: parse_output raises → AgentResult.error_type=AUTH via single isinstance-check on base class

🤖 Generated with Claude Code

suzuke and others added 2 commits April 26, 2026 12:40
Replaces the PR 16 stub with the real GeminiCLIAdapter wrapping
`gemini -p -o stream-json` headless mode as a non-conversational
subprocess. Carries forward all the design pins from PR 16a:

  Sandbox / approval:
    - --approval-mode default (NOT yolo / auto_edit, which auto-approve
      tool calls — gemini's analog of codex --full-auto)
    - --skip-trust required for ephemeral scratch directories
      (otherwise gemini blocks on stdin asking the user to trust the
      workspace; fatal in headless mode)
  §INV-3 belt-and-braces:
    - _FORBIDDEN_FLAGS covers --yolo, -y, --raw-output,
      --accept-raw-output-risk, plus hypothetical CodeAct/REPL/eval
    - _FORBIDDEN_APPROVAL_MODES covers yolo + auto_edit (the values
      we'd never pass via --approval-mode)
  Spike fixture:
    - tests/fixtures/gemini_stream_json_tool_call.jsonl is real gemini
      0.39.1 output covering init / message (user) / tool_use /
      tool_result / message (assistant deltas) / result
  Typed auth error:
    - GeminiCLIAuthError raised on declared phrases in result events,
      assistant messages, OR stderr (mirrors codex stderr fallback
      from PR 16a R2 #1)

Refactor: CLISubscriptionAuthError base class (PR 16a R2 follow-up #4):
  - Defined in cli_subscription/base.py
  - Both CodexCLIAuthError and GeminiCLIAuthError subclass it
  - SubscriptionCLIBackend.generate_edit catches the BASE class via
    isinstance, no longer the per-adapter class
  - Reviewer's "premature today, defer to PR 16c" guidance was
    contingent on a single concrete instance — PR 16b's GeminiCLIAdapter
    is the second instance that motivates the abstraction

Stats: +325/-29 across 5 modified files (base, codex_cli, gemini_cli,
cli_subscription_backend, test_cli_subscription) plus 2 new files
(test_gemini_cli_adapter.py +377, gemini fixture +7). Total ≈ +709 LOC.
Authoritative count via `git diff --stat HEAD~1 HEAD | tail -1` after
push.

Test results: 2786 passed, 4 skipped (`pytest --ignore=tests/test_agents.py`).
The single pre-existing failure (test_create_agent_unknown_raises regex
case mismatch) traces to PR-16 commit c0906fa, unrelated to this PR.

PR 16b coverage:
  - argv: -p prompt, -o stream-json, --approval-mode default,
    --skip-trust
  - §INV-3: forbidden flags + forbidden approval-mode values absent
  - parser: spike fixture round-trip, assistant message aggregation
    (with delta chunks combining), user echo excluded, tool_use →
    tool_was_called, unknown event type → schema drift
  - typed auth: result-event phrase, assistant-message phrase, stderr
    fallback, non-auth errors not misclassified, phantom-free message
  - hierarchy: GeminiCLIAuthError + CodexCLIAuthError both subclass
    CLISubscriptionAuthError
  - backend integration: parse_output raises → AgentResult.error_type=AUTH
    via single isinstance-check on base class

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address 2 of 3 round-1 reviewer follow-ups (#1, #2). #3 (token cost
reporting / API-vs-OAuth framing) is forward-pointing for a future
docs/feature PR.

#1 docstring/code mismatch (gemini_cli.py module docstring):
  Module docstring claimed `--include-directories` was passed to
  scope the blast radius. build_argv never emits it. Reality: the
  scope-limiting is the subprocess `cwd` (set to scratch by the
  backend's run_subprocess in base.py) — gemini operates only on
  cwd by default, no flag needed. Updated docstring to reflect actual
  mechanism.

#2 verified auth-failure phrase:
  Captured real gemini 0.39.1 auth-failure stderr via HOME=tmp +
  empty GEMINI_API_KEY (saved as
  `tests/fixtures/gemini_auth_failure.stderr.txt`). The actual phrase
  emitted is "Please set an Auth method in your <path>/.gemini/
  settings.json or specify one of the following environment
  variables before running: GEMINI_API_KEY, ...". Added "Please set
  an Auth method" as the first entry in _AUTH_FAILURE_PHRASES with
  inline comment marking it as VERIFIED (vs the rest which remain
  EXTRAPOLATED for forward-compat).

  New regression test test_gemini_auth_error_real_fixture_from_spike
  exercises the real fixture against the parser → raises
  GeminiCLIAuthError. Notable: gemini 0.39.1 exits 0 on auth failure
  (not 1) — the typed-exception pattern is what catches it, not
  exit-code heuristics. Stderr-fallback path (PR 16a R2 #1) is the
  one that fires here.

Stats: +53/-15 across 3 files (gemini_cli.py +24/-15, new fixture +1,
test_gemini_cli_adapter.py +28).
Test results: 24 passed in test_gemini_cli_adapter.py (23 prior + 1
new fixture regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@suzuke suzuke changed the base branch from feat/m3-codex-cli-adapter to main April 26, 2026 06:43
@suzuke suzuke merged commit 75953c6 into main Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant