diff --git a/README.md b/README.md
index adbbe111..76504c5b 100644
--- a/README.md
+++ b/README.md
@@ -45,22 +45,60 @@ The canonical implementation lives in [`rust/`](./rust), and the current source
## Quick start
+> [!NOTE]
+> [!WARNING]
+> **`cargo install claw-code` installs the wrong thing.** The `claw-code` crate on crates.io is a deprecated stub that places `claw-code-deprecated.exe` — not `claw`. Running it only prints `"claw-code has been renamed to agent-code"`. **Do not use `cargo install claw-code`.** Either build from source (this repo) or install the upstream binary:
+> ```bash
+> cargo install agent-code # upstream binary — installs 'agent.exe' (Windows) / 'agent' (Unix), NOT 'agent-code'
+> ```
+> This repo (`ultraworkers/claw-code`) is **build-from-source only** — follow the steps below.
+
```bash
-cd rust
+# 1. Clone and build
+git clone https://github.com/ultraworkers/claw-code
+cd claw-code/rust
cargo build --workspace
-./target/debug/claw --help
-./target/debug/claw prompt "summarize this repository"
-```
-Authenticate with either an API key or the built-in OAuth flow:
-
-```bash
+# 2. Set your API key (Anthropic API key — not a Claude subscription)
export ANTHROPIC_API_KEY="sk-ant-..."
-# or
-cd rust
-./target/debug/claw login
+
+# 3. Verify everything is wired correctly
+./target/debug/claw doctor
+
+# 4. Run a prompt
+./target/debug/claw prompt "say hello"
```
+> [!NOTE]
+> **Windows (PowerShell):** the binary is `claw.exe`, not `claw`. Use `.\target\debug\claw.exe` or run `cargo run -- prompt "say hello"` to skip the path lookup.
+
+### Windows setup
+
+**PowerShell is a supported Windows path.** Use whichever shell works for you. The common onboarding issues on Windows are:
+
+1. **Install Rust first** — download from and run the installer. Close and reopen your terminal when it finishes.
+2. **Verify Rust is on PATH:**
+ ```powershell
+ cargo --version
+ ```
+ If this fails, reopen your terminal or run the PATH setup from the Rust installer output, then retry.
+3. **Clone and build** (works in PowerShell, Git Bash, or WSL):
+ ```powershell
+ git clone https://github.com/ultraworkers/claw-code
+ cd claw-code/rust
+ cargo build --workspace
+ ```
+4. **Run** (PowerShell — note `.exe` and backslash):
+ ```powershell
+ $env:ANTHROPIC_API_KEY = "sk-ant-..."
+ .\target\debug\claw.exe prompt "say hello"
+ ```
+
+**Git Bash / WSL** are optional alternatives, not requirements. If you prefer bash-style paths (`/c/Users/you/...` instead of `C:\Users\you\...`), Git Bash (ships with Git for Windows) works well. In Git Bash, the `MINGW64` prompt is expected and normal — not a broken install.
+
+> [!NOTE]
+> **Auth:** claw requires an **API key** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) — Claude subscription login is not a supported auth path.
+
Run the workspace test suite:
```bash
diff --git a/ROADMAP.md b/ROADMAP.md
index 9e267ae6..cf4bb299 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -483,8 +483,64 @@ Model name prefix now wins unconditionally over env-var presence. Regression tes
31. **`code-on-disk → verified commit lands` depends on undocumented executor quirks** — dogfooded 2026-04-08 during live fix session. Three hidden contracts tripped the "last mile" path when using droid via acpx in the claw-code workspace: **(a) hidden CWD contract** — droid's `terminal/create` rejects `cd /path && cargo build` compound commands with `spawn ENOENT`; callers must pass `--cwd` or split commands; **(b) hidden commit-message transport limit** — embedding a multi-line commit message in a single shell invocation hits `ENAMETOOLONG`; workaround is `git commit -F ` but the caller must know to write the file first; **(c) hidden workspace lint/edition contract** — `unsafe_code = "forbid"` workspace-wide with Rust 2021 edition makes `unsafe {}` wrappers incorrect for `set_var`/`remove_var`, but droid generates Rust 2024-style unsafe blocks without inspecting the workspace Cargo.toml or clippy config. Each of these required the orchestrator to learn the constraint by failing, then switching strategies. **Acceptance bar:** a fresh agent should be able to verify/commit/push a correct diff in this workspace without needing to know executor-specific shell trivia ahead of time. **Fix shape:** (1) `run-acpx.sh`-style wrapper that normalizes the commit idiom (always writes to temp file, sets `--cwd`, splits compound commands); (2) inject workspace constraints into the droid/acpx task preamble (edition, lint gates, known shell executor quirks) so the model doesn't have to discover them from failures; (3) or upstream a fix to the executor itself so `cd /path && cmd` chains work correctly.
-32. **OpenAI-compatible provider/model-id passthrough is not fully literal** — dogfooded 2026-04-08 via live user in #claw-code who confirmed the exact backend model id works outside claw but fails through claw for an OpenAI-compatible endpoint. The gap: `openai/` prefix is correctly used for **transport selection** (pick the OpenAI-compat client) but the **wire model id** — the string placed in `"model": "..."` in the JSON request body — may not be the literal backend model string the user supplied. Two candidate failure modes: **(a)** `resolve_model_alias()` is called on the model string before it reaches the wire — alias expansion designed for Anthropic/known models corrupts a user-supplied backend-specific id; **(b)** the `openai/` routing prefix may not be stripped before `build_chat_completion_request` packages the body, so backends receive `openai/gpt-4` instead of `gpt-4`. **Fix shape:** cleanly separate transport selection from wire model id. Transport selection uses the prefix; wire model id is the user-supplied string minus only the routing prefix — no alias expansion, no prefix leakage. **Trace path for next session:** (1) find where `resolve_model_alias()` is called relative to the OpenAI-compat dispatch path; (2) inspect what `build_chat_completion_request` puts in `"model"` for an `openai/some-backend-id` input. **Source:** live user in #claw-code 2026-04-08, confirmed exact model id works outside claw, fails through claw for OpenAI-compat backend.
+32. **OpenAI-compatible provider/model-id passthrough is not fully literal** — **verified no-bug on 2026-04-09**: `resolve_model_alias()` only matches bare shorthand aliases (`opus`/`sonnet`/`haiku`) and passes everything else through unchanged, so `openai/gpt-4` reaches the dispatch layer unmodified. `strip_routing_prefix()` at `openai_compat.rs:732` then strips only recognised routing prefixes (`openai`, `xai`, `grok`, `qwen`) so the wire model is the bare backend id. No fix needed. **Original filing below.**
32. **OpenAI-compatible provider/model-id passthrough is not fully literal** — dogfooded 2026-04-08 via live user in #claw-code who confirmed the exact backend model id works outside claw but fails through claw for an OpenAI-compatible endpoint. The gap: `openai/` prefix is correctly used for **transport selection** (pick the OpenAI-compat client) but the **wire model id** — the string placed in `"model": "..."` in the JSON request body — may not be the literal backend model string the user supplied. Two candidate failure modes: **(a)** `resolve_model_alias()` is called on the model string before it reaches the wire — alias expansion designed for Anthropic/known models corrupts a user-supplied backend-specific id; **(b)** the `openai/` routing prefix may not be stripped before `build_chat_completion_request` packages the body, so backends receive `openai/gpt-4` instead of `gpt-4`. **Fix shape:** cleanly separate transport selection from wire model id. Transport selection uses the prefix; wire model id is the user-supplied string minus only the routing prefix — no alias expansion, no prefix leakage. **Trace path for next session:** (1) find where `resolve_model_alias()` is called relative to the OpenAI-compat dispatch path; (2) inspect what `build_chat_completion_request` puts in `"model"` for an `openai/some-backend-id` input. **Source:** live user in #claw-code 2026-04-08, confirmed exact model id works outside claw, fails through claw for OpenAI-compat backend.
-32. **OpenAI-compatible provider/model-id passthrough is not fully literal** — dogfooded 2026-04-08 via live user in #claw-code who confirmed the exact backend model id works outside claw but fails through claw for an OpenAI-compatible endpoint. The gap: `openai/` prefix is correctly used for **transport selection** (pick the OpenAI-compat client) but the **wire model id** — the string placed in `"model": "..."` in the JSON request body — may not be the literal backend model string the user supplied. Two candidate failure modes: **(a)** `resolve_model_alias()` is called on the model string before it reaches the wire — alias expansion designed for Anthropic/known models corrupts a user-supplied backend-specific id; **(b)** the `openai/` routing prefix may not be stripped before `build_chat_completion_request` packages the body, so backends receive `openai/gpt-4` instead of `gpt-4`. **Fix shape:** cleanly separate transport selection from wire model id. Transport selection uses the prefix; wire model id is the user-supplied string minus only the routing prefix — no alias expansion, no prefix leakage. **Trace path for next session:** (1) find where `resolve_model_alias()` is called relative to the OpenAI-compat dispatch path; (2) inspect what `build_chat_completion_request` puts in `"model"` for an `openai/some-backend-id` input. **Source:** live user in #claw-code 2026-04-08, confirmed exact model id works outside claw, fails through claw for OpenAI-compat backend.
+33. **OpenAI `/responses` endpoint rejects claw's tool schema: `object schema missing properties` / `invalid_function_parameters`** — **done at `e7e0fd2` on 2026-04-09**. Added `normalize_object_schema()` in `openai_compat.rs` which recursively walks JSON Schema trees and injects `"properties": {}` and `"additionalProperties": false` on every object-type node (without overwriting existing values). Called from `openai_tool_definition()` so both `/chat/completions` and `/responses` receive strict-validator-safe schemas. 3 unit tests added. All api tests pass. **Original filing below.**
+33. **OpenAI `/responses` endpoint rejects claw's tool schema: `object schema missing properties` / `invalid_function_parameters`** — dogfooded 2026-04-08 via live user in #claw-code. Repro: startup succeeds, provider routing succeeds (`Connected: gpt-5.4 via openai`), but request fails when claw sends tool/function schema to a `/responses`-compatible OpenAI backend. Backend rejects `StructuredOutput` with `object schema missing properties` and `invalid_function_parameters`. This is distinct from the `#32` model-id passthrough issue — routing and transport work correctly. The failure is at the schema validation layer: claw's tool schema is acceptable for `/chat/completions` but not strict enough for `/responses` endpoint validation. **Sharp next check:** emit what schema claw sends for `StructuredOutput` tool functions, compare against OpenAI `/responses` spec for strict JSON schema validation (required `properties` object, `additionalProperties: false`, etc). Likely fix: add missing `properties: {}` on object types, ensure `additionalProperties: false` is present on all object schemas in the function tool JSON. **Source:** live user in #claw-code 2026-04-08 with `gpt-5.4` on OpenAI-compat backend.
+
+
+34. **`reasoning_effort` / `budget_tokens` not surfaced on OpenAI-compat path** — dogfooded 2026-04-09. Users asking for "reasoning effort parity with opencode" are hitting a structural gap: `MessageRequest` in `rust/crates/api/src/types.rs` has no `reasoning_effort` or `budget_tokens` field, and `build_chat_completion_request` in `openai_compat.rs` does not inject either into the request body. This means passing `--thinking` or equivalent to an OpenAI-compat reasoning model (e.g. `o4-mini`, `deepseek-r1`, any model that accepts `reasoning_effort`) silently drops the field — the model runs without the requested effort level, and the user gets no warning. **Contrast with Anthropic path:** `anthropic.rs` already maps `thinking` config into `anthropic.thinking.budget_tokens` in the request body. **Fix shape:** (a) Add optional `reasoning_effort: Option` field to `MessageRequest`; (b) In `build_chat_completion_request`, if `reasoning_effort` is `Some`, emit `"reasoning_effort": value` in the JSON body; (c) In the CLI, wire `--thinking low/medium/high` or equivalent to populate the field when the resolved provider is `ProviderKind::OpenAi`; (d) Add unit test asserting `reasoning_effort` appears in the request body when set. **Source:** live user questions in #claw-code 2026-04-08/09 (dan_theman369 asking for "same flow as opencode for reasoning effort"; gaebal-gajae confirmed gap at `1491453913100976339`). Companion gap to #33 on the OpenAI-compat path.
+
+35. **OpenAI gpt-5.x requires max_completion_tokens not max_tokens** -- dogfooded 2026-04-09. rklehm repro: gpt-5.2 via OpenAI-compat, startup OK, routing OK, but requests fail because claw emits max_tokens where gpt-5* requires max_completion_tokens. Fix: emit max_completion_tokens on OpenAI-compat path (backward-compatible). Add unit test. Source: rklehm in #claw-code 2026-04-09.
+
+36. **Custom/project skill invocation disconnected from skill discovery** -- dogfooded 2026-04-09. /skills lists custom skills (e.g. caveman) but bare skill-name invocation does not dispatch them; falls through to plain model prompt. Fix: audit classify_skills_slash_command, ensure any skill listed by /skills has a deterministic invocation path, or document the correct syntax. Source: gaebal-gajae dogfood 2026-04-09.
+
+37. **Claude subscription login path should be removed, not deprecated** -- dogfooded 2026-04-09. Official auth should be API key only (ANTHROPIC_API_KEY or OAuth access token via ANTHROPIC_AUTH_TOKEN). claw login with Claude subscription credentials creates legal/billing ambiguity. Fix: remove the subscription login surface entirely; update README/USAGE.md to say API key is the only supported path. Source: gaebal-gajae policy decision 2026-04-09.
+
+38. **Dead-session opacity: bot cannot self-detect compaction vs broken tool surface** -- dogfooded 2026-04-09. Jobdori session spent ~15h declaring itself "dead" in-channel while tools were actually returning correct results within each turn. Root cause: context compaction causes tool outputs to be summarised away between turns, making the bot interpret absence-of-remembered-output as tool failure. This is a distinct failure mode from ROADMAP #31 (executor quirks): the session is alive and tools are functional, but the agent cannot tell the difference between "my last tool call produced no output" (compaction) and "the tool is broken". Downstream: repetitive false-dead signals in the channel, work not getting done despite the execution surface being live. Fix shape: (a) probe with a short known-output command at turn start if context has been compacted; (b) gate "I am dead" declarations behind at least one within-turn tool call with a verified non-empty result; (c) consider adding a session-health canary cron that fires a wake with a minimal probe and checks the result. Source: Jobdori self-dogfood 2026-04-09; observed in #clawcode-building-in-public across multiple Clawhip nudge cycles.
+
+39. **Several slash commands are registered but not implemented: /branch, /rewind, /ide, /tag, /output-style, /add-dir** -- dogfooded 2026-04-09. These commands appear in the REPL completions surface but silently print 'Command registered but not yet implemented.' and return false. Users (mezz2301 in #claw-code) hit this as 'many features are not supported in this version now'. Fix shape: either (a) implement the missing commands, or (b) remove them from completions/help output until they are ready, so the discovery surface matches what actually works. Source: mezz2301 in #claw-code 2026-04-09; pinpointed in main.rs:3728.
+
+40. **Surface broken installed plugins before they become support ghosts** — community-support lane. Clawhip commit `ff6d3b7` on worktree `claw-code-community-support-plugin-list-load-failures` / branch `community-support/plugin-list-load-failures`. When an installed plugin has a broken manifest (missing hook scripts, parse errors, bad json), the plugin silently fails to load and the user sees nothing — no warning, no list entry, no hint. Related to ROADMAP #27 (host plugin path leaking into tests) but at the user-facing surface: the test gap and the UX gap are siblings of the same root. Landing on `main` will close the silent-ghost class of support issues where users report "my plugin does nothing" with no error to share. Track until merged to `main`.
+
+40. **Surface broken installed plugins before they become support ghosts** — community-support lane. Clawhip commit `ff6d3b7` on worktree `claw-code-community-support-plugin-list-load-failures` / branch `community-support/plugin-list-load-failures`. When an installed plugin has a broken manifest (missing hook scripts, parse errors, bad json), the plugin silently fails to load and the user sees nothing — no warning, no list entry, no hint. Related to ROADMAP #27 (host plugin path leaking into tests) but at the user-facing surface: the test gap and the UX gap are siblings of the same root. Landing on `main` will close the silent-ghost class of support issues where users report "my plugin does nothing" with no error to share. Track until merged to `main`.
+
+41. **Stop ambient plugin state from skewing CLI regression checks** — community-support lane. Clawhip commit `7d493a7` on worktree `claw-code-community-support-plugin-test-sealing` / branch `community-support/plugin-test-sealing`. Companion to #40: the test sealing gap is the CI/developer side of the same root — host `~/.claude/plugins/installed/` bleeds into CLI test runs, making regression checks non-deterministic on any machine with a non-pristine plugin install. Closely related to ROADMAP #27 (dev/rust `cargo test` reads host plugin state). Track until merged to `main`.
+
+42. **`--output-format json` errors emitted as prose, not JSON** — dogfooded 2026-04-09. When `claw --output-format json prompt` hits an API error, the error was printed as plain text (`error: api returned 401 ...`) to stderr instead of a JSON object. Any tool or CI step parsing claw's JSON output gets nothing parseable on failure — the error is invisible to the consumer. **Fix (`a...`):** detect `--output-format json` in `main()` at process exit and emit `{"type":"error","error":""}` to stderr instead of the prose format. Non-JSON path unchanged. **Done** in this nudge cycle.
+
+43. **Hook ingress opacity: typed hook-health/delivery report missing** — dogfooded 2026-04-09 while wiring the agentika timer→hook→session bridge. Debugging hook delivery required manual HTTP probing and inferring state from raw status codes (404 = no route, 405 = route exists, 400 = body missing required field). No typed endpoint exists to report: route present/absent, accepted methods, mapping matched/not matched, target session resolved/not resolved, last delivery failure class. Fix shape: add `GET /hooks/health` (or `/hooks/status`) returning a structured JSON diagnostic — no auth exposure, just routing/matching/session state. Source: gaebal-gajae dogfood 2026-04-09.
+
+44. **Broad-CWD guardrail is warning-only; needs policy-level enforcement** — dogfooded 2026-04-09. `5f6f453` added a stderr warning when claw starts from `$HOME` or filesystem root (live user kapcomunica scanned their whole machine). Warning is a mitigation, not a guardrail: the agent still proceeds with unbounded scope. Follow-up fix shape: (a) add `--allow-broad-cwd` flag to suppress the warning explicitly (for legitimate home-dir use cases); (b) in default interactive mode, prompt "You are running from your home directory — continue? [y/N]" and exit unless confirmed; (c) in `--output-format json` or piped mode, treat broad-CWD as a hard error (exit 1) with `{"type":"error","error":"broad CWD: running from home directory requires --allow-broad-cwd"}`. Source: kapcomunica in #claw-code 2026-04-09; gaebal-gajae ROADMAP note same cycle.
+
+45. **`claw dump-manifests` fails with opaque "No such file or directory"** — dogfooded 2026-04-09. `claw dump-manifests` emits `error: failed to extract manifests: No such file or directory (os error 2)` with no indication of which file or directory is missing. **Partial fix at `47aa1a5`+1**: error message now includes `looked in: ` so the build-tree path is visible, what manifests are, or how to fix it. Fix shape: (a) surface the missing path in the error message; (b) add a pre-check that explains what manifests are and where they should be (e.g. `.claw/manifests/` or the plugins directory); (c) if the command is only valid after `claw init` or after installing plugins, say so explicitly. Source: Jobdori dogfood 2026-04-09.
+
+46. **`/tokens`, `/cache`, `/stats` were dead spec — parse arms missing** — dogfooded 2026-04-09. All three had spec entries with `resume_supported: true` but no parse arms, producing the circular error "Unknown slash command: /tokens — Did you mean /tokens". Also `SlashCommand::Stats` existed but was unimplemented in both REPL and resume dispatch. **Done at `60ec2ae` 2026-04-09**: `"tokens" | "cache"` now alias to `SlashCommand::Stats`; `Stats` is wired in both REPL and resume path with full JSON output. Source: Jobdori dogfood 2026-04-09.
+
+47. **`/diff` fails with cryptic "unknown option 'cached'" outside a git repo; resume /diff used wrong CWD** — dogfooded 2026-04-09. `claw --resume /diff` in a non-git directory produced `git diff --cached failed: error: unknown option 'cached'` because git falls back to `--no-index` mode outside a git tree. Also resume `/diff` used `session_path.parent()` (the `.claw/sessions//` dir) as CWD for the diff — never a git repo. **Done at `aef85f8` 2026-04-09**: `render_diff_report_for()` now checks `git rev-parse --is-inside-work-tree` first and returns a clear "no git repository" message; resume `/diff` uses `std::env::current_dir()`. Source: Jobdori dogfood 2026-04-09.
+
+48. **Piped stdin triggers REPL startup and banner instead of one-shot prompt** — dogfooded 2026-04-09. `echo "hello" | claw` started the interactive REPL, printed the ASCII banner, consumed the pipe without sending anything to the API, then exited. `parse_args` always returned `CliAction::Repl` when no args were given, never checking whether stdin was a pipe. **Done at `84b77ec` 2026-04-09**: when `rest.is_empty()` and stdin is not a terminal, read the pipe and dispatch as `CliAction::Prompt`. Empty pipe still falls through to REPL. Source: Jobdori dogfood 2026-04-09.
+
+49. **Resumed slash command errors emitted as prose in `--output-format json` mode** — dogfooded 2026-04-09. `claw --output-format json --resume /commit` called `eprintln!()` and `exit(2)` directly, bypassing the JSON formatter. Both the slash-command parse-error path and the `run_resume_command` Err path now check `output_format` and emit `{"type":"error","error":"...","command":"..."}`. **Done at `da42421` 2026-04-09**. Source: gaebal-gajae ROADMAP #26 track; Jobdori dogfood.
+
+50. **PowerShell tool is registered as `danger-full-access` — workspace-aware reads still require escalation** — dogfooded 2026-04-10. User running `workspace-write` session mode (tanishq_devil in #claw-code) had to use `danger-full-access` even for simple in-workspace reads via PowerShell (e.g. `Get-Content`). Root cause traced by gaebal-gajae: `PowerShell` tool spec is registered with `required_permission: PermissionMode::DangerFullAccess` (same as the `bash` tool in `mvp_tool_specs`), not with per-command workspace-awareness. Bash shell and PowerShell execute arbitrary commands, so blanket promotion to `danger-full-access` is conservative — but it over-escalates read-only in-workspace operations. Fix shape: (a) add command-level heuristic analysis to the PowerShell executor (read-only commands like `Get-Content`, `Get-ChildItem`, `Test-Path` that target paths inside CWD → `WorkspaceWrite` required; everything else → `DangerFullAccess`); (b) mirror the same workspace-path check that the bash executor uses; (c) add tests covering the permission boundary for PowerShell read vs write vs network commands. Note: the `bash` tool in `mvp_tool_specs` is also `DangerFullAccess` and has the same gap — both should be fixed together. Source: tanishq_devil in #claw-code 2026-04-10; root cause identified by gaebal-gajae.
+
+51. **Windows first-run onboarding missing: no explicit Rust + shell prerequisite branch** — dogfooded 2026-04-10 via #claw-code. User hit `bash: cargo: command not found`, `C:\...` vs `/c/...` path confusion in Git Bash, and misread `MINGW64` prompt as a broken MinGW install rather than normal Git Bash. Root cause: README/docs have no Windows-specific install path that says (1) install Rust first via rustup, (2) open Git Bash or WSL (not PowerShell or cmd), (3) use `/c/Users/...` style paths in bash, (4) then `cargo install claw-code`. Users can reach chat mode confusion before realizing claw was never installed. Fix shape: add a **Windows setup** section to README.md (or INSTALL.md) with explicit prerequisite steps, Git Bash vs WSL guidance, and a note that `MINGW64` in the prompt is expected and normal. Source: tanishq_devil in #claw-code 2026-04-10; traced by gaebal-gajae.
+
+52. **`cargo install claw-code` false-positive install: deprecated stub silently succeeds** — dogfooded 2026-04-10 via #claw-code. User runs `cargo install claw-code`, install succeeds, Cargo places `claw-code-deprecated.exe`, user runs `claw` and gets `command not found`. The deprecated binary only prints `"claw-code has been renamed to agent-code"`. The success signal is false-positive: install appears to work but leaves the user with no working `claw` binary. Fix shape: (a) README must warn explicitly against `cargo install claw-code` with the hyphen (current note only warns about `clawcode` without hyphen); (b) if the deprecated crate is in our control, update its binary to print a clearer redirect message including `cargo install agent-code`; (c) ensure the Windows setup doc path mentions `agent-code` explicitly. Source: user in #claw-code 2026-04-10; traced by gaebal-gajae.
+
+53. **`cargo install agent-code` produces `agent.exe`, not `agent-code.exe` — binary name mismatch in docs** — dogfooded 2026-04-10 via #claw-code. User follows the `claw-code` rename hint to run `cargo install agent-code`, install succeeds, but the installed binary is `agent.exe` (Unix: `agent`), not `agent-code` or `agent-code.exe`. User tries `agent-code --version`, gets `command not found`, concludes install is broken. The package name (`agent-code`), the crate name, and the installed binary name (`agent`) are all different. Fix shape: docs must show the full chain explicitly: `cargo install agent-code` → run via `agent` (Unix) / `agent.exe` (Windows). ROADMAP #52 note updated with corrected binary name. Source: user in #claw-code 2026-04-10; traced by gaebal-gajae.
+
+54. **Circular "Did you mean /X?" error for spec-registered commands with no parse arm** — dogfooded 2026-04-10. 23 commands in the spec (shown in `/help` output) had no parse arm in `validate_slash_command_input`, so typing them produced `"Unknown slash command: /X — Did you mean /X?"`. The "Did you mean" suggestion pointed at the exact command the user just typed. Root cause: spec registration and parse-arm implementation were independent — a command could appear in help and completions without being parseable. **Done at `1e14d59` 2026-04-10**: added all 23 to STUB_COMMANDS and added pre-parse intercept in resume dispatch. Source: Jobdori dogfood.
+
+55. **`/session list` unsupported in resume mode despite only needing directory read** — dogfooded 2026-04-10. `/session list` in `--output-format json --resume` mode returned `"unsupported resumed slash command"`. The command only reads the sessions directory — no live runtime needed. **Done at `8dcf103` 2026-04-10**: added `Session{action:"list"}` arm in `run_resume_command()`. Emits `{kind:session_list, sessions:[...ids], active:}`. Partial progress on ROADMAP #21. Source: Jobdori dogfood.
+
+56. **`--resume` with no command ignores `--output-format json`** — dogfooded 2026-04-10. `claw --output-format json --resume ` (no slash command) printed prose `"Restored session from (N messages)."` to stdout, ignoring the JSON output format flag. **Done at `4f670e5` 2026-04-10**: empty-commands path now emits `{kind:restored, session_id, path, message_count}` in JSON mode. Source: Jobdori dogfood.
+
+57. **Session load errors bypass `--output-format json` — prose error on corrupt JSONL** — dogfooded 2026-04-10. `claw --output-format json --resume /status` printed bare prose `"failed to restore session: ..."` to stderr, not a JSON error object. Both the path-resolution and JSONL-load error paths ignored `output_format`. **Done at `cf129c8` 2026-04-10**: both paths now emit `{type:error, error:"failed to restore session: "}` in JSON mode. Source: Jobdori dogfood.
+
+58. **Windows startup crash: `HOME is not set`** — user report 2026-04-10 in #claw-code (MaxDerVerpeilte). On Windows, `HOME` is often unset — `USERPROFILE` is the native equivalent. Four code paths only checked `HOME`: `config_home_dir()` (tools), `credentials_home_dir()` (runtime/oauth), `detect_broad_cwd()` (CLI), and skill lookup roots (tools). All crashed or silently skipped on stock Windows installs. **Done at `b95d330` 2026-04-10**: all four paths now fall back to `USERPROFILE` when `HOME` is absent. Error message updated to suggest `USERPROFILE` or `CLAW_CONFIG_HOME`. Source: MaxDerVerpeilte in #claw-code.
+
+59. **Session metadata does not persist the model used** — dogfooded 2026-04-10. When resuming a session, `/status` reports `model: null` because the session JSONL stores no model field. A claw resuming a session cannot tell what model was originally used. The model is only known at runtime construction time via CLI flag or config. Fix shape: persist `model` in the session metadata record on first API call, and surface it in resumed `/status`. Source: Jobdori dogfood.
diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs
index 9ac9c8fd..1d46ee68 100644
--- a/rust/crates/api/src/providers/openai_compat.rs
+++ b/rust/crates/api/src/providers/openai_compat.rs
@@ -157,6 +157,35 @@ impl OpenAiCompatClient {
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.map_err(ApiError::from)?;
+ // Some backends return {"error":{"message":"...","type":"...","code":...}}
+ // instead of a valid completion object. Check for this before attempting
+ // full deserialization so the user sees the actual error, not a cryptic
+ // "missing field 'id'" parse failure.
+ if let Ok(raw) = serde_json::from_str::(&body) {
+ if let Some(err_obj) = raw.get("error") {
+ let msg = err_obj
+ .get("message")
+ .and_then(|m| m.as_str())
+ .unwrap_or("provider returned an error")
+ .to_string();
+ let code = err_obj
+ .get("code")
+ .and_then(|c| c.as_u64())
+ .map(|c| c as u16);
+ return Err(ApiError::Api {
+ status: reqwest::StatusCode::from_u16(code.unwrap_or(400))
+ .unwrap_or(reqwest::StatusCode::BAD_REQUEST),
+ error_type: err_obj
+ .get("type")
+ .and_then(|t| t.as_str())
+ .map(str::to_owned),
+ message: Some(msg),
+ request_id,
+ body,
+ retryable: false,
+ });
+ }
+ }
let payload = serde_json::from_str::(&body).map_err(|error| {
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
})?;
@@ -255,6 +284,19 @@ impl OpenAiCompatClient {
static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Returns a random additive jitter in `[0, base]` to decorrelate retries
+/// Deserialize a JSON field as a `Vec`, treating an explicit `null` value
+/// the same as a missing field (i.e. as an empty vector).
+/// Some OpenAI-compatible providers emit `"tool_calls": null` instead of
+/// omitting the field or using `[]`, which serde's `#[serde(default)]` alone
+/// does not tolerate — `default` only handles absent keys, not null values.
+fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result, D::Error>
+where
+ D: serde::Deserializer<'de>,
+ T: serde::Deserialize<'de>,
+{
+ Ok(Option::>::deserialize(deserializer)?.unwrap_or_default())
+}
+
/// from multiple concurrent clients. Entropy is drawn from the nanosecond
/// wall clock mixed with a monotonic counter and run through a splitmix64
/// finalizer; adequate for retry jitter (no cryptographic requirement).
@@ -673,7 +715,7 @@ struct ChunkChoice {
struct ChunkDelta {
#[serde(default)]
content: Option,
- #[serde(default)]
+ #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec,
}
@@ -726,6 +768,24 @@ fn is_reasoning_model(model: &str) -> bool {
|| canonical.contains("thinking")
}
+/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
+/// The prefix is used only to select transport; the backend expects the
+/// bare model id.
+fn strip_routing_prefix(model: &str) -> &str {
+ if let Some(pos) = model.find('/') {
+ let prefix = &model[..pos];
+ // Only strip if the prefix before "/" is a known routing prefix,
+ // not if "/" appears in the middle of the model name for other reasons.
+ if matches!(prefix, "openai" | "xai" | "grok" | "qwen") {
+ &model[pos + 1..]
+ } else {
+ model
+ }
+ } else {
+ model
+ }
+}
+
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
let mut messages = Vec::new();
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
@@ -737,10 +797,30 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
for message in &request.messages {
messages.extend(translate_message(message));
}
+ // Sanitize: drop any `role:"tool"` message that does not have a valid
+ // paired `role:"assistant"` with a `tool_calls` entry carrying the same
+ // `id` immediately before it (directly or as part of a run of tool
+ // results). OpenAI-compatible backends return 400 for orphaned tool
+ // messages regardless of how they were produced (compaction, session
+ // editing, resume, etc.). We drop rather than error so the request can
+ // still proceed with the remaining history intact.
+ messages = sanitize_tool_message_pairing(messages);
+
+ // Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
+ let wire_model = strip_routing_prefix(&request.model);
+
+ // gpt-5* requires `max_completion_tokens`; older OpenAI models accept both.
+ // We send the correct field based on the wire model name so gpt-5.x requests
+ // don't fail with "unknown field max_tokens".
+ let max_tokens_key = if wire_model.starts_with("gpt-5") {
+ "max_completion_tokens"
+ } else {
+ "max_tokens"
+ };
let mut payload = json!({
- "model": request.model,
- "max_tokens": request.max_tokens,
+ "model": wire_model,
+ max_tokens_key: request.max_tokens,
"messages": messages,
"stream": request.stream,
});
@@ -780,6 +860,10 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
payload["stop"] = json!(stop);
}
}
+ // reasoning_effort for OpenAI-compatible reasoning models (o4-mini, o3, etc.)
+ if let Some(effort) = &request.reasoning_effort {
+ payload["reasoning_effort"] = json!(effort);
+ }
payload
}
@@ -806,11 +890,16 @@ fn translate_message(message: &InputMessage) -> Vec {
if text.is_empty() && tool_calls.is_empty() {
Vec::new()
} else {
- vec![json!({
+ let mut msg = serde_json::json!({
"role": "assistant",
"content": (!text.is_empty()).then_some(text),
- "tool_calls": tool_calls,
- })]
+ });
+ // Only include tool_calls when non-empty: some providers reject
+ // assistant messages with an explicit empty tool_calls array.
+ if !tool_calls.is_empty() {
+ msg["tool_calls"] = json!(tool_calls);
+ }
+ vec![msg]
}
}
_ => message
@@ -837,6 +926,75 @@ fn translate_message(message: &InputMessage) -> Vec {
}
}
+/// Remove `role:"tool"` messages from `messages` that have no valid paired
+/// `role:"assistant"` message with a matching `tool_calls[].id` immediately
+/// preceding them. This is a last-resort safety net at the request-building
+/// layer — the compaction boundary fix (6e301c8) prevents the most common
+/// producer path, but resume, session editing, or future compaction variants
+/// could still create orphaned tool messages.
+///
+/// Algorithm: scan left-to-right. For each `role:"tool"` message, check the
+/// immediately preceding non-tool message. If it's `role:"assistant"` with a
+/// `tool_calls` array containing an entry whose `id` matches the tool
+/// message's `tool_call_id`, the pair is valid and both are kept. Otherwise
+/// the tool message is dropped.
+fn sanitize_tool_message_pairing(messages: Vec) -> Vec {
+ // Collect indices of tool messages that are orphaned.
+ let mut drop_indices = std::collections::HashSet::new();
+ for (i, msg) in messages.iter().enumerate() {
+ if msg.get("role").and_then(|v| v.as_str()) != Some("tool") {
+ continue;
+ }
+ let tool_call_id = msg
+ .get("tool_call_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ // Find the nearest preceding non-tool message.
+ let preceding = messages[..i]
+ .iter()
+ .rev()
+ .find(|m| m.get("role").and_then(|v| v.as_str()) != Some("tool"));
+ // A tool message is considered paired when:
+ // (a) the nearest preceding non-tool message is an assistant message
+ // whose `tool_calls` array contains an entry with the matching id, OR
+ // (b) there's no clear preceding context (e.g. the message comes right
+ // after a user turn — this can happen with translated mixed-content
+ // user messages). In case (b) we allow the message through rather
+ // than silently dropping potentially valid history.
+ let preceding_role = preceding
+ .and_then(|m| m.get("role"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ // Only apply sanitization when the preceding message is an assistant
+ // turn (the invariant is: assistant-with-tool_calls must precede tool).
+ // If the preceding is something else (user, system) don't drop — it
+ // may be a valid translation artifact or a path we don't understand.
+ if preceding_role != "assistant" {
+ continue;
+ }
+ let paired = preceding
+ .and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array()))
+ .map(|tool_calls| {
+ tool_calls
+ .iter()
+ .any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id))
+ })
+ .unwrap_or(false);
+ if !paired {
+ drop_indices.insert(i);
+ }
+ }
+ if drop_indices.is_empty() {
+ return messages;
+ }
+ messages
+ .into_iter()
+ .enumerate()
+ .filter(|(i, _)| !drop_indices.contains(i))
+ .map(|(_, m)| m)
+ .collect()
+}
+
fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
content
.iter()
@@ -848,13 +1006,45 @@ fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
.join("\n")
}
+/// Recursively ensure every object-type node in a JSON Schema has
+/// `"properties"` (at least `{}`) and `"additionalProperties": false`.
+/// The OpenAI `/responses` endpoint validates schemas strictly and rejects
+/// objects that omit these fields; `/chat/completions` is lenient but also
+/// accepts them, so we normalise unconditionally.
+fn normalize_object_schema(schema: &mut Value) {
+ if let Some(obj) = schema.as_object_mut() {
+ if obj.get("type").and_then(Value::as_str) == Some("object") {
+ obj.entry("properties").or_insert_with(|| json!({}));
+ obj.entry("additionalProperties")
+ .or_insert(Value::Bool(false));
+ }
+ // Recurse into properties values
+ if let Some(props) = obj.get_mut("properties") {
+ if let Some(props_obj) = props.as_object_mut() {
+ let keys: Vec = props_obj.keys().cloned().collect();
+ for k in keys {
+ if let Some(v) = props_obj.get_mut(&k) {
+ normalize_object_schema(v);
+ }
+ }
+ }
+ }
+ // Recurse into items (arrays)
+ if let Some(items) = obj.get_mut("items") {
+ normalize_object_schema(items);
+ }
+ }
+}
+
fn openai_tool_definition(tool: &ToolDefinition) -> Value {
+ let mut parameters = tool.input_schema.clone();
+ normalize_object_schema(&mut parameters);
json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
- "parameters": tool.input_schema,
+ "parameters": parameters,
}
})
}
@@ -971,6 +1161,35 @@ fn parse_sse_frame(
if payload == "[DONE]" {
return Ok(None);
}
+ // Some backends embed an error object in a data: frame instead of using an
+ // HTTP error status. Surface the error message directly rather than letting
+ // ChatCompletionChunk deserialization fail with a cryptic 'missing field' error.
+ if let Ok(raw) = serde_json::from_str::(&payload) {
+ if let Some(err_obj) = raw.get("error") {
+ let msg = err_obj
+ .get("message")
+ .and_then(|m| m.as_str())
+ .unwrap_or("provider returned an error in stream")
+ .to_string();
+ let code = err_obj
+ .get("code")
+ .and_then(|c| c.as_u64())
+ .map(|c| c as u16);
+ let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
+ .unwrap_or(reqwest::StatusCode::BAD_REQUEST);
+ return Err(ApiError::Api {
+ status,
+ error_type: err_obj
+ .get("type")
+ .and_then(|t| t.as_str())
+ .map(str::to_owned),
+ message: Some(msg),
+ request_id: None,
+ body: payload.to_string(),
+ retryable: false,
+ });
+ }
+ }
serde_json::from_str::(&payload)
.map(Some)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
@@ -1122,6 +1341,76 @@ mod tests {
assert_eq!(payload["tool_choice"], json!("auto"));
}
+ #[test]
+ fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
+ // OpenAI /responses endpoint rejects object schemas missing
+ // "properties" and "additionalProperties". Verify normalize_object_schema
+ // fills them in so the request shape is strict-validator-safe.
+ use super::normalize_object_schema;
+
+ // Bare object — no properties at all
+ let mut schema = json!({"type": "object"});
+ normalize_object_schema(&mut schema);
+ assert_eq!(schema["properties"], json!({}));
+ assert_eq!(schema["additionalProperties"], json!(false));
+
+ // Nested object inside properties
+ let mut schema2 = json!({
+ "type": "object",
+ "properties": {
+ "location": {"type": "object", "properties": {"lat": {"type": "number"}}}
+ }
+ });
+ normalize_object_schema(&mut schema2);
+ assert_eq!(schema2["additionalProperties"], json!(false));
+ assert_eq!(
+ schema2["properties"]["location"]["additionalProperties"],
+ json!(false)
+ );
+
+ // Existing properties/additionalProperties should not be overwritten
+ let mut schema3 = json!({
+ "type": "object",
+ "properties": {"x": {"type": "string"}},
+ "additionalProperties": true
+ });
+ normalize_object_schema(&mut schema3);
+ assert_eq!(
+ schema3["additionalProperties"],
+ json!(true),
+ "must not overwrite existing"
+ );
+ }
+
+ #[test]
+ fn reasoning_effort_is_included_when_set() {
+ let payload = build_chat_completion_request(
+ &MessageRequest {
+ model: "o4-mini".to_string(),
+ max_tokens: 1024,
+ messages: vec![InputMessage::user_text("think hard")],
+ reasoning_effort: Some("high".to_string()),
+ ..Default::default()
+ },
+ OpenAiCompatConfig::openai(),
+ );
+ assert_eq!(payload["reasoning_effort"], json!("high"));
+ }
+
+ #[test]
+ fn reasoning_effort_omitted_when_not_set() {
+ let payload = build_chat_completion_request(
+ &MessageRequest {
+ model: "gpt-4o".to_string(),
+ max_tokens: 64,
+ messages: vec![InputMessage::user_text("hello")],
+ ..Default::default()
+ },
+ OpenAiCompatConfig::openai(),
+ );
+ assert!(payload.get("reasoning_effort").is_none());
+ }
+
#[test]
fn openai_streaming_requests_include_usage_opt_in() {
let payload = build_chat_completion_request(
@@ -1239,6 +1528,7 @@ mod tests {
frequency_penalty: Some(0.5),
presence_penalty: Some(0.3),
stop: Some(vec!["\n".to_string()]),
+ reasoning_effort: None,
};
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
assert_eq!(payload["temperature"], 0.7);
@@ -1323,4 +1613,186 @@ mod tests {
assert!(payload.get("presence_penalty").is_none());
assert!(payload.get("stop").is_none());
}
+
+ #[test]
+ fn gpt5_uses_max_completion_tokens_not_max_tokens() {
+ // gpt-5* models require `max_completion_tokens`; legacy `max_tokens` causes
+ // a request-validation failure. Verify the correct key is emitted.
+ let request = MessageRequest {
+ model: "gpt-5.2".to_string(),
+ max_tokens: 512,
+ messages: vec![],
+ stream: false,
+ ..Default::default()
+ };
+ let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
+ assert_eq!(
+ payload["max_completion_tokens"],
+ json!(512),
+ "gpt-5.2 should emit max_completion_tokens"
+ );
+ assert!(
+ payload.get("max_tokens").is_none(),
+ "gpt-5.2 must not emit max_tokens"
+ );
+ }
+
+ /// Regression test: some OpenAI-compatible providers emit `"tool_calls": null`
+ /// in stream delta chunks instead of omitting the field or using `[]`.
+ /// Before the fix this produced: `invalid type: null, expected a sequence`.
+ #[test]
+ fn delta_with_null_tool_calls_deserializes_as_empty_vec() {
+ // Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09)
+ let json = r#"{
+ "content": "",
+ "function_call": null,
+ "refusal": null,
+ "role": "assistant",
+ "tool_calls": null
+ }"#;
+
+ use super::deserialize_null_as_empty_vec;
+ #[allow(dead_code)]
+ #[derive(serde::Deserialize, Debug)]
+ struct Delta {
+ content: Option,
+ #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
+ tool_calls: Vec,
+ }
+ let delta: Delta = serde_json::from_str(json)
+ .expect("delta with tool_calls:null must deserialize without error");
+ assert!(
+ delta.tool_calls.is_empty(),
+ "tool_calls:null must produce an empty vec, not an error"
+ );
+ }
+
+ /// Regression: when building a multi-turn request where a prior assistant
+ /// turn has no tool calls, the serialized assistant message must NOT include
+ /// `tool_calls: []`. Some providers reject requests that carry an empty
+ /// tool_calls array on assistant turns (gaebal-gajae repro 2026-04-09).
+ #[test]
+ fn assistant_message_without_tool_calls_omits_tool_calls_field() {
+ use crate::types::{InputContentBlock, InputMessage};
+
+ let request = MessageRequest {
+ model: "gpt-4o".to_string(),
+ max_tokens: 100,
+ messages: vec![InputMessage {
+ role: "assistant".to_string(),
+ content: vec![InputContentBlock::Text {
+ text: "Hello".to_string(),
+ }],
+ }],
+ stream: false,
+ ..Default::default()
+ };
+ let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
+ let messages = payload["messages"].as_array().unwrap();
+ let assistant_msg = messages
+ .iter()
+ .find(|m| m["role"] == "assistant")
+ .expect("assistant message must be present");
+ assert!(
+ assistant_msg.get("tool_calls").is_none(),
+ "assistant message without tool calls must omit tool_calls field: {:?}",
+ assistant_msg
+ );
+ }
+
+ /// Regression: assistant messages WITH tool calls must still include
+ /// the tool_calls array (normal multi-turn tool-use flow).
+ #[test]
+ fn assistant_message_with_tool_calls_includes_tool_calls_field() {
+ use crate::types::{InputContentBlock, InputMessage};
+
+ let request = MessageRequest {
+ model: "gpt-4o".to_string(),
+ max_tokens: 100,
+ messages: vec![InputMessage {
+ role: "assistant".to_string(),
+ content: vec![InputContentBlock::ToolUse {
+ id: "call_1".to_string(),
+ name: "read_file".to_string(),
+ input: serde_json::json!({"path": "/tmp/test"}),
+ }],
+ }],
+ stream: false,
+ ..Default::default()
+ };
+ let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
+ let messages = payload["messages"].as_array().unwrap();
+ let assistant_msg = messages
+ .iter()
+ .find(|m| m["role"] == "assistant")
+ .expect("assistant message must be present");
+ let tool_calls = assistant_msg
+ .get("tool_calls")
+ .expect("assistant message with tool calls must include tool_calls field");
+ assert!(tool_calls.is_array());
+ assert_eq!(tool_calls.as_array().unwrap().len(), 1);
+ }
+
+ /// Orphaned tool messages (no preceding assistant tool_calls) must be
+ /// dropped by the request-builder sanitizer. Regression for the second
+ /// layer of the tool-pairing invariant fix (gaebal-gajae 2026-04-10).
+ #[test]
+ fn sanitize_drops_orphaned_tool_messages() {
+ use super::sanitize_tool_message_pairing;
+
+ // Valid pair: assistant with tool_calls → tool result
+ let valid = vec![
+ json!({"role": "assistant", "content": null, "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "search", "arguments": "{}"}}]}),
+ json!({"role": "tool", "tool_call_id": "call_1", "content": "result"}),
+ ];
+ let out = sanitize_tool_message_pairing(valid);
+ assert_eq!(out.len(), 2, "valid pair must be preserved");
+
+ // Orphaned tool message: no preceding assistant tool_calls
+ let orphaned = vec![
+ json!({"role": "assistant", "content": "hi"}),
+ json!({"role": "tool", "tool_call_id": "call_2", "content": "orphaned"}),
+ ];
+ let out = sanitize_tool_message_pairing(orphaned);
+ assert_eq!(out.len(), 1, "orphaned tool message must be dropped");
+ assert_eq!(out[0]["role"], json!("assistant"));
+
+ // Mismatched tool_call_id
+ let mismatched = vec![
+ json!({"role": "assistant", "content": null, "tool_calls": [{"id": "call_3", "type": "function", "function": {"name": "f", "arguments": "{}"}}]}),
+ json!({"role": "tool", "tool_call_id": "call_WRONG", "content": "bad"}),
+ ];
+ let out = sanitize_tool_message_pairing(mismatched);
+ assert_eq!(out.len(), 1, "tool message with wrong id must be dropped");
+
+ // Two tool results both valid (same preceding assistant)
+ let two_results = vec![
+ json!({"role": "assistant", "content": null, "tool_calls": [
+ {"id": "call_a", "type": "function", "function": {"name": "fa", "arguments": "{}"}},
+ {"id": "call_b", "type": "function", "function": {"name": "fb", "arguments": "{}"}}
+ ]}),
+ json!({"role": "tool", "tool_call_id": "call_a", "content": "ra"}),
+ json!({"role": "tool", "tool_call_id": "call_b", "content": "rb"}),
+ ];
+ let out = sanitize_tool_message_pairing(two_results);
+ assert_eq!(out.len(), 3, "both valid tool results must be preserved");
+ }
+
+ #[test]
+ fn non_gpt5_uses_max_tokens() {
+ // Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.
+ let request = MessageRequest {
+ model: "gpt-4o".to_string(),
+ max_tokens: 512,
+ messages: vec![],
+ stream: false,
+ ..Default::default()
+ };
+ let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
+ assert_eq!(payload["max_tokens"], json!(512));
+ assert!(
+ payload.get("max_completion_tokens").is_none(),
+ "gpt-4o must not emit max_completion_tokens"
+ );
+ }
}
diff --git a/rust/crates/api/src/types.rs b/rust/crates/api/src/types.rs
index 830b3de9..e136a766 100644
--- a/rust/crates/api/src/types.rs
+++ b/rust/crates/api/src/types.rs
@@ -26,6 +26,11 @@ pub struct MessageRequest {
pub presence_penalty: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option>,
+ /// Reasoning effort level for OpenAI-compatible reasoning models (e.g. `o4-mini`).
+ /// Accepted values: `"low"`, `"medium"`, `"high"`. Omitted when `None`.
+ /// Silently ignored by backends that do not support it.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reasoning_effort: Option,
}
impl MessageRequest {
diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs
index 556ff968..4904f9f6 100644
--- a/rust/crates/commands/src/lib.rs
+++ b/rust/crates/commands/src/lib.rs
@@ -1221,6 +1221,84 @@ impl SlashCommand {
pub fn parse(input: &str) -> Result