diff --git a/README.md b/README.md index 49347c3..0236f0b 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,13 @@ run, `scripts/impasse_run.py set-model --backend codex ` to persist, or th `IMPASSE_CODEX_MODEL` / `IMPASSE_CLAUDE_MODEL` env var. Precedence: flag > env > persisted > default. Pinning a model *different* from the host's buys a little extra independence *within* a rung (a different model, same provider). +**Execution speed (Fast mode):** Codex has a **Fast mode** service tier (**codex-only**) that trades +a **higher credit cost** for faster serving. It's off by default (`standard`). Set it the same way as +the model: `--speed fast` per run, `scripts/impasse_run.py set-speed fast` to persist (clear with +`--clear`), or the `IMPASSE_CODEX_SPEED` env var. Precedence: flag > env > persisted > default +(`standard`). It's **independent of `--effort`**, and — like the model and effort — the host can set +it for you conversationally ("always use fast mode"). The claude backend has no speed knob. + **Fast checks (`--raw`):** for a quick, low-stakes look at your own work, `review --raw` returns the reviewer's findings and skips the verify → reconcile → escalate protocol (and doesn't record). The findings are **unverified** — the host hasn't checked them — so use the full protocol when it matters. diff --git a/SKILL.md b/SKILL.md index 73f7de4..08bce10 100644 --- a/SKILL.md +++ b/SKILL.md @@ -144,7 +144,7 @@ backend is the cross-provider reviewer). The host is auto-detected (`IMPASSE_HOS python3 "$IMPASSE_ROOT/scripts/impasse_run.py" review \ --kind \ --instruction-file --artifact-file \ - [--backend auto|codex|claude] [--model ] [--approve-send ] [--effort none|low|medium|high|xhigh] [--wall 300] [--idle 300] + [--backend auto|codex|claude] [--model ] [--approve-send ] [--effort none|low|medium|high|xhigh] [--speed standard|fast] [--wall 300] [--idle 300] ``` It returns JSON: on success, `response` is the reviewer's **untrusted** structured output; on failure, a `failure` with a `code` @@ -221,6 +221,31 @@ backend is the cross-provider reviewer). The host is auto-detected (`IMPASSE_HOS claude backend has no effort knob — nothing resolves for it and any result that reaches backend resolution reports `effort: null`. **Scale `--wall` to the resolved effort** (see Timeouts above) — raising effort without raising the wall trades findings for timeouts. + + **Speed (Fast mode).** A separate **codex-only** service-tier knob, **independent of effort**. + Precedence: `--speed ` (this run) > `IMPASSE_CODEX_SPEED` env > persisted default + (`impasse_run.py set-speed `, clear with `--clear`) > **`standard`** (Fast mode + **off**, the default). `fast` turns Codex **Fast mode** on — roughly ~1.5× faster serving at a + **higher credit cost** — via `-c service_tier="fast" -c features.fast_mode=true`; `standard`/unset + add nothing. Values are allowlisted at every entry; a bad `IMPASSE_CODEX_SPEED` is a structured + `backend_error` naming the var, not a traceback. The claude backend has no speed knob — nothing + resolves for it and it reports `speed: null`. A codex run always reports the resolved `speed` + (`standard` or `fast`) alongside `model` and `effort`. Speed and effort compose freely (e.g. high + effort **and** fast mode). + + **Letting the operator choose model / effort / speed interactively.** These three reviewer knobs + share one rule: when the operator asks to choose or change any of them (or you offer), the runner + can't prompt, so present the options yourself with **`AskUserQuestion`** — for **speed**, offer + `standard` vs `fast` and note it is **codex-only** and that `fast` costs more credits; for + **effort**, the `none|low|medium|high|xhigh` scale (also codex-only); for **model**, a short + curated candidate list plus an "other" free-text choice (Codex has no model-list command; a bad + model fails with a clear 400). Then map their answer to scope: a **per-run** request ("review at + high effort with fast mode") becomes `--effort` / `--speed` (and `--model`) on **that run**; a + **persistent** request ("always use fast mode", "default my reviewer to ") becomes the + matching **`set-*`** command (`set-speed` / `set-effort` / `set-model`, clear with `--clear`). The + precedence is the same for all three — per-run flag > `IMPASSE_*` env > persisted `set-*` default > + the backend default — and **effort and speed are codex-only** (the claude backend reports both as + `null`). 4. **Treat `response` as partially validated.** The runner confirms it's JSON with the required top-level fields; full schema validation runs in CI (`tests/validate_schemas.py`), not at runtime. Don't rely on fields the runner didn't check without validating them yourself. diff --git a/docs/backends/codex.md b/docs/backends/codex.md index eb20a24..f1b1877 100644 --- a/docs/backends/codex.md +++ b/docs/backends/codex.md @@ -30,6 +30,7 @@ codex exec --json --output-last-message \ --sandbox read-only --color never --skip-git-repo-check --ephemeral \ --ignore-user-config --ignore-rules \ [-m ] [-c model_reasoning_effort="low"] \ + [-c service_tier="fast" -c features.fast_mode=true] # only when --speed fast (Fast mode ON) \ "" # artifact piped on stdin, then EOF ``` @@ -103,6 +104,23 @@ with it (see SKILL.md Timeouts). The claude backend has no effort equivalent: no for it (an `IMPASSE_CLAUDE_EFFORT` is ignored, never an error) and it reports `effort: null`. The argv builder re-checks the allowlist before interpolating into `-c` (defense in depth). +## Fast mode / service tier + +A separate codex-only knob, **independent of reasoning effort**: `--speed fast` selects Codex's +**Fast mode** service tier — roughly ~1.5× faster serving at a **higher credit cost** — by adding +`-c service_tier="fast" -c features.fast_mode=true`. `standard` (or unset) is the default and adds +no flags (Fast mode off). Precedence mirrors effort: `--speed` (per run) > `IMPASSE_CODEX_SPEED` +env > a persisted default (`impasse_run.py set-speed `, stored in `settings.json` +beside the model/effort defaults) > `standard`. The two-value allowlist (`standard|fast`) is +enforced at every entry point — the CLI flag and `set-speed` by argparse choices, `set_default_speed` +on write, `get_default_speed` again on read (a hand-edited `settings.json` can't smuggle a bad +value), and the env var at resolution: an invalid `IMPASSE_CODEX_SPEED` fails as a structured +`backend_error` naming the variable, never a traceback. The argv builder re-checks the allowlist +before interpolating into `-c` (defense in depth, so a future caller can't inject config syntax). +The review result reports the resolved value in `speed` (`standard` or `fast`); the claude backend +has no speed knob — an `IMPASSE_CLAUDE_SPEED` is ignored, never an error, and it reports `speed: +null`. Speed and effort compose freely (e.g. high effort with fast mode). + ## Failure handling (limits & outages) On an API error Codex exits non-zero and puts the real error — `{"type":"error"|"turn.failed", …}` diff --git a/scripts/impasse_lib.py b/scripts/impasse_lib.py index f2b655b..9954975 100644 --- a/scripts/impasse_lib.py +++ b/scripts/impasse_lib.py @@ -631,6 +631,11 @@ def _run_dir(run_id: str) -> str: # runner (per-run validation) and the settings store (set-effort validation) share one source. ALLOWED_EFFORT = ("none", "low", "medium", "high", "xhigh") +# Codex execution-speed / service-tier allowlist. "fast" == Fast mode ON (a higher serving tier at +# higher credit cost); "standard" is the default (Fast mode OFF). Independent of reasoning effort. +# Same one-source discipline as ALLOWED_EFFORT: shared by the runner and the set-speed settings store. +ALLOWED_SPEED = ("standard", "fast") # codex service tier; "fast" == Fast mode ON. Default OFF. + # --- Persisted settings (a small config store, e.g. the operator's default reviewer model) ------ @@ -676,6 +681,15 @@ def get_default_effort(backend: str) -> str | None: return e if e in ALLOWED_EFFORT else None +def get_default_speed(backend: str) -> str | None: + """The persisted default execution speed (service tier) for a backend, or None. Lower precedence + than a per-run --speed and than IMPASSE_CODEX_SPEED — see impasse_run.review(). A hand-edited + value outside ALLOWED_SPEED is dropped here (fail safe on the read path); set_default_speed + refuses to write one.""" + s = _get_default_setting("default_speed", backend) + return s if s in ALLOWED_SPEED else None + + def _settings_lock(): """An interprocess lock guarding the settings read-modify-write, so two hosts (e.g. a Claude Code and a Codex host sharing one config dir) can't lose an update via interleaved read-modify-replace @@ -753,6 +767,19 @@ def set_default_effort(backend: str, effort: str | None) -> None: _set_default_setting("default_effort", backend, effort) +def set_default_speed(backend: str, speed: str | None) -> None: + """Persist (speed set) or clear (speed None) the default execution speed / service tier for a + backend. Refuses a value outside ALLOWED_SPEED — never persist something the runner would reject. + Only codex HAS a service-tier/Fast-mode knob, so a non-null write for any other backend is refused + at the library level too (not just the CLI); clearing (speed=None) is allowed for any backend so a + legacy persisted value can always be removed (migration path).""" + if speed is not None and speed not in ALLOWED_SPEED: + raise ValueError(f"speed must be one of {sorted(ALLOWED_SPEED)}") + if speed is not None and backend != "codex": + raise ValueError(f"only the codex backend has a service-tier/Fast-mode knob (got backend={backend!r})") + _set_default_setting("default_speed", backend, speed) + + def fsync_dir(path: str) -> None: """fsync a directory so a preceding os.replace into it is durable across a crash. Best-effort: not every platform/filesystem supports it (Windows raises), so failures are swallowed.""" diff --git a/scripts/impasse_run.py b/scripts/impasse_run.py index 6c23d03..98105bd 100644 --- a/scripts/impasse_run.py +++ b/scripts/impasse_run.py @@ -45,6 +45,7 @@ _POSIX = os.name == "posix" _ALLOWED_EFFORT = frozenset(lib.ALLOWED_EFFORT) # single source of truth in impasse_lib +_ALLOWED_SPEED = frozenset(lib.ALLOWED_SPEED) # codex service tier / Fast mode; same single source _MAX_FINAL = 2_000_000 _MAX_INPUT = 4_000_000 # The reviewer response schema is embedded in the instruction so the reviewer knows the required @@ -259,21 +260,28 @@ def stdin_writer(): def build_codex_argv(backend_command, *, instruction: str, output_last_message: str, - effort: str | None = None, model: str | None = None) -> list[str]: + effort: str | None = None, model: str | None = None, + speed: str | None = None) -> list[str]: """Assemble a read-only `codex exec` review command. The artifact is fed on stdin (as context), not as an argv element, so large artifacts don't hit ARG_MAX and stdin still reaches EOF. + `speed` is the codex service tier / Fast mode and is INDEPENDENT of `effort` (reasoning + effort): "fast" turns Fast mode on (higher serving tier, higher credit cost); "standard"/None + leaves it off. The two knobs compose freely (e.g. high effort + fast mode). + NOTE: we do NOT use `--output-schema`. OpenAI's structured-output mode requires a restricted schema (every property in `required`, no oneOf/allOf/if-then/minLength/ pattern) — the rich reviewer-response schema doesn't qualify. Instead the schema is embedded in the instruction (see review()) and the output is validated afterward. """ - # Defense in depth: review() allowlists every effort source (flag/env/persisted), but this + # Defense in depth: review() allowlists every effort/speed source (flag/env/persisted), but this # helper is the surface that interpolates the value into a codex `-c` config expression — a # future direct caller must not be able to smuggle config syntax through it. if effort is not None and effort not in _ALLOWED_EFFORT: raise ValueError(f"effort must be one of {sorted(_ALLOWED_EFFORT)}") + if speed is not None and speed not in _ALLOWED_SPEED: + raise ValueError(f"speed must be one of {sorted(_ALLOWED_SPEED)}") argv = list(backend_command) + [ "exec", "--json", "--output-last-message", output_last_message, "--sandbox", "read-only", "--color", "never", @@ -295,6 +303,10 @@ def build_codex_argv(backend_command, *, instruction: str, output_last_message: argv += ["-m", model] if effort: argv += ["-c", f'model_reasoning_effort="{effort}"'] + # Fast mode is opt-in and independent of effort: set the service tier AND the feature flag only + # when explicitly "fast". "standard"/None add nothing (leave the account/backend default). + if speed == "fast": + argv += ["-c", 'service_tier="fast"', "-c", "features.fast_mode=true"] argv += [instruction] return argv @@ -504,7 +516,8 @@ def _fail(code, message, kind, notice, manifest, termination=None, retryable=Non def review(*, kind: str, instruction: str, artifact_bytes: bytes, backend: str = "auto", schema_path: str | None = None, approve_send: str | None = None, - effort: str | None = None, model: str | None = None, wall_timeout: float = 300.0, + effort: str | None = None, model: str | None = None, speed: str | None = None, + wall_timeout: float = 300.0, idle_timeout: float = 300.0, no_record: bool = False, raw: bool = False) -> dict: """Enforce consent, run a supervised read-only review, and classify the result. The returned 'response' is UNTRUSTED reviewer output — validate against the schema. @@ -514,6 +527,8 @@ def review(*, kind: str, instruction: str, artifact_bytes: bytes, backend: str = downgraded tier (same_provider/undetermined) carries an `independence_notice` the host surfaces.""" if effort is not None and effort not in _ALLOWED_EFFORT: raise ValueError(f"effort must be one of {sorted(_ALLOWED_EFFORT)}") + if speed is not None and speed not in _ALLOWED_SPEED: + raise ValueError(f"speed must be one of {sorted(_ALLOWED_SPEED)}") manifest = consent.manifest_for_bytes(artifact_bytes) hd = lib.host_detection() # one snapshot up front — every return path reports the host + provenance @@ -559,6 +574,15 @@ def review(*, kind: str, instruction: str, artifact_bytes: bytes, backend: str = or lib.get_default_effort("codex")) else: effort = None + # Speed (service tier / Fast mode) precedence mirrors effort: per-run --speed > IMPASSE_CODEX_SPEED + # env > persisted default (`set-speed`) > "standard" (Fast mode OFF). Independent of effort. Only + # the codex backend HAS this knob — for backends without one (claude), resolve nothing and report + # null: an irrelevant IMPASSE_CLAUDE_SPEED must neither fail the run nor masquerade as applied. + if be.name == "codex": + speed = (speed or os.environ.get("IMPASSE_CODEX_SPEED") + or lib.get_default_speed("codex") or "standard") + else: + speed = None # Independence is host-relative. Compute the tier ONCE from this run's single host snapshot (the # tier is never cached on Backend — F011) so host, confidence, tier, and notice can never disagree @@ -571,7 +595,7 @@ def review(*, kind: str, instruction: str, artifact_bytes: bytes, backend: str = # disclosure carried on EVERY return path (success and failure), not just success bmeta = {"backend": be.name, "provider": be.provider, "independence": independence, "host": host, "host_detection": {"method": hd["method"], "confidence": hd["confidence"]}, - "model": model, "effort": effort, "independence_notice": independence_notice} + "model": model, "effort": effort, "speed": speed, "independence_notice": independence_notice} # The per-run param was validated above; the persisted default is allowlisted on both write # (set_default_effort) and read (get_default_effort). So an invalid value here can only come @@ -580,6 +604,13 @@ def review(*, kind: str, instruction: str, artifact_bytes: bytes, backend: str = msg = (f"IMPASSE_CODEX_EFFORT={effort!r} is not a valid reasoning effort " f"(one of {sorted(_ALLOWED_EFFORT)})") return {**_fail("backend_error", msg, kind, msg, manifest), **bmeta} + # Same for speed: the per-run param and persisted default are allowlisted on both write + # (set_default_speed) and read (get_default_speed), so an invalid resolved codex speed here can + # only come from IMPASSE_CODEX_SPEED — a config error, not API misuse: fail structured. + if speed is not None and speed not in _ALLOWED_SPEED: + msg = (f"IMPASSE_CODEX_SPEED={speed!r} is not a valid execution speed " + f"(one of {sorted(_ALLOWED_SPEED)})") + return {**_fail("backend_error", msg, kind, msg, manifest), **bmeta} approved, notice = consent.check(be, manifest=manifest, approve_send=approve_send) if not approved: @@ -628,7 +659,8 @@ def _schema_fail(detail): # every schema defect is a structured backend_error, out_fd, out_last = tempfile.mkstemp(prefix="last-", suffix=".txt", dir=scratch) os.close(out_fd) argv = build_codex_argv(be.command, instruction=full_instruction, - output_last_message=out_last, effort=effort, model=model) + output_last_message=out_last, effort=effort, model=model, + speed=speed) elif be.type == "claude-cli": argv = build_claude_argv(be.command, instruction=full_instruction, model=model) else: @@ -798,6 +830,10 @@ def _main(argv=None) -> int: rv.add_argument("--effort", default=None, choices=sorted(_ALLOWED_EFFORT), help="codex reasoning effort (else IMPASSE_CODEX_EFFORT, else the persisted " "set-effort default, else the codex default; ignored by the claude backend)") + rv.add_argument("--speed", default=None, choices=sorted(_ALLOWED_SPEED), + help="codex service tier / Fast mode (else IMPASSE_CODEX_SPEED, else the persisted " + "set-speed default, else standard = Fast OFF; independent of --effort; ignored " + "by the claude backend)") rv.add_argument("--model", default=None, help="reviewer model (else IMPASSE_CODEX_MODEL / IMPASSE_CLAUDE_MODEL, else the backend default)") rv.add_argument("--wall", type=float, default=300.0, @@ -828,6 +864,14 @@ def _main(argv=None) -> int: se.add_argument("effort", nargs="?", default=None, choices=sorted(_ALLOWED_EFFORT), help="effort to persist; omit to show the current default") se.add_argument("--clear", action="store_true", help="clear the persisted default for this backend") + sp = sub.add_parser("set-speed", help="persist (or show/clear) the default execution speed / Fast mode (codex only)") + # Only codex has a service-tier/Fast-mode knob, so set_default_speed refuses a non-null non-codex + # WRITE. We still expose `claude` here so a legacy persisted claude speed can be CLEARED + # (`set-speed --backend claude --clear`) — the library allows speed=None for any backend. + sp.add_argument("--backend", default="codex", choices=["codex", "claude"]) + sp.add_argument("speed", nargs="?", default=None, choices=sorted(_ALLOWED_SPEED), + help="speed to persist ('standard'|'fast'); omit to show the current default") + sp.add_argument("--clear", action="store_true", help="clear the persisted default for this backend") args = ap.parse_args(argv) if args.cmd == "set-model": @@ -858,6 +902,29 @@ def _main(argv=None) -> int: print(f"default effort for {args.backend}: {lib.get_default_effort(args.backend) or '(backend default)'}") return 0 + if args.cmd == "set-speed": + if args.clear and args.speed: + print("give a speed to persist OR --clear, not both", file=sys.stderr) + return 2 + if args.clear: + lib.set_default_speed(args.backend, None) # clearing is allowed for any backend (migration) + print(f"cleared persisted default speed for {args.backend}") + elif args.speed: + # Only codex has a Fast-mode/service-tier knob: set_default_speed refuses a non-null write + # for any other backend. Surface that as a clean exit 2, not an uncaught traceback. + try: + lib.set_default_speed(args.backend, args.speed) + except ValueError as e: + print(str(e), file=sys.stderr) + return 2 + print(f"persisted default speed for {args.backend}: {args.speed}") + elif args.backend != "codex": + # Don't present a speed for a backend that has no such knob — that would read as if it did. + print(f"the {args.backend} backend has no speed/Fast-mode knob") + else: + print(f"default speed for {args.backend}: {lib.get_default_speed(args.backend) or '(standard, Fast off)'}") + return 0 + if args.cmd == "mode": def _avail(resolve): try: # a bad *_BIN override raises; treat as unavailable, don't crash @@ -883,7 +950,7 @@ def _avail(resolve): return 1 result = review(kind=args.kind, instruction=instruction, artifact_bytes=artifact_bytes, backend=args.backend, schema_path=args.schema, approve_send=args.approve_send, - effort=args.effort, model=args.model, wall_timeout=args.wall, + effort=args.effort, model=args.model, speed=args.speed, wall_timeout=args.wall, idle_timeout=args.idle, no_record=args.no_record, raw=args.raw) print(json.dumps(result, indent=2)) return 0 if result.get("ok") else 1 diff --git a/tests/test_helpers.py b/tests/test_helpers.py index a75ff40..578d91c 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -177,7 +177,8 @@ def main() -> int: # IMPASSE_CODEX_MODEL or a custom base URL would otherwise break the suite. Standalone # process: clear, don't bother restoring. for _v in ("IMPASSE_HOST", "IMPASSE_ENV", "IMPASSE_CODEX_MODEL", "IMPASSE_CODEX_EFFORT", - "IMPASSE_CLAUDE_MODEL", "IMPASSE_CLAUDE_EFFORT", "IMPASSE_CODEX_RESPECT_CONFIG", + "IMPASSE_CLAUDE_MODEL", "IMPASSE_CLAUDE_EFFORT", "IMPASSE_CODEX_SPEED", + "IMPASSE_CLAUDE_SPEED", "IMPASSE_CODEX_RESPECT_CONFIG", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "OPENAI_BASE_URL", "ANTHROPIC_BASE_URL", "FAKE_COUNT_ALL", "FAKE_COUNTER"): os.environ.pop(_v, None) @@ -902,6 +903,127 @@ def _spy(argv, **kw): lib.set_default_model("codex", None) lib.set_default_effort("codex", None) + # --- execution speed (Codex Fast mode): a codex-only service-tier knob, mirroring effort --- + _sp_fast = run.build_codex_argv(["/x/codex"], instruction="I", output_last_message="/tmp/o", speed="fast") + check('service_tier="fast"' in _sp_fast and "features.fast_mode=true" in _sp_fast, + "build_codex_argv: speed=fast adds both -c service_tier and -c features.fast_mode") + _sp_std = run.build_codex_argv(["/x/codex"], instruction="I", output_last_message="/tmp/o", speed="standard") + _sp_none = run.build_codex_argv(["/x/codex"], instruction="I", output_last_message="/tmp/o") + check('service_tier="fast"' not in _sp_std and "features.fast_mode=true" not in _sp_std + and 'service_tier="fast"' not in _sp_none and "features.fast_mode=true" not in _sp_none, + "build_codex_argv: speed=standard/None adds neither fast flag") + check(lib.get_default_speed("codex") is None, "settings: no persisted speed by default") + # no speed configured -> "standard" (Fast OFF) reported, and no fast flags reach the argv + _spd_orig, _spd_cap = run.supervise, {} + + def _spd_spy(argv, **kw): + _spd_cap["argv"] = argv + return _spd_orig(argv, **kw) + run.supervise = _spd_spy + rm = run.review(kind="code", instruction="review", artifact_bytes=b"code", no_record=True) + run.supervise = _spd_orig + check(rm["ok"] and rm.get("speed") == "standard" + and 'service_tier="fast"' not in _spd_cap.get("argv", []), + "review: no speed configured -> standard (Fast OFF) reported, no fast flags in argv") + lib.set_default_speed("codex", "fast") + check(lib.get_default_speed("codex") == "fast", "settings: set/get persisted default speed") + rm = run.review(kind="code", instruction="review", artifact_bytes=b"code", no_record=True) + check(rm.get("speed") == "fast", "review: persisted default speed resolves when no flag/env") + os.environ["IMPASSE_CODEX_SPEED"] = "standard" + rm = run.review(kind="code", instruction="review", artifact_bytes=b"code", no_record=True) + check(rm.get("speed") == "standard", "review: IMPASSE_CODEX_SPEED beats the persisted default") + rm = run.review(kind="code", instruction="review", artifact_bytes=b"code", speed="fast", no_record=True) + check(rm.get("speed") == "fast", "review: per-run --speed beats env and persisted") + os.environ["IMPASSE_CODEX_SPEED"] = "turbo" + rm = run.review(kind="code", instruction="review", artifact_bytes=b"code", no_record=True) + check(rm["ok"] is False and rm["failure"]["code"] == "backend_error" + and "IMPASSE_CODEX_SPEED" in rm["failure"]["message"], + "review: invalid env speed -> structured failure naming the env var") + os.environ.pop("IMPASSE_CODEX_SPEED", None) + lib.set_default_speed("codex", None) + try: + lib.set_default_speed("codex", "turbo") + _sp_bad = False + except ValueError: + _sp_bad = True + check(_sp_bad, "settings: set_default_speed refuses a disallowed value ('turbo')") + # only codex has a service-tier knob: the LIBRARY setter refuses a non-null claude write (dead + # config the runner can't consume) but still allows CLEARING one (legacy migration path). + _sp_claude_refused = False + try: + lib.set_default_speed("claude", "fast") + except ValueError: + _sp_claude_refused = True + check(_sp_claude_refused, "F008: set_default_speed refuses a non-null claude write (library level)") + lib.set_default_speed("claude", None) # clearing must NOT raise (migration path) + check(True, "F008: set_default_speed(claude, None) clears without error") + with open(lib._settings_path(), "w") as _sf: + _sf.write('{"default_speed": {"codex": "turbo"}}') + check(lib.get_default_speed("codex") is None, "settings: hand-edited invalid speed dropped on read (fail safe)") + check(run._main(["set-speed", "--backend", "codex", "fast", "--clear"]) == 2, "set-speed: a speed + --clear together is rejected") + check(run._main(["set-speed", "fast"]) == 0 and lib.get_default_speed("codex") == "fast", "set-speed: persists via CLI (and repairs a malformed store)") + check(run._main(["set-speed", "--clear"]) == 0 and lib.get_default_speed("codex") is None, "set-speed: --clear via CLI") + # a non-null claude speed write is refused by the library — the CLI must surface it as a clean + # exit 2, never an uncaught ValueError traceback + check(run._main(["set-speed", "--backend", "claude", "fast"]) == 2, + "set-speed: a non-null claude write exits 2 cleanly (no traceback)") + # the resolved speed must actually reach the codex argv, not just the result metadata + _spd_orig2, _spd_cap2 = run.supervise, {} + + def _spd_spy2(argv, **kw): + _spd_cap2["argv"] = argv + return _spd_orig2(argv, **kw) + run.supervise = _spd_spy2 + os.environ["IMPASSE_CODEX_SPEED"] = "fast" + rm = run.review(kind="code", instruction="review", artifact_bytes=b"code", no_record=True) + run.supervise = _spd_orig2 + os.environ.pop("IMPASSE_CODEX_SPEED", None) + check(rm.get("speed") == "fast" and 'service_tier="fast"' in _spd_cap2.get("argv", []) + and "features.fast_mode=true" in _spd_cap2.get("argv", []), + "review: env-resolved speed reaches the codex argv (not just metadata)") + # defense in depth: the argv builder itself refuses a non-allowlisted speed (config-syntax payload) + _sp_inj = False + try: + run.build_codex_argv(["/x/codex"], instruction="I", output_last_message="/tmp/o", + speed='fast" injected="1') + except ValueError: + _sp_inj = True + check(_sp_inj, "build_codex_argv: rejects a non-allowlisted speed itself (no config injection)") + # claude has no speed knob: an irrelevant IMPASSE_CLAUDE_SPEED (even an invalid one) must + # neither fail the run nor be reported as configuration that was applied + os.environ["IMPASSE_CLAUDE_SPEED"] = "turbo" + rc = run.review(kind="decision", instruction="review", artifact_bytes=b"memo", backend="claude", no_record=True) + os.environ.pop("IMPASSE_CLAUDE_SPEED", None) + check(rc["ok"] is True and rc.get("speed") is None, + "review(claude): irrelevant IMPASSE_CLAUDE_SPEED neither fails the run nor reports as applied") + # the generic settings writer preserves sibling keys (model + effort) when speed is written + lib.set_default_model("codex", "keep-model-3") + lib.set_default_effort("codex", "low") + lib.set_default_speed("codex", "fast") + check(lib.get_default_model("codex") == "keep-model-3" and lib.get_default_effort("codex") == "low" + and lib.get_default_speed("codex") == "fast", + "settings: speed write preserves the model and effort defaults") + lib.set_default_model("codex", None) + lib.set_default_effort("codex", None) + lib.set_default_speed("codex", None) + # speed rides the success-path result metadata alongside model + effort + rm = run.review(kind="code", instruction="review", artifact_bytes=b"code", no_record=True) + check(rm["ok"] and "speed" in rm and "model" in rm and "effort" in rm, + "review: speed appears in a successful codex run's result metadata alongside model/effort") + # HOST-FACING doc consistency: the operator drives Impasse THROUGH the host AI, so the speed + # surface must be documented where the host reads (stdlib file reads, no deps). + def _sp_doc(fn): + with open(os.path.join(HERE, "..", fn), encoding="utf-8") as _df: + return _df.read() + _sp_skill = _sp_doc("SKILL.md") + _sp_readme = _sp_doc("README.md") + _sp_codex = _sp_doc("docs/backends/codex.md") + check(all("--speed" in d and "IMPASSE_CODEX_SPEED" in d and "set-speed" in d + for d in (_sp_skill, _sp_readme, _sp_codex)) + and "AskUserQuestion" in _sp_skill and "standard" in _sp_skill and "fast" in _sp_skill + and "codex-only" in _sp_skill, + "docs: SKILL/README/codex document the --speed / set-speed / IMPASSE_CODEX_SPEED surface") + # --- host-relative independence (IMPASSE_HOST): the tier is a relation, not a backend property --- _host_env = {k: os.environ.pop(k, None) for k in ( "IMPASSE_HOST", "IMPASSE_ENV", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",