20 Commits
Author SHA1 Message Date
ZacharyZhang-NY d3c9380307 release: v0.1.7
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
Since v0.1.6 — the cross-provider replay audit (Pi transform-messages
policy: every wire builder emits only items valid for its target):
- fix(responses): reasoning items without a native rs_* id dropped (the
  GPT/codex 400 Invalid 'input[N].id'); provenance gate drops foreign
  reasoning and demotes foreign backend tool calls on model switches
- fix(codex): bare rs_* reasoning references dropped (stateless backend)
- fix(wire): shared ASCII tool-call id sanitizer, symmetric call+result
  on both the Messages and Responses legs
- fix(messages): image-source whitelist (raster base64 only, http(s)
  urls only) and empty-user-content guard
- fix(conversation): char-boundary-safe code preview (CJK/emoji code
  panicked every request build)
- docs: AGENTS.md records the replay policy
2026-07-23 01:16:55 -04:00
ZacharyZhang-NY c950f8087c fix(messages): whitelist image sources and guard empty user content
Cross-provider audit M4/M5/M6 (Anthropic Messages builder):
- Non-base64 data: URIs rode as ImageSource::Url — url sources are
  http(s) only → 400; and the two image paths parsed data URIs
  differently (user path split on first comma, tool-result path on
  ';base64,').
- No media-type whitelist: image/svg+xml and param-carrying headers
  ('image/webp;name=x') reached the wire → 400.
- Empty user content shipped empty arrays/text blocks → 400.

One shared parse_base64_image_data_uri (raster whitelist: jpeg/png/gif/
webp) now serves both paths; rejected images degrade to a SHORT
'[unsupported image]' placeholder (never the multi-megabyte payload);
empty user turns get '[empty message]' (mirrors the assistant guard).

Part 6 of the cross-provider replay audit.

Verified: sampling-types 292 + downstream green, clippy clean.
2026-07-23 01:16:03 -04:00
ZacharyZhang-NY 40c71a8343 fix(responses): provenance-gate foreign turns (Pi transform-messages)
Cross-provider audit R5 + R2-residual: BackendToolCall items round-trip
as their typed shapes with provider-issued ids (grok x_search
CustomToolCall, web_search/code_interpreter calls) and Reasoning items
carry model-bound encrypted payloads — replaying either to a DIFFERENT
Responses target names undeclared tools / undecryptable material → 400.

New transform_items_for_responses pre-pass: each [Reasoning|
BackendToolCall]* Assistant run carries provenance in
AssistantItem.model_id; on confirmed mismatch with the request's target
model, Reasoning siblings are dropped and BackendToolCall demoted to the
same text summary the Messages/ChatCompletions builders already emit.
Same-model and unknown-provenance turns stay byte-verbatim (KV-cache
prefix stability preserved). The legacy-upgrade round-trip test now
models the same-model continuation it always described.

Part 5 of the cross-provider replay audit.

Verified: sampling-types 290 + downstream 5794 green, clippy clean.
2026-07-23 01:10:51 -04:00
ZacharyZhang-NY a3e3973453 docs(tests): restore doc comments displaced by the summary-test insertion
The new truncation test's doc landed between backend_tool_call_position_
stable's doc and its #[test] attribute (clippy: duplicated attribute).
Each test owns its own doc block again.
2026-07-23 01:02:19 -04:00
ZacharyZhang-NY 9cdc0ccfa3 fix(wire): shared ASCII tool-call id sanitizer, symmetric on both legs
Cross-provider audit R4+M3: the Responses leg passed tool-call ids
verbatim (call_id on both function_call and function_call_output) while
the Messages leg sanitized — and its closure used Unicode
is_alphanumeric, letting CJK ids through to Anthropic's ASCII-only
contract, with no empty-id fallback. Both providers enforce
[A-Za-z0-9_-]+ (the codex 400's own words). One module-scope
sanitize_tool_call_id now serves both builders, ASCII-only, empty → "_",
applied identically on call+result so pairing survives.

Part 4 of the cross-provider replay audit.

Verified: 6081 tests green across the four crates, clippy clean.
2026-07-23 01:00:56 -04:00
ZacharyZhang-NY 6f9f550308 fix(codex): drop bare rs_* reasoning references — stateless backend
Cross-provider audit R3: reasoning captured on stateful api.openai.com
sessions (no include requested) carries a server-issued rs_* id but NO
encrypted_content; replaying that bare reference to the stateless
(store:false) codex backend points at server state chatgpt.com does not
have. adapt_body_for_codex_backend step 3 drops such items (encrypted
ones pass through verbatim). Capture-side include for the API-key path
is deferred: the typed CreateResponse is shared with the xai Responses
leg and changing its bytes needs separate validation.

Part 3 of the cross-provider replay audit.

Verified: sampling-types+sampler+chat-state+shell all green.
2026-07-23 00:55:23 -04:00
ZacharyZhang-NY 2b43f54669 test(conversation): fix code-preview fixture to actually exceed the char cap
The multibyte-truncation test used 80 chars — below the 100-char cap, so
the truncation assertion failed (the previous commit's suite count was
misread; the FIX itself was correct and the panic repro held). 120 chars
now exercises both the boundary safety and the truncation.
2026-07-23 00:51:04 -04:00
ZacharyZhang-NY 6979407f22 fix(conversation): char-boundary-safe code preview in text_summary
The code-interpreter preview truncated at BYTE 100 — a guaranteed panic
on any CJK/emoji boundary in interpreted code. One poisoned history item
then crashed every subsequent request build on every backend (the
summary feeds both the Messages and ChatCompletions builders). Truncate
at 100 chars via char_indices instead. Panic pinned by test.

Part 2 of the cross-provider replay audit.

Verified: sampling-types 287 tests green.
2026-07-23 00:50:29 -04:00
ZacharyZhang-NY 1fa87566d9 fix(responses): drop reasoning items without a native rs_* id
Root cause of 400 Invalid 'input[N].id': '' on chatgpt.com/backend-api/
codex/responses: the Responses input builder replayed every stored
Reasoning item verbatim, and rs::ReasoningItem.id serializes
unconditionally — so foreign items (Messages-captured Anthropic
signatures, chat-completions-synthesized reasoning, stream-delta
fallbacks, legacy upgrades — all id '') reached the wire with an empty
id the API rejects. This also self-poisoned pure codex sessions whose
reasoning arrived only as deltas.

A native item always carries a server-issued rs_* id: empty id = foreign
= unusable by any Responses provider = dropped at the builder — the
exact mirror of the Messages builder's prune_replayed_thinking. The
encrypted-only fixture that pinned the poison shape now uses a native id
(the pass-through case it always meant to cover).

Part 1 of the cross-provider replay audit (Pi transform-messages
policy: builders emit only items valid for their target).

Verified: sampling-types 286 + sampler/chat-state/shell 5791 green.
2026-07-23 00:49:18 -04:00
ZacharyZhang-NY d6e49bcc7d release: v0.1.6
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 8s
Since v0.1.5:
- fix(messages): Claude models no longer 400 with 'Invalid signature in
  thinking block' — thinking is replayed only for the active tool loop
  and only when genuinely signed (cross-backend/cross-model histories
  are stripped)
- fix(codex): ChatGPT Codex no longer 400s with 'System messages are
  not allowed' — system prompts ride the instructions field and
  encrypted reasoning is requested for stateless replay
- docs: AGENTS.md records both wire contracts
2026-07-22 23:36:22 -04:00
ZacharyZhang-NY d2358b037c fix(codex): adapt the Responses body to the ChatGPT/Codex backend contract
Root cause of 400 {"detail":"System messages are not allowed"} at
chatgpt.com/backend-api/codex/responses (both platforms): the codex
adaptation covered only IDENTITY HEADERS (originator/OpenAI-Beta/UA/
chatgpt-account-id) — the BODY still carried the system prompt as
role:system input items, which the codex backend rejects outright. Its
system channel is the top-level  field, and stateless
(store:false) reasoning replay requires
include:["reasoning.encrypted_content"] — both per the same reference
the headers were ported from (official Codex CLI + Pi's
api/openai-codex-responses.ts).

New adapt_body_for_codex_backend (kigi-sampling-types): hoists every
system input item into  (order preserved, appended to any
existing instructions; string and parts content shapes) and requests
encrypted reasoning. Idempotent. Applied at both Responses send paths,
openai_codex-GATED — the API-key  path stays byte-identical
(pinned by a control wire test).

Tests: adapter unit tests (hoist+include, no-system no-op, idempotence)
plus two mock-server wire tests (codex body has no system role,
instructions + include present; plain Responses body unchanged).

Verified: sampling-types + sampler + chat-state + shell 6076 tests
green, clippy clean.
2026-07-22 23:35:31 -04:00
ZacharyZhang-NY 6ba24db019 fix(messages): replay thinking blocks only for the active tool loop
Root cause of 'Invalid signature in thinking block' (400 at
messages.1.content.0, Claude models): build_messages_request replayed
EVERY stored Reasoning item as a thinking block with no origin check —
history synthesized by other backends (encrypted_content: None → the
mandatory signature field serialized as ""), Responses-API tco_* blobs
(signature bytes, no text), and blocks signed by a DIFFERENT model after
a mid-session /model switch. Anthropic validates every replayed
signature (model-bound), so such histories 400 deterministically. The
platform split was circumstantial: Windows sessions started on the
default model and switched to Claude; macOS sessions were Claude-native
from turn 1. Adversarially verified — no platform-divergent byte path
exists in capture, storage, or replay.

New prune_replayed_thinking pass (Pi/Claude Code replay policy): keep
exactly the final assistant message's thinking when its tool loop is
still open (request ends on the tool results — an open loop can never
span a model switch) and the block is genuinely signed; strip every
other thinking block (the API ignores valid prior-turn thinking and
rejects invalid). Assistant messages emptied by the strip (thinking-only
aborted turns) are removed — empty content arrays are rejected too.

Tests: three unit tests pin strip-outside-loop (unsigned, tco_*, stale
signed), keep-in-active-loop (verbatim text+signature at content.0), and
emptied-message removal; the legacy-upgrade integration test now proves
both wire fidelity in the active loop AND stripping once the loop
closes.

Verified: sampling-types + sampler + chat-state + shell 6072 tests
green, clippy clean.
2026-07-22 23:26:49 -04:00
ZacharyZhang-NY 10149f50dd install: stop persisting KIGI_GRAPH — the binary default is the product default
The README always shipped graph engineering enabled; the enablement was
delegated to installer env plumbing that diverged per platform:
install.sh exported KIGI_GRAPH=1 into shell rc (worked), install.ps1
wrote the User registry variable — which running Windows terminals (and
new tabs of an open Windows Terminal) never pick up, so /graph was
'missing on Windows' despite a successful install.

With resolve_graph() defaulting true in the binary (e53a66d), the env
writes are redundant complexity: drop them from both installers, keep
KIGI_GRAPH=0 as the documented opt-out (env still beats the default),
and correct the flag comment to tell this story instead of a
'gray release' one. Both scripts syntax-checked (sh -n / pwsh parser).

Installers are served from main (raw.githubusercontent), so this takes
effect for all new installs immediately — no retag needed; the running
v0.1.5 build already carries the binary-default fix.
2026-07-22 21:22:16 -04:00
ZacharyZhang-NY e53a66d113 feat(graph): /graph ships on by default — end the KIGI_GRAPH gray release
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
resolve_graph() read only the KIGI_GRAPH env var with default(false)
(plan.md G0 gate), so /graph existed solely on machines whose environment
exported the dev flag — which presented as '/graph is missing on
Windows'. There was never any platform-conditional code: the Mac worked
because the dev env var was set there.

Default is now true (matching /goal's shipped state); KIGI_GRAPH=0
remains the off-switch, and availability still requires the goal harness
(BuiltinGate::Graph). AGENTS.md updated; new test pins fresh-install-on
plus env-zero-off.

Verified: kigi-shell 5262 + kigi-tui 6874 tests green, clippy clean.
2026-07-22 21:16:49 -04:00
ZacharyZhang-NY f6eaafa3da build(kigi-shell): retry transient ripgrep download failures with backoff
A single HTTP 502 from the GitHub release CDN killed the entire v0.1.5
tag build (one job of five, ~50 minutes wasted). The build-script
download now retries twice with backoff on 5xx/429/network errors;
genuine failures (404, offline) still fail fast with the
KIGI_SHELL_BUNDLE_RG_PATH hint, and every retry prints a cargo:warning
so flakiness stays observable.
2026-07-22 20:48:13 -04:00
ZacharyZhang-NY 13ccab980c ci: warm a main-branch build cache so release tag builds aren't cold
GitHub Actions cache isolation lets a run restore caches only from its own
ref or the default branch. Releases run on tag refs and nothing ever ran
on main, so release.yml's rust-cache never restored anything — every
release compiled all 62 crates cold on five targets (~50 min, gated by
Windows at ~49.5 min).

warm-cache.yml builds the same release-dist profile with the same
rust-cache key on main (dep-affecting pushes — including each release's
version-bump commit — plus a weekly refresh against 7-day eviction and
manual dispatch). Tag builds then restore a warm default-branch cache;
the remaining cost is workspace-crate compilation + the codegen-units=1
thin-LTO link, which is the deliberate release-hardening tradeoff.
2026-07-22 19:59:00 -04:00
ZacharyZhang-NY b9b7e6c989 release: v0.1.5
Since v0.1.4:
- fix(fs): Windows-safe atomic replace everywhere (util::fs::replace_file)
  — model+effort switches now persist on Windows; models cache and session
  state writes no longer fail silently under AV/indexer file locks
- fix(tui): a default-model persist failure keeps the live session's model
  instead of reverting the pick
- docs: AGENTS.md records the replace_file contract
2026-07-22 19:55:30 -04:00
ZacharyZhang-NY f403c38a94 fix(tui): a default-model persist failure no longer reverts the live model
Persist-failure ≠ switch-failure. The default_model rollback arm re-ran
set_default_model_inner(prev) AND issued a reverse SwitchModel whenever
the config.toml write failed — the only mechanism in the codebase that
deliberately re-shows the ORIGINAL model after a successful pick. On
Windows, where AV/indexer file locks routinely failed that write (until
27d009c), every /model selection appeared to not take.

The session switch succeeds independently and reports its own failures
via handle_switch_model_complete; a disk-persist failure now keeps the
live model, logs, and surfaces only the 'Could not save' toast — the
same policy PersistPreferredModel already ships ('still active for this
session').

Verified: kigi-tui 6874 tests green, clippy clean.
2026-07-22 19:55:02 -04:00
ZacharyZhang-NY 27d009cb6e fix(fs): Windows-safe atomic replace everywhere — model switch now sticks
Root cause of 'model+effort switch works on Mac, not on Windows': the
switch APPLIES in-session (the dispatch/apply chain is platform-identical,
verified adversarially) but its persistence never sticks on Windows.
Every tmp+rename atomic write except auth/storage.rs committed with a
bare fs::rename, and Windows MoveFileExW(REPLACE_EXISTING) fails with a
sharing violation whenever AV/search-indexer/cloud-sync transiently holds
the destination open. Consequences: [models].default never persisted
(next launch = original model), the session summary's current model never
persisted (resume = original model), and the models cache went silently
stale (all its write errors were swallowed).

- New kigi_shell_base::util::fs::replace_file — THE commit step for
  tmp+rename: plain rename on Unix; on Windows delete-first + two short
  backoffs (the pattern auth/storage.rs shipped first), tmp cleaned on
  failure, error always returned. Windows branch type-checked against
  x86_64-pc-windows-msvc.
- Adopted at every replace site: config.toml (save_config /
  atomic_write_string / mcp saves), models cache (plus unique tmp
  suffixes and tracing::warn on failure — writes were fully silent),
  session storage (summary/current-model, jsonl, plan/signals/
  announcement/goal/graph state), auth.json, active-sessions registry,
  prompt history, claude/kimi import, campaigns state, goal artifacts.
  Directory-move renames (worktree pool, corrupt-file backups) keep
  plain rename — their destinations don't pre-exist.

Verified: kigi-shell + kigi-shell-base 5318 tests green, clippy clean,
msvc-target check of the new cfg(windows) code clean.
2026-07-22 19:42:13 -04:00
ZacharyZhang-NY 815bd99356 docs(agents): stop embedding the workspace version literal
'(0.1.0)' had rotted three releases behind. Name the source of truth
(workspace.package.version) instead of snapshotting its value.
2026-07-22 17:54:51 -04:00
32 changed files with 1618 additions and 312 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Warm build cache
# Release builds run on TAG refs, and GitHub Actions cache isolation only
# lets a run restore caches created on its OWN ref or the DEFAULT branch.
# No workflow ran on `main`, so every release compiled the whole workspace
# cold on all five targets (~50 min wall clock, gated by Windows). This
# workflow builds the same `release-dist` profile on `main` so tag builds
# restore a warm default-branch cache.
#
# Triggers: dependency-affecting pushes to main (each release's version-bump
# commit warms the cache for the NEXT release), a weekly refresh so the
# cache never hits GitHub's 7-day unused-eviction, and manual dispatch.
#
# The setup steps mirror release.yml's build job (toolchain, target,
# dotslash/protoc, rust-cache key) — keep them in lockstep, or the cache
# key won't match and releases go back to cold builds.
on:
push:
branches: ["main"]
paths:
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".github/workflows/warm-cache.yml"
schedule:
- cron: "17 5 * * 1"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
concurrency:
group: warm-cache
cancel-in-progress: true
jobs:
warm:
name: warm (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-14
- target: x86_64-apple-darwin
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Add build target
run: rustup target add ${{ matrix.target }}
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- name: Install protoc (Windows PATH fallback)
if: runner.os == 'Windows'
shell: pwsh
run: |
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
# Same key as release.yml so tag builds restore this cache verbatim.
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build kigi (release-dist)
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
+51 -7
View File
@@ -32,9 +32,19 @@ import) or any `KIMI_*` env var.
- **Observability is local**: `kigi-log` (unified session log, `--debug` - **Observability is local**: `kigi-log` (unified session log, `--debug`
firehose, subsystem file logs, opt-in instrumentation) writes under firehose, subsystem file logs, opt-in instrumentation) writes under
`~/.kigi` only. Its zero-network property is a contract. `~/.kigi` only. Its zero-network property is a contract.
- **Atomic file replace goes through `util::fs::replace_file`** (tmp+rename
commit step; async callers wrap in `spawn_blocking`). Never inline a bare
`fs::rename` replace: Windows `MoveFileExW(REPLACE_EXISTING)` fails with a
sharing violation while AV/indexer/cloud-sync holds the destination open —
the "persists on macOS, silently doesn't on Windows" class (a /model
switch that never stuck). Plain rename stays correct only for true moves
whose destination doesn't pre-exist (worktree-pool markers, corrupt-file
backups). Write failures must at least `warn!` — never `let _ =`.
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in - The root `Cargo.toml` is hand-maintained (upstream's generator is not in
this repo). Members sorted; versions inherited from this repo). Members sorted; versions inherited from
`workspace.package.version` (0.1.0). `workspace.package.version` — the single source of truth for the release
version (`kigi_version::VERSION` derives from it; the release workflow
gates the `v*` tag against it).
## Layout ## Layout
@@ -79,8 +89,9 @@ node as one ordinary goal — the agentic loop lives INSIDE the node; the
edges stay deterministic Rust. The harness appends a terminal edges stay deterministic Rust. The harness appends a terminal
`gn-final` verification node depending on every planner node. `gn-final` verification node depending on every planner node.
- Feature flag `KIGI_GRAPH=1` (default off); availability additionally - Enabled by default (`KIGI_GRAPH=0` is the off-switch; the G0 gray
requires the goal harness (`BuiltinGate::Graph`). release is over); availability additionally requires the goal harness
(`BuiltinGate::Graph`).
- Key modules (kigi-shell): `session/graph_tracker.rs` (pure state - Key modules (kigi-shell): `session/graph_tracker.rs` (pure state
machine; reuses `GoalStatus`/`GoalPhase`/`GoalPauseReason`), machine; reuses `GoalStatus`/`GoalPhase`/`GoalPauseReason`),
`session/graph_plan.rs` (planner-JSON contract + validation + fnv id `session/graph_plan.rs` (planner-JSON contract + validation + fnv id
@@ -209,7 +220,34 @@ edges stay deterministic Rust. The harness appends a terminal
system prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max system prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max
only), so API-key `anthropic`/`minimax` Messages requests stay only), so API-key `anthropic`/`minimax` Messages requests stay
byte-identical. Its `/v1/models` listing rides the same Bearer + byte-identical. Its `/v1/models` listing rides the same Bearer +
oauth-beta headers. oauth-beta headers. THINKING REPLAY (`prune_replayed_thinking`,
all Messages requests): Anthropic validates every replayed
`thinking` block (signature model-bound, non-empty required), so
only the final assistant message's signed thinking is replayed and
only while its tool loop is open (request ends on the tool
results); everything else — unsigned cross-backend history, `tco_*`
Responses blobs, stale-model blocks — is stripped, or the request
400s "Invalid `signature` in `thinking` block".
- CROSS-PROVIDER REPLAY POLICY (Pi `transform-messages` pattern): the
conversation history is provider-agnostic and sessions switch
models/backends mid-history, so EACH wire builder owns emitting only
items valid for its target — never patch downstream except in the
per-backend body adapters. Concretely: the Responses input drops
Reasoning items without a native `rs_*` id (foreign capture is id "")
and provenance-gates whole turns via `transform_items_for_responses`
(`AssistantItem.model_id` vs the request model: foreign Reasoning
dropped, foreign BackendToolCall demoted to its `text_summary`);
the codex adapter additionally drops bare `rs_*` references (stateless
backend); tool-call ids pass through ONE shared ASCII
`sanitize_tool_call_id` symmetrically on call+result on BOTH the
Messages and Responses legs; Messages image sources go through
`parse_base64_image_data_uri` (raster whitelist, no `data:` url
sources) and empty user turns get a placeholder. Dangling tool calls
are already repaired item-level by `repair_dangling_tool_calls` on the
actor's build path. When a provider wire bug surfaces, fix the CLASS
across all three builders in the same pass — three sequential
single-provider fixes (thinking signature → codex system role → codex
reasoning id) motivated this policy.
- `openai-codex` (ChatGPT Plus/Pro, `scope_key oauth/openai-codex`, port - `openai-codex` (ChatGPT Plus/Pro, `scope_key oauth/openai-codex`, port
1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`, 1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`,
client `app_EMoam…`, scope `openid profile email offline_access`, the 3 client `app_EMoam…`, scope `openid profile email offline_access`, the 3
@@ -226,9 +264,15 @@ edges stay deterministic Rust. The harness appends a terminal
`PlatformId::sends_codex_responses_headers()`): headers `PlatformId::sends_codex_responses_headers()`): headers
`chatgpt-account-id` (per-request from the JWT), `originator codex_cli_rs`, `chatgpt-account-id` (per-request from the JWT), `originator codex_cli_rs`,
`OpenAI-Beta responses=experimental`, a codex `User-Agent`; `store:false` `OpenAI-Beta responses=experimental`, a codex `User-Agent`; `store:false`
is the shared Responses default. API-key `openai` Responses requests carry is the shared Responses default. BODY adaptation
NONE of this (byte-identical). `reasoning.effort` carries the thinking (`adapt_body_for_codex_backend`, same gate): the backend 400s
level (incl. the codex-only `ultra`). NO websocket, NO base_instructions. `role:system` input ("System messages are not allowed") — system items
are hoisted into the top-level `instructions` field — and stateless
reasoning replay requires `include:["reasoning.encrypted_content"]`.
API-key `openai` Responses requests carry NONE of this
(byte-identical, pinned by a control wire test). `reasoning.effort`
carries the thinking level (incl. the codex-only `ultra`). NO
websocket, NO base_instructions.
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` → CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
`openai_codex_wire_models`, mapped through the SAME `openai_codex_wire_models`, mapped through the SAME
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` && `platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
Generated
+62 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-acp-lib" name = "kigi-acp-lib"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"async-trait", "async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-agent" name = "kigi-agent"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 6.0.0", "dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-agent-lifecycle" name = "kigi-agent-lifecycle"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"tokio", "tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-auth" name = "kigi-auth"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"http 1.4.2", "http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-bin" name = "kigi-bin"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-chat-state" name = "kigi-chat-state"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"kigi-compaction", "kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-codebase-graph" name = "kigi-codebase-graph"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"ahash", "ahash",
"clap", "clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-compaction" name = "kigi-compaction"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-config" name = "kigi-config"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"base64", "base64",
"blake3", "blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-config-types" name = "kigi-config-types"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"indexmap", "indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-crash-handler" name = "kigi-crash-handler"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"libc", "libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-env" name = "kigi-env"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"tracing", "tracing",
"url", "url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-fast-worktree" name = "kigi-fast-worktree"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-file-utils" name = "kigi-file-utils"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"aws-config", "aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-fsnotify" name = "kigi-fsnotify"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"criterion", "criterion",
"dunce", "dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-gix-status" name = "kigi-gix-status"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"gix", "gix",
"kigi-test-utils", "kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-hooks" name = "kigi-hooks"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"kigi-config", "kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-hooks-plugins-types" name = "kigi-hooks-plugins-types"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-http" name = "kigi-http"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"kigi-auth", "kigi-auth",
"kigi-log", "kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-hunk-tracker" name = "kigi-hunk-tracker"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"chrono", "chrono",
"dunce", "dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]] [[package]]
name = "kigi-interjection-core" name = "kigi-interjection-core"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "kigi-log" name = "kigi-log"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-markdown" name = "kigi-markdown"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"anstyle-lossy", "anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]] [[package]]
name = "kigi-markdown-core" name = "kigi-markdown-core"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"pulldown-cmark", "pulldown-cmark",
] ]
[[package]] [[package]]
name = "kigi-mcp" name = "kigi-mcp"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"async-trait", "async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-memory" name = "kigi-memory"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-mermaid" name = "kigi-mermaid"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"fontdb", "fontdb",
"image", "image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-models" name = "kigi-models"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"kigi-env", "kigi-env",
"serde", "serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-pager-minimal" name = "kigi-pager-minimal"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"chrono", "chrono",
"crossterm", "crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-pager-pty-harness" name = "kigi-pager-pty-harness"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"anyhow", "anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-pager-render" name = "kigi-pager-render"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anstyle", "anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-paths" name = "kigi-paths"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"camino", "camino",
"serde", "serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-prompt-queue" name = "kigi-prompt-queue"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-proto-build" name = "kigi-proto-build"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pbjson-build", "pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-ratatui-inline" name = "kigi-ratatui-inline"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"ansi-width", "ansi-width",
"anstyle-parse 0.2.7", "anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-ratatui-textarea" name = "kigi-ratatui-textarea"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sampler" name = "kigi-sampler"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"async-openai", "async-openai",
"async-stream", "async-stream",
@@ -6154,7 +6154,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sampling-types" name = "kigi-sampling-types"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"assert_matches", "assert_matches",
"async-openai", "async-openai",
@@ -6171,7 +6171,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sandbox" name = "kigi-sandbox"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -6192,7 +6192,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-secrets" name = "kigi-secrets"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"regex", "regex",
"serde_json", "serde_json",
@@ -6230,7 +6230,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-shell" name = "kigi-shell"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anyhow", "anyhow",
@@ -6367,7 +6367,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-shell-base" name = "kigi-shell-base"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -6392,7 +6392,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sqlite-journal" name = "kigi-sqlite-journal"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"libc", "libc",
"rusqlite", "rusqlite",
@@ -6403,7 +6403,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-subagent-resolution" name = "kigi-subagent-resolution"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"kigi-sampling-types", "kigi-sampling-types",
"kigi-tool-types", "kigi-tool-types",
@@ -6418,7 +6418,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-system-power" name = "kigi-system-power"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
"zbus", "zbus",
@@ -6426,7 +6426,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-test-support" name = "kigi-test-support"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anyhow", "anyhow",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-test-utils" name = "kigi-test-utils"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"runfiles", "runfiles",
"tracing", "tracing",
@@ -6457,11 +6457,11 @@ dependencies = [
[[package]] [[package]]
name = "kigi-token-estimation" name = "kigi-token-estimation"
version = "0.1.4" version = "0.1.7"
[[package]] [[package]]
name = "kigi-tool-protocol" name = "kigi-tool-protocol"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"kigi-tool-types", "kigi-tool-types",
"serde", "serde",
@@ -6472,7 +6472,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tool-runtime" name = "kigi-tool-runtime"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tool-types" name = "kigi-tool-types"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"minijinja", "minijinja",
"schemars 1.2.1", "schemars 1.2.1",
@@ -6500,7 +6500,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tools" name = "kigi-tools"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -6577,7 +6577,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tools-api" name = "kigi-tools-api"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"kigi-proto-build", "kigi-proto-build",
"kigi-tool-protocol", "kigi-tool-protocol",
@@ -6590,11 +6590,11 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tracing-macros" name = "kigi-tracing-macros"
version = "0.1.4" version = "0.1.7"
[[package]] [[package]]
name = "kigi-tty-utils" name = "kigi-tty-utils"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"libc", "libc",
"nix 0.30.1", "nix 0.30.1",
@@ -6604,7 +6604,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tui" name = "kigi-tui"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"ansi-to-tui", "ansi-to-tui",
@@ -6691,7 +6691,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-update" name = "kigi-update"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dunce", "dunce",
@@ -6720,14 +6720,14 @@ dependencies = [
[[package]] [[package]]
name = "kigi-version" name = "kigi-version"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"semver", "semver",
] ]
[[package]] [[package]]
name = "kigi-workspace" name = "kigi-workspace"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anyhow", "anyhow",
@@ -6806,7 +6806,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-workspace-types" name = "kigi-workspace-types"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"base64", "base64",
"chrono", "chrono",
@@ -8840,7 +8840,7 @@ dependencies = [
[[package]] [[package]]
name = "ptyctl" name = "ptyctl"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"anyhow", "anyhow",
@@ -8858,7 +8858,7 @@ dependencies = [
[[package]] [[package]]
name = "ptyctl-cli" name = "ptyctl-cli"
version = "0.1.4" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.4" version = "0.1.7"
edition = "2024" edition = "2024"
license = "Apache-2.0" license = "Apache-2.0"
@@ -1183,6 +1183,9 @@ impl SamplingClient {
// old raw_output machinery. // old raw_output machinery.
kigi_sampling_types::patch_reasoning_text_types(&mut request_body); kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort); kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
if self.defaults.openai_codex {
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
}
let http_request = self.post(self.endpoint("responses")).json(&request_body); let http_request = self.post(self.endpoint("responses")).json(&request_body);
let response = http_request.send().await.map_err(|e| { let response = http_request.send().await.map_err(|e| {
@@ -1321,6 +1324,9 @@ impl SamplingClient {
} }
kigi_sampling_types::patch_reasoning_text_types(&mut request_body); kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort); kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
if self.defaults.openai_codex {
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
}
// Fresh per attempt so signals never leak across retries; `None` // Fresh per attempt so signals never leak across retries; `None`
// (check disabled) sends no header and does no peek work per event. // (check disabled) sends no header and does no peek work per event.
let doom_loop = self let doom_loop = self
@@ -319,15 +319,15 @@ impl BackendToolCallItem {
format!("[backend x_search] {}({})", ct.name, ct.input) format!("[backend x_search] {}({})", ct.name, ct.input)
} }
BackendToolKind::CodeInterpreter(ci) => { BackendToolKind::CodeInterpreter(ci) => {
// Char-boundary-safe preview: a byte slice (`&c[..100]`)
// panicked on CJK/emoji code, crashing every subsequent
// request build on every backend.
let code_preview = ci let code_preview = ci
.code .code
.as_deref() .as_deref()
.map(|c| { .map(|c| match c.char_indices().nth(100) {
if c.len() > 100 { Some((byte_idx, _)) => format!("{}...", &c[..byte_idx]),
format!("{}...", &c[..100]) None => c.to_string(),
} else {
c.to_string()
}
}) })
.unwrap_or_default(); .unwrap_or_default();
format!("[backend code_interpreter] {code_preview}") format!("[backend code_interpreter] {code_preview}")
@@ -2188,14 +2188,86 @@ impl From<&ConversationRequest> for rs::CreateResponse {
/// so they appear inline in the same order the model originally emitted — /// so they appear inline in the same order the model originally emitted —
/// which is what lets the server-side prefix KV-cache hit on repeat turns. /// which is what lets the server-side prefix KV-cache hit on repeat turns.
fn build_responses_input(req: &ConversationRequest) -> rs::InputParam { fn build_responses_input(req: &ConversationRequest) -> rs::InputParam {
let items: Vec<rs::InputItem> = req let transformed = transform_items_for_responses(&req.items, req.model.as_deref());
.items let items: Vec<rs::InputItem> = transformed
.iter() .iter()
.flat_map(conversation_item_to_input_items) .flat_map(conversation_item_to_input_items)
.collect(); .collect();
rs::InputParam::Items(items) rs::InputParam::Items(items)
} }
/// Provenance gate for the Responses input (the Pi `transform-messages`
/// pattern): opaque provider-issued items replay verbatim only when the
/// turn that produced them ran on the SAME model this request targets.
///
/// Each `[Reasoning | BackendToolCall]* Assistant` run carries its
/// provenance in `AssistantItem::model_id`. On a confirmed mismatch
/// (both sides known, different — a mid-session `/model` switch or a
/// cross-backend history):
/// - `Reasoning` siblings are DROPPED — their encrypted payloads are
/// scoped to the issuing model (OpenAI documents encrypted content as
/// model-bound; foreign backends' blobs are undecryptable outright);
/// - `BackendToolCall` items are DEMOTED to a synthetic assistant text
/// summary — exactly the downgrade the Messages and ChatCompletions
/// builders already perform — because their typed shapes carry
/// provider-issued ids and tool names the target never declared
/// (e.g. a grok `x_search` CustomToolCall replayed to codex).
///
/// Same-model runs, unknown provenance (pre-provenance histories with
/// `model_id: None`), and trailing orphans pass through verbatim — that
/// byte-stability is what lets the server-side prefix KV-cache hit.
fn transform_items_for_responses(
items: &[ConversationItem],
target_model: Option<&str>,
) -> Vec<ConversationItem> {
let Some(target) = target_model else {
return items.to_vec();
};
let mut out: Vec<ConversationItem> = Vec::with_capacity(items.len());
// Pending run of opaque siblings awaiting their Assistant carrier.
let mut run_start: usize = 0;
for item in items {
match item {
ConversationItem::Reasoning(_) | ConversationItem::BackendToolCall(_) => {
out.push(item.clone());
}
ConversationItem::Assistant(a) => {
let foreign = a
.model_id
.as_deref()
.is_some_and(|producer| producer != target);
if foreign {
// Rewrite the pending run in place.
let mut rewritten: Vec<ConversationItem> = Vec::new();
for pending in out.drain(run_start..) {
match pending {
ConversationItem::Reasoning(_) => {}
ConversationItem::BackendToolCall(b) => {
rewritten.push(ConversationItem::Assistant(AssistantItem {
content: b.text_summary().into(),
tool_calls: vec![],
model_id: a.model_id.clone(),
model_fingerprint: None,
reasoning_effort: None,
}));
}
other => rewritten.push(other),
}
}
out.extend(rewritten);
}
out.push(item.clone());
run_start = out.len();
}
other => {
out.push(other.clone());
run_start = out.len();
}
}
}
out
}
/// Walk a serialized Responses API request body and inject the /// Walk a serialized Responses API request body and inject the
/// `type: "reasoning_text"` discriminator that the API requires on /// `type: "reasoning_text"` discriminator that the API requires on
/// `reasoning.content[*]` items. /// `reasoning.content[*]` items.
@@ -2251,9 +2323,23 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
} }
ConversationItem::Reasoning(r) => { ConversationItem::Reasoning(r) => {
// Reasoning items round-trip back to the Responses API in their // Reasoning items round-trip back to the Responses API in their
// native typed form. `status` is output-only (the API rejects it // native typed form — but ONLY items the Responses API itself
// on input), so strip it before emission; everything else // produced. A native item always carries a server-issued `rs_*`
// (summary, content, encrypted_content, id) passes through. // id; an EMPTY id marks a foreign item (Messages capture stores
// the Anthropic signature with id "", chat-completions and the
// stream-delta fallback synthesize with id "", legacy upgraders
// reconstruct with id ""), and the API rejects it outright:
// 400 "Invalid 'input[N].id': ''. Expected an ID that contains
// letters, numbers, underscores, or dashes". Foreign reasoning
// is unusable by a Responses provider anyway — drop it (the
// exact mirror of the Messages builder's
// `prune_replayed_thinking`).
if r.id.is_empty() {
return vec![];
}
// `status` is output-only (the API rejects it on input), so
// strip it before emission; everything else (summary, content,
// encrypted_content, id) passes through.
let mut r = r.clone(); let mut r = r.clone();
r.status = None; r.status = None;
vec![rs::InputItem::Item(rs::Item::Reasoning(r))] vec![rs::InputItem::Item(rs::Item::Reasoning(r))]
@@ -2274,12 +2360,15 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
})); }));
} }
// Add each tool call as a FunctionCall item // Add each tool call as a FunctionCall item. The call_id is
// normalized to the Responses charset (foreign backends mint
// arbitrary ids); the ToolResult arm applies the SAME map so
// pairing survives.
for tc in &a.tool_calls { for tc in &a.tool_calls {
let arguments = sanitize_tool_arguments(&tc.id, &tc.name, tc.arguments.clone()); let arguments = sanitize_tool_arguments(&tc.id, &tc.name, tc.arguments.clone());
items.push(rs::InputItem::Item(rs::Item::FunctionCall( items.push(rs::InputItem::Item(rs::Item::FunctionCall(
rs::FunctionToolCall { rs::FunctionToolCall {
call_id: tc.id.as_ref().to_owned(), call_id: sanitize_tool_call_id(&tc.id),
name: tc.name.clone(), name: tc.name.clone(),
arguments: arguments.as_ref().to_owned(), arguments: arguments.as_ref().to_owned(),
id: None, id: None,
@@ -2313,7 +2402,9 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
}; };
vec![rs::InputItem::Item(rs::Item::FunctionCallOutput( vec![rs::InputItem::Item(rs::Item::FunctionCallOutput(
rs::FunctionCallOutputItemParam { rs::FunctionCallOutputItemParam {
call_id: t.tool_call_id.clone(), // Same normalization as the FunctionCall arm — pairing
// survives because both sides map identically.
call_id: sanitize_tool_call_id(&t.tool_call_id),
output, output,
id: None, id: None,
status: None, status: None,
@@ -2983,6 +3074,53 @@ pub fn dedup_duplicate_tool_results(conversation: &mut Vec<ConversationItem>) ->
// ============================================================================ // ============================================================================
/// Convert a ConversationRequest to Anthropic MessagesRequest. /// Convert a ConversationRequest to Anthropic MessagesRequest.
/// Normalize a tool-call id to the `[A-Za-z0-9_-]+` charset both Anthropic
/// Messages and the OpenAI Responses API enforce ("Expected an ID that
/// contains letters, numbers, underscores, or dashes"). Foreign backends
/// mint arbitrary ids (chat-completions providers, UUID synthesis), so the
/// wire builders apply this SYMMETRICALLY on the call and its result —
/// pairing survives because both sides map through the same function.
/// ASCII-only (the old closure used Unicode `is_alphanumeric`, letting
/// e.g. CJK ids through to Anthropic's ASCII contract); an empty id maps
/// to `"_"` so the mandatory field is never empty on the wire.
fn sanitize_tool_call_id(id: &str) -> String {
if id.is_empty() {
return "_".to_string();
}
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
/// The raster media types Anthropic accepts for base64 image sources.
const ANTHROPIC_IMAGE_MEDIA_TYPES: [&str; 4] =
["image/jpeg", "image/png", "image/gif", "image/webp"];
/// Parse a `data:` URI into an Anthropic base64 image source
/// `(media_type, data)`.
///
/// `None` for anything Anthropic would reject — non-base64 data URIs
/// (previously leaked as `ImageSource::Url` carrying a `data:` payload:
/// url sources must be http(s) → 400), media types outside the raster
/// whitelist (`image/svg+xml` → 400), and param-carrying headers
/// (`data:image/webp;name=x;base64,…` yields media type
/// `"image/webp;name=x"` → 400). Callers degrade to a short text
/// placeholder — never the raw URI, which for data URIs can be megabytes.
fn parse_base64_image_data_uri(url: &str) -> Option<(String, String)> {
let rest = url.strip_prefix("data:")?;
let (media_type, data) = rest.split_once(";base64,")?;
let media_type = media_type.to_ascii_lowercase();
ANTHROPIC_IMAGE_MEDIA_TYPES
.contains(&media_type.as_str())
.then(|| (media_type, data.to_string()))
}
pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::MessagesRequest { pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::MessagesRequest {
use crate::messages::{ use crate::messages::{
CacheControl, ContentBlock, ImageSource, Message, MessageContent, MessageRole, CacheControl, ContentBlock, ImageSource, Message, MessageContent, MessageRole,
@@ -2995,19 +3133,6 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
let mut pending_assistant: Vec<ContentBlock> = Vec::new(); let mut pending_assistant: Vec<ContentBlock> = Vec::new();
let mut pending_tool_results: Vec<ContentBlock> = Vec::new(); let mut pending_tool_results: Vec<ContentBlock> = Vec::new();
// Helper to sanitize tool call IDs (replace [^a-zA-Z0-9_-] with _)
let sanitize_tool_call_id = |id: &str| -> String {
id.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
};
// Helper to convert ContentPart to Anthropic ContentBlock // Helper to convert ContentPart to Anthropic ContentBlock
let content_parts_to_anthropic_blocks = |parts: &[ContentPart]| -> Vec<ContentBlock> { let content_parts_to_anthropic_blocks = |parts: &[ContentPart]| -> Vec<ContentBlock> {
parts parts
@@ -3018,28 +3143,9 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
cache_control: None, cache_control: None,
}, },
ContentPart::Image { url } => { ContentPart::Image { url } => {
// Parse data: URI vs HTTP(S) URL if let Some((media_type, data)) = parse_base64_image_data_uri(url) {
if url.starts_with("data:") {
// data:image/png;base64,ABC123...
if let Some((header, data)) = url.split_once(',') {
// Extract media type from header: data:image/png;base64
let media_type = header
.strip_prefix("data:")
.and_then(|h| h.strip_suffix(";base64"))
.unwrap_or("image/png")
.to_string();
ContentBlock::Image { ContentBlock::Image {
source: ImageSource::Base64 { source: ImageSource::Base64 { media_type, data },
media_type,
data: data.to_string(),
},
}
} else {
// Malformed data URI, treat as text
ContentBlock::Text {
text: format!("[invalid image: {}]", url),
cache_control: None,
}
} }
} else if url.starts_with("http://") || url.starts_with("https://") { } else if url.starts_with("http://") || url.starts_with("https://") {
ContentBlock::Image { ContentBlock::Image {
@@ -3047,6 +3153,13 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
url: url.as_ref().to_owned(), url: url.as_ref().to_owned(),
}, },
} }
} else if url.starts_with("data:") {
// Rejected data URI (non-base64 / non-raster media
// type): short placeholder, NEVER the payload.
ContentBlock::Text {
text: "[unsupported image]".to_string(),
cache_control: None,
}
} else { } else {
// Unknown format, treat as text // Unknown format, treat as text
ContentBlock::Text { ContentBlock::Text {
@@ -3096,7 +3209,17 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
ConversationItem::User(u) => { ConversationItem::User(u) => {
flush_assistant(&mut pending_assistant, &mut messages); flush_assistant(&mut pending_assistant, &mut messages);
flush_tool_results(&mut pending_tool_results, &mut messages); flush_tool_results(&mut pending_tool_results, &mut messages);
let blocks = content_parts_to_anthropic_blocks(&u.content); // Anthropic rejects empty content: drop empty text parts and
// give an all-empty user turn a placeholder (mirrors the
// assistant arm's emptiness guard).
let mut blocks = content_parts_to_anthropic_blocks(&u.content);
blocks.retain(|b| !matches!(b, ContentBlock::Text { text, .. } if text.is_empty()));
if blocks.is_empty() {
blocks.push(ContentBlock::Text {
text: "[empty message]".to_string(),
cache_control: None,
});
}
messages.push(Message { messages.push(Message {
role: MessageRole::User, role: MessageRole::User,
content: MessageContent::Blocks(blocks), content: MessageContent::Blocks(blocks),
@@ -3139,23 +3262,26 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
}]; }];
for img in &t.images { for img in &t.images {
if let ContentPart::Image { url } = img { if let ContentPart::Image { url } = img {
let source = if let Some(rest) = url.strip_prefix("data:") { // Same whitelist parse as the user path; a
if let Some((media_type, data)) = rest.split_once(";base64,") { // rejected data URI must never ride as
ImageSource::Base64 { // `ImageSource::Url` (url sources are http(s)
media_type: media_type.to_string(), // only — Anthropic 400s a `data:` payload).
data: data.to_string(), if let Some((media_type, data)) = parse_base64_image_data_uri(url) {
} blocks.push(ContentBlock::Image {
} else { source: ImageSource::Base64 { media_type, data },
ImageSource::Url { });
} else if url.starts_with("http://") || url.starts_with("https://") {
blocks.push(ContentBlock::Image {
source: ImageSource::Url {
url: url.as_ref().to_owned(), url: url.as_ref().to_owned(),
} },
} });
} else { } else {
ImageSource::Url { blocks.push(ContentBlock::Text {
url: url.as_ref().to_owned(), text: "[unsupported image]".to_string(),
cache_control: None,
});
} }
};
blocks.push(ContentBlock::Image { source });
} }
} }
ToolResultContent::Blocks(blocks) ToolResultContent::Blocks(blocks)
@@ -3201,6 +3327,8 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
flush_assistant(&mut pending_assistant, &mut messages); flush_assistant(&mut pending_assistant, &mut messages);
flush_tool_results(&mut pending_tool_results, &mut messages); flush_tool_results(&mut pending_tool_results, &mut messages);
prune_replayed_thinking(&mut messages);
// Attach cache_control: {type: "ephemeral"} to last system block // Attach cache_control: {type: "ephemeral"} to last system block
if let Some(last) = system_blocks.last_mut() { if let Some(last) = system_blocks.last_mut() {
last.cache_control = Some(CacheControl { last.cache_control = Some(CacheControl {
@@ -3290,6 +3418,74 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
} }
} }
/// Strip replayed `thinking` blocks the Anthropic Messages API would
/// reject — keep exactly the one it requires.
///
/// Anthropic validates EVERY `thinking` block in the request: the
/// signature is bound to the emitting model and must be non-empty, so
/// history from another backend (`encrypted_content: None` replays as
/// `signature: ""`), a Responses-API `tco_*` blob (signature bytes with no
/// text), or a block signed by a DIFFERENT model after a mid-session
/// `/model` switch 400s the whole request with
/// "messages.N.content.0: Invalid `signature` in `thinking` block".
///
/// The API only NEEDS thinking for the ACTIVE tool-use continuation: the
/// final assistant message whose tool_use results follow must carry its
/// signed thinking back verbatim. Prior turns' thinking is ignored even
/// when valid (Pi/Claude Code replay exactly this way). So: keep the
/// final assistant message's thinking when the loop is open and the block
/// is genuinely signed (non-empty text AND signature — an open loop can
/// never span a model switch, so that signature is always the current
/// model's); strip every other thinking block. An assistant message left
/// EMPTY by the strip (a thinking-only aborted turn) is removed — the API
/// rejects empty content arrays.
fn prune_replayed_thinking(messages: &mut Vec<crate::messages::Message>) {
use crate::messages::{ContentBlock, MessageContent, MessageRole};
let last_assistant = messages
.iter()
.rposition(|m| matches!(m.role, MessageRole::Assistant));
let active_tool_loop = last_assistant.is_some_and(|i| {
let has_tool_use = matches!(
&messages[i].content,
MessageContent::Blocks(blocks)
if blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. }))
);
// The loop is OPEN only while the request ends on the tool results:
// a later plain user turn closes it (the results answered, the model
// replied — its thinking is history the API ignores or rejects).
let continuation = &messages[i + 1..];
let ends_on_results = !continuation.is_empty()
&& continuation.iter().all(|m| {
matches!(
&m.content,
MessageContent::Blocks(blocks)
if blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }))
)
});
has_tool_use && ends_on_results
});
let mut index = 0;
messages.retain_mut(|m| {
let i = index;
index += 1;
if !matches!(m.role, MessageRole::Assistant) {
return true;
}
let MessageContent::Blocks(blocks) = &mut m.content else {
return true;
};
let keep_thinking = active_tool_loop && Some(i) == last_assistant;
blocks.retain(|b| match b {
ContentBlock::Thinking {
thinking,
signature,
} => keep_thinking && !thinking.is_empty() && !signature.is_empty(),
_ => true,
});
!blocks.is_empty()
});
}
/// Convert a MessagesResponse to a single Assistant `ConversationItem`. /// Convert a MessagesResponse to a single Assistant `ConversationItem`.
/// ///
/// Note: Anthropic `Thinking` blocks are dropped here because this `From` /// Note: Anthropic `Thinking` blocks are dropped here because this `From`
@@ -4551,6 +4747,310 @@ mod tests {
} }
} }
/// Anthropic image sources: base64 only for whitelisted raster types;
/// url sources http(s) only. Previously a non-base64 `data:` URI rode
/// as `ImageSource::Url` (400), `image/svg+xml` passed the media type
/// through (400), and a param-carrying header produced
/// `"image/webp;name=x"` (400). Rejected images degrade to a SHORT
/// placeholder — never the multi-megabyte payload. Empty user turns
/// get a placeholder block (Anthropic rejects empty content).
#[test]
fn messages_request_guards_images_and_empty_user_content() {
let req = ConversationRequest::from_items(vec![
ConversationItem::user_with_parts(vec![
ContentPart::Text { text: "".into() },
ContentPart::Image {
url: "data:image/png;base64,AAAA".into(),
},
ContentPart::Image {
url: "data:image/svg+xml;base64,PHN2Zz4=".into(),
},
ContentPart::Image {
url: "data:text/plain,hello".into(),
},
]),
assistant_text("a1"),
// Empty user turn: must not ship an empty content array.
ConversationItem::user(""),
]);
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
let messages = json["messages"].as_array().unwrap();
let first_user = &messages[0]["content"].as_array().unwrap();
// Valid png passes as base64.
assert!(
first_user
.iter()
.any(|b| b["type"] == "image" && b["source"]["media_type"] == "image/png"),
"{json:#}"
);
// svg + non-base64 rejected to short placeholders; never a
// data: payload in a url source, never a non-raster media type.
for m in messages {
if let Some(content) = m.get("content").and_then(|c| c.as_array()) {
assert!(!content.is_empty(), "empty content array: {json:#}");
for b in content {
if b["type"] == "image" {
let src = &b["source"];
if src["type"] == "url" {
let u = src["url"].as_str().unwrap();
assert!(
u.starts_with("http://") || u.starts_with("https://"),
"url source must be http(s): {u}"
);
} else {
let mt = src["media_type"].as_str().unwrap();
assert!(
ANTHROPIC_IMAGE_MEDIA_TYPES.contains(&mt),
"media type must be whitelisted: {mt}"
);
}
}
}
}
}
assert_eq!(
first_user
.iter()
.filter(|b| b["text"] == "[unsupported image]")
.count(),
2,
"both rejected images degrade to placeholders: {json:#}"
);
// The empty user turn carries the placeholder block.
let last_user = messages.last().unwrap();
assert_eq!(last_user["content"][0]["text"], "[empty message]");
}
/// Tool-result images take the same whitelist: a rejected data URI in
/// a tool result degrades to a text block, never an
/// `ImageSource::Url` carrying a `data:` payload.
#[test]
fn messages_request_guards_tool_result_images() {
let req = ConversationRequest::from_items(vec![
ConversationItem::user("q"),
ConversationItem::Assistant(AssistantItem {
content: "".into(),
tool_calls: vec![ToolCall {
id: std::sync::Arc::from("tc1"),
name: "read_file".to_string(),
arguments: std::sync::Arc::from("{}"),
}],
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
}),
ConversationItem::tool_result_with_images(
"tc1",
"saw an image",
vec![ContentPart::Image {
url: "data:text/plain,hello".into(),
}],
),
]);
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
let raw = json.to_string();
assert!(
!raw.contains("data:text/plain"),
"rejected data URI must not reach the wire: {json:#}"
);
assert!(raw.contains("[unsupported image]"), "{json:#}");
}
/// Provenance gate: a turn produced by a DIFFERENT model must not
/// replay its opaque items to this request's target — Reasoning
/// (model-bound encrypted payloads) is dropped, BackendToolCall
/// (provider-issued ids + undeclared tool shapes, e.g. grok x_search
/// → codex) is demoted to the same text summary the other builders
/// emit. Same-model and unknown-provenance turns stay byte-verbatim
/// (KV-cache stability).
#[test]
fn responses_input_gates_foreign_turns_by_provenance() {
let custom_call: rs::CustomToolCall = serde_json::from_value(serde_json::json!({
"call_id": "xs_1",
"id": "ct_1",
"input": "{\"q\":\"news\"}",
"name": "x_search",
}))
.expect("custom tool call fixture");
let x_search = ConversationItem::BackendToolCall(BackendToolCallItem {
kind: BackendToolKind::XSearch(custom_call),
});
let foreign_assistant = ConversationItem::Assistant(AssistantItem {
content: "grok says hi".into(),
tool_calls: vec![],
model_id: Some("grok-4".to_string()),
model_fingerprint: None,
reasoning_effort: None,
});
let native_assistant = ConversationItem::Assistant(AssistantItem {
content: "codex says hi".into(),
tool_calls: vec![],
model_id: Some("gpt-5.2-codex".to_string()),
model_fingerprint: None,
reasoning_effort: None,
});
let mut req = ConversationRequest::from_items(vec![
ConversationItem::user("q1"),
// Foreign turn: grok reasoning + x_search + assistant.
ConversationItem::Reasoning(rs::ReasoningItem {
id: "rs_grok_1".to_string(),
summary: vec![],
content: None,
encrypted_content: Some("grok-blob".to_string()),
status: None,
}),
x_search,
foreign_assistant,
ConversationItem::user("q2"),
// Native turn: same model as the request target.
ConversationItem::Reasoning(rs::ReasoningItem {
id: "rs_codex_1".to_string(),
summary: vec![],
content: None,
encrypted_content: Some("codex-blob".to_string()),
status: None,
}),
native_assistant,
ConversationItem::user("q3"),
]);
req.model = Some("gpt-5.2-codex".to_string());
let json = serde_json::to_value(rs::CreateResponse::from(&req)).unwrap();
let input = json["input"].as_array().unwrap();
// Foreign reasoning + x_search gone; the summary text survives.
assert!(
!input.iter().any(|i| i["id"] == "rs_grok_1"),
"foreign reasoning must be dropped:\n{json:#}"
);
assert!(
!input.iter().any(|i| i["type"] == "custom_tool_call"),
"foreign backend tool call must not replay typed:\n{json:#}"
);
assert!(
input.iter().any(|i| i["role"] == "assistant"
&& i["content"]
.as_str()
.is_some_and(|c| c.contains("[backend x_search]"))),
"foreign backend tool call demoted to text summary:\n{json:#}"
);
// Native reasoning verbatim.
assert!(
input
.iter()
.any(|i| i["id"] == "rs_codex_1" && i["encrypted_content"] == "codex-blob"),
"native reasoning must replay verbatim:\n{json:#}"
);
}
/// Both Anthropic Messages and the Responses API enforce
/// `[A-Za-z0-9_-]+` tool-call ids; foreign backends mint arbitrary
/// ones. The shared sanitizer must be ASCII-only (the old closure's
/// Unicode `is_alphanumeric` let CJK ids through), never emit an empty
/// id, and map call + result IDENTICALLY so pairing survives.
#[test]
fn tool_call_ids_sanitized_symmetrically_on_responses_leg() {
let weird_id = "调用#1 β";
let req = ConversationRequest::from_items(vec![
ConversationItem::user("q"),
ConversationItem::Assistant(AssistantItem {
content: "".into(),
tool_calls: vec![ToolCall {
id: std::sync::Arc::from(weird_id),
name: "read_file".to_string(),
arguments: std::sync::Arc::from("{}"),
}],
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
}),
ConversationItem::tool_result(weird_id, "contents"),
]);
let json = serde_json::to_value(rs::CreateResponse::from(&req)).unwrap();
let input = json["input"].as_array().unwrap();
let call_id = input
.iter()
.find(|i| i["type"] == "function_call")
.map(|i| i["call_id"].as_str().unwrap().to_string())
.expect("function_call present");
let output_id = input
.iter()
.find(|i| i["type"] == "function_call_output")
.map(|i| i["call_id"].as_str().unwrap().to_string())
.expect("function_call_output present");
assert_eq!(call_id, output_id, "pairing must survive sanitization");
assert!(
call_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"sanitized id must satisfy the wire charset: {call_id:?}"
);
assert!(!call_id.is_empty());
// The sanitizer itself: ASCII passthrough, unicode replaced, empty
// never emitted.
assert_eq!(sanitize_tool_call_id("toolu_01AB-cd"), "toolu_01AB-cd");
assert_eq!(sanitize_tool_call_id("统A1"), "_A1");
assert_eq!(sanitize_tool_call_id(""), "_");
}
/// The Responses API requires a server-issued id on every replayed
/// reasoning input item — an empty one 400s with "Invalid
/// 'input[N].id': ''" (observed on the Codex backend after a
/// cross-backend session switched to a GPT model). Empty-id reasoning
/// can only be foreign: Messages-captured (Anthropic signature,
/// id "") or chat-completions-synthesized (id "", no encrypted
/// content). Neither is usable by a Responses provider — drop them;
/// native `rs_*` items pass through untouched.
#[test]
fn responses_input_drops_reasoning_without_native_id() {
let req = ConversationRequest::from_items(vec![
ConversationItem::user("q1"),
// Messages-captured: Anthropic signature, empty id.
reasoning("claude thinking", Some("anthropic-sig")),
assistant_text("a1"),
ConversationItem::user("q2"),
// Chat-completions synthesized: empty id, nothing encrypted.
ConversationItem::Reasoning(synthesized_reasoning_item("kimi thinking")),
assistant_text("a2"),
ConversationItem::user("q3"),
// Native Responses item: server-issued id.
ConversationItem::Reasoning(rs::ReasoningItem {
id: "rs_native_1".to_string(),
summary: vec![],
content: None,
encrypted_content: Some("gAAAA-native".to_string()),
status: None,
}),
assistant_text("a3"),
ConversationItem::user("q4"),
]);
let responses_req: rs::CreateResponse = (&req).into();
let json = serde_json::to_value(&responses_req).unwrap();
let reasoning_items: Vec<&serde_json::Value> = json["input"]
.as_array()
.unwrap()
.iter()
.filter(|i| i.get("type").and_then(|t| t.as_str()) == Some("reasoning"))
.collect();
assert_eq!(
reasoning_items.len(),
1,
"only the native rs_* item may be replayed:\n{json:#}"
);
assert_eq!(reasoning_items[0]["id"], "rs_native_1");
assert_eq!(reasoning_items[0]["encrypted_content"], "gAAAA-native");
assert!(
!json["input"]
.as_array()
.unwrap()
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("")),
"no input item may carry an empty id:\n{json:#}"
);
}
#[test] #[test]
fn test_encrypted_reasoning_included_in_responses_api_request() { fn test_encrypted_reasoning_included_in_responses_api_request() {
// Test that when building a Responses API request, encrypted reasoning is included // Test that when building a Responses API request, encrypted reasoning is included
@@ -4617,12 +5117,15 @@ mod tests {
#[test] #[test]
fn test_only_encrypted_reasoning_included_in_request() { fn test_only_encrypted_reasoning_included_in_request() {
// Test that when there's only encrypted content (no visible summary), // Encrypted-only reasoning replays ONLY with a native (server-issued)
// it's still included in the request // id. An id-less encrypted blob is by construction FOREIGN (the
// Responses stream always captures the `rs_*` id; Messages capture
// stores the Anthropic signature with id "") and the API rejects
// empty ids — see `responses_input_drops_reasoning_without_native_id`.
let req = ConversationRequest::from_items(vec![ let req = ConversationRequest::from_items(vec![
ConversationItem::user("Hello"), ConversationItem::user("Hello"),
ConversationItem::Reasoning(rs::ReasoningItem { ConversationItem::Reasoning(rs::ReasoningItem {
id: String::new(), id: "rs_hidden_1".to_string(),
summary: vec![], summary: vec![],
content: None, content: None,
encrypted_content: Some("enc_hidden_thoughts".to_string()), encrypted_content: Some("enc_hidden_thoughts".to_string()),
@@ -4655,6 +5158,7 @@ mod tests {
assert_eq!(reasoning_items.len(), 1); assert_eq!(reasoning_items.len(), 1);
let reasoning = reasoning_items[0]; let reasoning = reasoning_items[0];
assert_eq!(reasoning.id, "rs_hidden_1");
// Encrypted content should be present // Encrypted content should be present
assert_eq!( assert_eq!(
@@ -5332,6 +5836,160 @@ mod tests {
/// messages while setting top-level `thinking: null` — the Messages API /// messages while setting top-level `thinking: null` — the Messages API
/// rejects this with a 400. Verify that stripped reasoning produces a /// rejects this with a 400. Verify that stripped reasoning produces a
/// valid request with no thinking blocks in messages. /// valid request with no thinking blocks in messages.
/// Collect `(message_index, thinking, signature)` for every thinking
/// block in a built Messages request.
fn thinking_blocks(json: &serde_json::Value) -> Vec<(usize, String, String)> {
let mut out = Vec::new();
for (i, m) in json["messages"].as_array().unwrap().iter().enumerate() {
if let Some(content) = m.get("content").and_then(|c| c.as_array()) {
for b in content {
if b.get("type").and_then(|t| t.as_str()) == Some("thinking") {
out.push((
i,
b["thinking"].as_str().unwrap_or_default().to_string(),
b["signature"].as_str().unwrap_or_default().to_string(),
));
}
}
}
}
out
}
fn reasoning(text: &str, encrypted: Option<&str>) -> ConversationItem {
ConversationItem::Reasoning(rs::ReasoningItem {
id: String::new(),
summary: if text.is_empty() {
vec![]
} else {
vec![rs::SummaryPart::SummaryText(rs::SummaryTextContent {
text: text.to_string(),
})]
},
content: None,
encrypted_content: encrypted.map(str::to_owned),
status: None,
})
}
fn assistant_text(text: &str) -> ConversationItem {
ConversationItem::Assistant(AssistantItem {
content: text.into(),
tool_calls: vec![],
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
})
}
/// Anthropic validates EVERY replayed `thinking` block: an unsigned one
/// (history synthesized by another backend replays as `signature: ""`),
/// a Responses `tco_*` blob (signature with no text), or a block signed
/// by a different model after a mid-session `/model` switch 400s the
/// whole request — "messages.N.content.0: Invalid `signature` in
/// `thinking` block". The API only NEEDS thinking for the active
/// tool-use continuation, so outside one the builder must replay NO
/// thinking blocks at all.
#[test]
fn messages_request_strips_thinking_outside_active_tool_loop() {
let req = ConversationRequest::from_items(vec![
ConversationItem::system("sys"),
ConversationItem::user("q1"),
// Cross-backend history: unsigned reasoning (the Windows repro —
// session started on another model, then switched to Claude).
reasoning("some thinking", None),
assistant_text("a1"),
ConversationItem::user("q2"),
// Responses-API blob: signature-shaped bytes, no text.
reasoning("", Some("tco_blob")),
assistant_text("a2"),
ConversationItem::user("q3"),
// Genuinely signed — but its tool loop (none) is closed, so the
// API ignores it when valid and 400s it after a model switch.
reasoning("signed thinking", Some("sig-real")),
assistant_text("a3"),
ConversationItem::user("q4"),
]);
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
assert_eq!(
thinking_blocks(&json),
vec![],
"no thinking block may be replayed outside an active tool loop:\n{json:#}"
);
}
/// The active tool-use continuation is the one place Anthropic REQUIRES
/// the signed thinking block back: the final assistant message issued
/// tool_use and its results follow. Exactly that block is kept; a
/// prior turn's signed thinking is still stripped.
#[test]
fn messages_request_keeps_signed_thinking_for_active_tool_loop() {
let req = ConversationRequest::from_items(vec![
ConversationItem::user("q0"),
reasoning("old turn", Some("sig-old")),
assistant_text("a0"),
ConversationItem::user("q1"),
reasoning("current turn", Some("sig-current")),
ConversationItem::Assistant(AssistantItem {
content: "".into(),
tool_calls: vec![ToolCall {
id: std::sync::Arc::from("tc1"),
name: "read_file".to_string(),
arguments: std::sync::Arc::from("{}"),
}],
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
}),
ConversationItem::tool_result("tc1", "file contents"),
]);
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
let blocks = thinking_blocks(&json);
assert_eq!(
blocks.len(),
1,
"exactly the active loop's thinking survives:\n{json:#}"
);
let (msg_idx, thinking, signature) = &blocks[0];
assert_eq!(thinking, "current turn");
assert_eq!(signature, "sig-current");
// It sits at content.0 of the final assistant message.
let msg = &json["messages"].as_array().unwrap()[*msg_idx];
assert_eq!(msg["role"], "assistant");
assert_eq!(msg["content"][0]["type"], "thinking");
assert!(
msg["content"]
.as_array()
.unwrap()
.iter()
.any(|b| b["type"] == "tool_use"),
"the kept thinking belongs to the tool_use turn"
);
}
/// A thinking-only assistant turn (aborted before any text/tool output)
/// must not survive as an EMPTY assistant message after the strip —
/// Anthropic rejects empty content arrays.
#[test]
fn messages_request_drops_assistant_message_emptied_by_thinking_strip() {
let req = ConversationRequest::from_items(vec![
ConversationItem::user("q"),
reasoning("aborted turn thinking", Some("sig")),
assistant_text(""),
ConversationItem::user("follow-up"),
]);
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
for m in json["messages"].as_array().unwrap() {
if let Some(content) = m.get("content").and_then(|c| c.as_array()) {
assert!(
!content.is_empty(),
"no message may ship an empty content array:\n{json:#}"
);
}
}
assert_eq!(thinking_blocks(&json), vec![]);
}
#[test] #[test]
fn test_btw_stripped_reasoning_produces_no_thinking_blocks() { fn test_btw_stripped_reasoning_produces_no_thinking_blocks() {
// Simulate a conversation where the model responded with thinking. // Simulate a conversation where the model responded with thinking.
@@ -9316,6 +9974,40 @@ mod tests {
assert_prefix_stable(&req2, &req3); assert_prefix_stable(&req2, &req3);
} }
/// `text_summary`'s code preview truncated at BYTE 100 (`&c[..100]`) —
/// a panic on any non-ASCII boundary (CJK/emoji in interpreted code).
/// One poisoned history item then crashed every subsequent request
/// build on every backend. Truncation must be char-boundary safe.
#[test]
fn code_interpreter_summary_truncates_multibyte_code_safely() {
let item = BackendToolCallItem {
kind: BackendToolKind::CodeInterpreter(rs::CodeInterpreterToolCall {
code: Some("统计".repeat(60)),
container_id: "cont_1".to_string(),
id: "ci_1".to_string(),
outputs: None,
status: rs::CodeInterpreterToolCallStatus::Completed,
}),
};
let summary = item.text_summary();
assert!(summary.starts_with("[backend code_interpreter] 统计"));
assert!(
summary.ends_with("..."),
"long code must truncate: {summary}"
);
// ASCII shorter than the cap stays whole.
let short = BackendToolCallItem {
kind: BackendToolKind::CodeInterpreter(rs::CodeInterpreterToolCall {
code: Some("print(1)".to_string()),
container_id: "cont_1".to_string(),
id: "ci_2".to_string(),
outputs: None,
status: rs::CodeInterpreterToolCallStatus::Completed,
}),
};
assert_eq!(short.text_summary(), "[backend code_interpreter] print(1)");
}
/// `BackendToolCall` items round-trip through the wire as their /// `BackendToolCall` items round-trip through the wire as their
/// typed Item shape; their serialized position must be stable across /// typed Item shape; their serialized position must be stable across
/// turns. (This is the structural analogue of the old /// turns. (This is the structural analogue of the old
@@ -1041,6 +1041,87 @@ pub fn patch_reasoning_effort(body: &mut Value, effort: Option<ReasoningEffort>)
} }
} }
/// Adapt a serialized Responses request body to the ChatGPT/Codex backend
/// contract (`chatgpt.com/backend-api/codex/responses`) — ported from the
/// same reference as the identity headers (official Codex CLI + Pi's
/// `api/openai-codex-responses.ts`):
///
/// 1. The backend rejects `role: system` input items outright
/// (400 `{"detail":"System messages are not allowed"}`); its system
/// channel is the top-level `instructions` field. Hoist every system
/// input message there (order preserved, blank-line joined, appended to
/// any existing instructions) and remove them from `input`.
/// 2. `store` is always `false` on this backend, so reasoning continuity
/// is stateless: `include: ["reasoning.encrypted_content"]` is required
/// for the response to carry replayable encrypted reasoning.
/// 3. For the same reason, a replayed reasoning item WITHOUT
/// `encrypted_content` (captured from a stateful api.openai.com session
/// that never requested the include) references server state
/// chatgpt.com does not have — drop it rather than 400.
///
/// openai-codex-GATED at the call sites — API-key `openai` Responses
/// bodies stay byte-identical.
pub fn adapt_body_for_codex_backend(body: &mut Value) {
// 1. Hoist system messages into `instructions`.
let mut hoisted: Vec<String> = Vec::new();
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
input.retain(|item| {
let is_system = item.get("role").and_then(|r| r.as_str()) == Some("system");
if is_system {
match item.get("content") {
Some(Value::String(s)) => hoisted.push(s.clone()),
Some(Value::Array(parts)) => {
for p in parts {
if let Some(t) = p.get("text").and_then(|t| t.as_str()) {
hoisted.push(t.to_string());
}
}
}
_ => {}
}
}
!is_system
});
}
if !hoisted.is_empty() {
let mut instructions = body
.get("instructions")
.and_then(|v| v.as_str())
.map(str::to_owned)
.unwrap_or_default();
for part in hoisted {
if !instructions.is_empty() {
instructions.push_str("\n\n");
}
instructions.push_str(&part);
}
body["instructions"] = Value::String(instructions);
}
// 2. Request replayable encrypted reasoning.
let include = body
.as_object_mut()
.map(|obj| obj.entry("include").or_insert_with(|| Value::Array(vec![])));
if let Some(Value::Array(entries)) = include {
let key = Value::String("reasoning.encrypted_content".to_string());
if !entries.contains(&key) {
entries.push(key);
}
}
// 3. Drop reasoning items with no encrypted payload: stateless codex
// cannot resolve a bare `rs_*` reference.
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
input.retain(|item| {
item.get("type").and_then(|t| t.as_str()) != Some("reasoning")
|| item
.get("encrypted_content")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
});
}
}
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse /// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
/// (`max`): remove it so response deserialization succeeds. The turn's /// (`max`): remove it so response deserialization succeeds. The turn's
/// canonical effort lives in the session sampling config regardless; only /// canonical effort lives in the session sampling config regardless; only
@@ -1551,6 +1632,98 @@ mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
/// The Codex backend rejects `role: system` input outright
/// (400 `{"detail":"System messages are not allowed"}`) — its system
/// channel is the top-level `instructions` field, and stateless
/// (`store: false`) reasoning replay needs
/// `include: ["reasoning.encrypted_content"]`. The adapter must hoist
/// every system item (string or parts content, order preserved),
/// append to existing instructions, and leave the rest of the input
/// untouched.
#[test]
fn codex_adapter_hoists_system_messages_and_requests_encrypted_reasoning() {
let mut body = json!({
"model": "gpt-5.2-codex",
"instructions": "base",
"input": [
{"type": "message", "role": "system", "content": "sys head"},
{"type": "message", "role": "user", "content": "hello"},
{"type": "message", "role": "system", "content": [
{"type": "input_text", "text": "memory reminder"}
]},
{"type": "message", "role": "assistant", "content": "hi"}
]
});
adapt_body_for_codex_backend(&mut body);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 2, "system items removed from input: {body:#}");
assert!(
input.iter().all(|i| i["role"] != "system"),
"no system role may remain: {body:#}"
);
assert_eq!(
body["instructions"], "base\n\nsys head\n\nmemory reminder",
"system content hoisted into instructions, order preserved"
);
assert_eq!(
body["include"],
json!(["reasoning.encrypted_content"]),
"stateless reasoning replay requires the include"
);
// Idempotent: a second pass changes nothing.
let before = body.clone();
adapt_body_for_codex_backend(&mut body);
assert_eq!(body, before);
}
/// Stateless codex cannot resolve a bare `rs_*` reference: reasoning
/// input items without an encrypted payload are dropped; items WITH
/// one pass through untouched.
#[test]
fn codex_adapter_drops_reasoning_without_encrypted_payload() {
let mut body = json!({
"model": "gpt-5.2-codex",
"input": [
{"type": "message", "role": "user", "content": "q"},
{"type": "reasoning", "id": "rs_bare", "summary": []},
{"type": "reasoning", "id": "rs_full", "summary": [],
"encrypted_content": "gAAAA-blob"},
{"type": "message", "role": "assistant", "content": "a"}
]
});
adapt_body_for_codex_backend(&mut body);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 3, "bare rs_* item dropped: {body:#}");
assert!(
input
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_full")),
"encrypted reasoning passes through: {body:#}"
);
assert!(
!input
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_bare")),
"{body:#}"
);
}
/// No system items and no prior instructions: input untouched, no
/// empty-string instructions invented, include still requested.
#[test]
fn codex_adapter_without_system_messages_only_adds_include() {
let mut body = json!({
"model": "gpt-5.2-codex",
"input": [{"type": "message", "role": "user", "content": "q"}]
});
adapt_body_for_codex_backend(&mut body);
assert!(body.get("instructions").is_none(), "{body:#}");
assert_eq!(body["input"].as_array().unwrap().len(), 1);
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
}
/// String content (the only shape non-Mistral providers send) stays the /// String content (the only shape non-Mistral providers send) stays the
/// answer verbatim with no thinking — byte-identical to the pre-change /// answer verbatim with no thinking — byte-identical to the pre-change
/// deserialization. /// deserialization.
@@ -0,0 +1,87 @@
//! Filesystem primitives shared across the shell.
use std::io;
use std::path::Path;
/// Replace `dest` with `tmp` — the commit step of every tmp+rename atomic
/// write in the product. This is the ONE place that knows how to make that
/// commit stick on Windows; call sites must never inline a bare
/// `fs::rename` replace again.
///
/// Unix `rename(2)` replaces atomically and needs no help. Windows
/// `MoveFileExW(REPLACE_EXISTING)` fails with a sharing violation while
/// ANOTHER process (antivirus scanner, search indexer, cloud sync) holds
/// `dest` open — the classic "persists on macOS, silently doesn't on
/// Windows" failure (a model switch that never sticks, a stale models
/// cache). On Windows a failed rename therefore deletes the destination
/// first (the pattern `auth/storage.rs` shipped first) and retries with two
/// short back-offs for scanners that hold the file for a few milliseconds.
///
/// On final failure the tmp file is removed (no litter) and the error is
/// returned — callers decide severity, but MUST at least log it (errors
/// never pass silently).
pub fn replace_file(tmp: &Path, dest: &Path) -> io::Result<()> {
let result = replace_file_inner(tmp, dest);
if result.is_err() {
let _ = std::fs::remove_file(tmp);
}
result
}
#[cfg(not(windows))]
fn replace_file_inner(tmp: &Path, dest: &Path) -> io::Result<()> {
std::fs::rename(tmp, dest)
}
#[cfg(windows)]
fn replace_file_inner(tmp: &Path, dest: &Path) -> io::Result<()> {
let mut last = match std::fs::rename(tmp, dest) {
Ok(()) => return Ok(()),
Err(e) => e,
};
for backoff_ms in [0u64, 10, 50] {
if backoff_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
}
// Delete-first: marks an open-with-delete-sharing file for deletion
// and clears the way for a plain rename; harmless when absent.
let _ = std::fs::remove_file(dest);
match std::fs::rename(tmp, dest) {
Ok(()) => return Ok(()),
Err(e) => last = e,
}
}
Err(last)
}
#[cfg(test)]
mod tests {
use super::*;
/// The common contract on every platform: replace over an existing
/// destination, create a missing one, and error (cleaning the tmp)
/// when the tmp itself is missing.
#[test]
fn replace_file_commits_and_cleans_up() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("target.json");
let tmp = dir.path().join("target.json.tmp");
// Create-missing.
std::fs::write(&tmp, b"v1").unwrap();
replace_file(&tmp, &dest).expect("create");
assert_eq!(std::fs::read(&dest).unwrap(), b"v1");
assert!(!tmp.exists(), "tmp must be consumed");
// Replace-existing.
std::fs::write(&tmp, b"v2").unwrap();
replace_file(&tmp, &dest).expect("replace");
assert_eq!(std::fs::read(&dest).unwrap(), b"v2");
assert!(!tmp.exists());
// Missing tmp → error, dest untouched.
let err = replace_file(&tmp, &dest).expect_err("missing tmp must fail");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert_eq!(std::fs::read(&dest).unwrap(), b"v2");
}
}
@@ -1,4 +1,5 @@
pub mod event_id; pub mod event_id;
pub mod fs;
pub mod kigi_home; pub mod kigi_home;
pub mod secure_file; pub mod secure_file;
pub mod tips; pub mod tips;
+35 -13
View File
@@ -109,21 +109,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
t = asset_triple t = asset_triple
); );
// Transient CDN hiccups (502/503/timeouts) are retried with backoff: a
// single flaky response must not kill a ~50-minute release build (it
// did — the v0.1.5 tag build failed on one 502). Genuine failures
// (404, offline) still error out with the offline-build hint.
let bytes: Vec<u8> = { let bytes: Vec<u8> = {
let resp = reqwest::blocking::get(&url).map_err(|e| { let mut last_err = String::new();
format!( let mut bytes = None;
"Failed to download ripgrep: {}\nSet KIGI_SHELL_BUNDLE_RG_PATH to a local rg for offline builds.", for (attempt, backoff_secs) in [0u64, 2, 8].into_iter().enumerate() {
e if backoff_secs > 0 {
) std::thread::sleep(std::time::Duration::from_secs(backoff_secs));
})?;
if !resp.status().is_success() {
return Err(format!(
"HTTP {} downloading ripgrep. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.",
resp.status()
)
.into());
} }
resp.bytes()?.to_vec() match reqwest::blocking::get(&url) {
Ok(resp) if resp.status().is_success() => match resp.bytes() {
Ok(b) => {
bytes = Some(b.to_vec());
break;
}
Err(e) => last_err = format!("reading ripgrep body: {e}"),
},
Ok(resp) => {
let status = resp.status();
last_err = format!("HTTP {status} downloading ripgrep");
// Only server-side/transient statuses are worth retrying.
if !(status.is_server_error() || status.as_u16() == 429) {
break;
}
}
Err(e) => last_err = format!("Failed to download ripgrep: {e}"),
}
println!(
"cargo:warning=ripgrep download attempt {} failed: {last_err}",
attempt + 1
);
}
bytes.ok_or_else(|| {
format!("{last_err}. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.")
})?
}; };
let gz = flate2::read::GzDecoder::new(&bytes[..]); let gz = flate2::read::GzDecoder::new(&bytes[..]);
@@ -170,9 +170,7 @@ fn write_data_file_atomic(
let json = serde_json::to_string_pretty(sessions) let json = serde_json::to_string_pretty(sessions)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(tmp_path, json.as_bytes())?; fs::write(tmp_path, json.as_bytes())?;
fs::rename(tmp_path, data_path).inspect_err(|_| { crate::util::fs::replace_file(tmp_path, data_path)
let _ = fs::remove_file(tmp_path);
})
} }
fn is_pid_alive(pid: u32) -> bool { fn is_pid_alive(pid: u32) -> bool {
+28 -5
View File
@@ -1881,12 +1881,17 @@ impl Config {
.default(true) .default(true)
.resolve() .resolve()
} }
/// Graph mode (`/graph`) master switch. Default OFF — gray-released via /// Graph mode (`/graph`) master switch. Default ON in the binary: the
/// `KIGI_GRAPH=1` only (plan.md G0 gate). Graph mode additionally /// README ships graph engineering enabled for every install, but the
/// requires the goal harness (nodes execute as goals), enforced at /// old `default(false)` delegated enablement to installer env plumbing
/// availability time, not here. /// (`install.sh` shell-rc export vs `install.ps1` registry write) — and
/// Windows terminals don't pick up freshly-written registry env, so
/// `/graph` went "missing on Windows". The product default lives HERE,
/// not in installers. `KIGI_GRAPH=0` is the off-switch. Graph mode
/// additionally requires the goal harness (nodes execute as goals),
/// enforced at availability time, not here.
pub(crate) fn resolve_graph(&self) -> Resolved<bool> { pub(crate) fn resolve_graph(&self) -> Resolved<bool> {
BoolFlag::env("KIGI_GRAPH").default(false).resolve() BoolFlag::env("KIGI_GRAPH").default(true).resolve()
} }
/// Max graph nodes running concurrently (`KIGI_GRAPH_CONCURRENCY`). /// Max graph nodes running concurrently (`KIGI_GRAPH_CONCURRENCY`).
/// 1 = serial (G0-identical); clamped to [1, 8] — the coordinator has /// 1 = serial (G0-identical); clamped to [1, 8] — the coordinator has
@@ -4337,6 +4342,24 @@ mod tests {
/// Catalog key of the bundled fallback default (`default_models.json`): /// Catalog key of the bundled fallback default (`default_models.json`):
/// `{platform_id}/{model_id}` for `crate::models::default_model()`. /// `{platform_id}/{model_id}` for `crate::models::default_model()`.
const BUNDLED_DEFAULT_KEY: &str = "kimi-code/kimi-for-coding"; const BUNDLED_DEFAULT_KEY: &str = "kimi-code/kimi-for-coding";
/// `/graph` ships ON by default: the G0 gray release (`KIGI_GRAPH=1`
/// only) made the command exist solely on machines with the dev env
/// var — which read as "missing on Windows". A fresh install with no
/// env must resolve `true`; `KIGI_GRAPH=0` stays the off-switch.
#[test]
#[serial]
fn graph_defaults_on_and_env_zero_disables() {
let cfg = Config::default();
{
let _unset = EnvGuard::unset("KIGI_GRAPH");
assert!(cfg.resolve_graph().value, "fresh install must offer /graph");
}
{
let _off = EnvGuard::set("KIGI_GRAPH", "0");
assert!(!cfg.resolve_graph().value, "KIGI_GRAPH=0 must disable");
}
}
#[test] #[test]
fn main_cli_tools_override_preserves_profile_injection_policy() { fn main_cli_tools_override_preserves_profile_injection_policy() {
let overrides = CliAgentOverrides { let overrides = CliAgentOverrides {
+38 -10
View File
@@ -1637,16 +1637,31 @@ impl ModelsCacheManager {
} }
} }
/// Sync; see `load_fresh` note. /// Unique tmp suffix (PID + nanos) so concurrent writers never share an
/// inode (mirrors `util::config::persist`).
fn tmp_path(&self) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
self.path
.with_extension(format!("json.tmp.{}.{}", std::process::id(), nanos))
}
/// Sync; see `load_fresh` note. Best-effort, but NEVER silent: a failed
/// cache write leaves a stale catalog on disk, which on Windows (sharing
/// violations) previously diverged picker behavior with zero trace.
fn atomic_write(&self, cache: &ModelsCache) { fn atomic_write(&self, cache: &ModelsCache) {
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
let tmp = self.path.with_extension("json.tmp"); let tmp = self.tmp_path();
if let Ok(json) = serde_json::to_vec_pretty(cache) let result = serde_json::to_vec_pretty(cache)
&& std::fs::write(&tmp, &json).is_ok() .map_err(std::io::Error::other)
{ .and_then(|json| std::fs::write(&tmp, &json))
let _ = std::fs::rename(&tmp, &self.path); .and_then(|()| crate::util::fs::replace_file(&tmp, &self.path));
if let Err(e) = result {
tracing::warn!(error = %e, path = %self.path.display(), "models cache write failed");
} }
} }
@@ -1654,12 +1669,25 @@ impl ModelsCacheManager {
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
let _ = tokio::fs::create_dir_all(parent).await; let _ = tokio::fs::create_dir_all(parent).await;
} }
let tmp = self.path.with_extension("json.tmp"); let tmp = self.tmp_path();
let Ok(json) = serde_json::to_vec_pretty(cache) else { let json = match serde_json::to_vec_pretty(cache) {
Ok(json) => json,
Err(e) => {
tracing::warn!(error = %e, "models cache serialize failed");
return; return;
}
}; };
if tokio::fs::write(&tmp, &json).await.is_ok() { let result = match tokio::fs::write(&tmp, &json).await {
let _ = tokio::fs::rename(&tmp, &self.path).await; Ok(()) => {
let dest = self.path.clone();
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
.await
.unwrap_or_else(|e| Err(std::io::Error::other(e)))
}
Err(e) => Err(e),
};
if let Err(e) = result {
tracing::warn!(error = %e, path = %self.path.display(), "models cache write failed");
} }
} }
} }
@@ -430,17 +430,12 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
Ok(()) Ok(())
} }
/// Atomic write: tmp + rename. Unix `rename(2)` replaces atomically; /// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`,
/// Windows `rename` requires removing the target first. /// which this site's inline delete-first pattern graduated into).
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> { fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id())); let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
write_store_to(&tmp, auth_store)?; write_store_to(&tmp, auth_store)?;
#[cfg(windows)] crate::util::fs::replace_file(&tmp, auth_file)
{
let _ = std::fs::remove_file(auth_file);
}
std::fs::rename(&tmp, auth_file)?;
Ok(())
} }
/// Non-atomic fallback: truncate and rewrite `auth.json` in place. /// Non-atomic fallback: truncate and rewrite `auth.json` in place.
@@ -670,10 +670,7 @@ fn write_import_marker(config_path: &Path) -> anyhow::Result<()> {
let _ = std::fs::remove_file(&tmp); let _ = std::fs::remove_file(&tmp);
return Err(e.into()); return Err(e.into());
} }
if let Err(e) = std::fs::rename(&tmp, config_path) { crate::util::fs::replace_file(&tmp, config_path)?;
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
Ok(()) Ok(())
} }
@@ -854,7 +851,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
std::fs::write(&tmp, &toml_str)?; std::fs::write(&tmp, &toml_str)?;
std::fs::rename(&tmp, config_path)?; crate::util::fs::replace_file(&tmp, config_path)?;
info!( info!(
path = %config_path.display(), path = %config_path.display(),
count, count,
@@ -1204,7 +1201,7 @@ fn apply_hooks_to_dir(hooks_dir: &Path, items: &[ImportableItem]) -> anyhow::Res
let json_str = serde_json::to_string_pretty(&root)?; let json_str = serde_json::to_string_pretty(&root)?;
let tmp = target.with_extension("json.tmp"); let tmp = target.with_extension("json.tmp");
std::fs::write(&tmp, &json_str)?; std::fs::write(&tmp, &json_str)?;
std::fs::rename(&tmp, &target)?; crate::util::fs::replace_file(&tmp, &target)?;
info!( info!(
path = %target.display(), path = %target.display(),
count, count,
@@ -90,7 +90,7 @@ pub fn save_import_state(state: &ImportState) -> std::io::Result<()> {
// `claude_import_state.json.tmp` (the last extension is replaced). // `claude_import_state.json.tmp` (the last extension is replaced).
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &json)?; std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path)?; crate::util::fs::replace_file(&tmp, &path)?;
Ok(()) Ok(())
} }
+1 -4
View File
@@ -528,10 +528,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
let _ = std::fs::remove_file(&tmp); let _ = std::fs::remove_file(&tmp);
return Err(e.into()); return Err(e.into());
} }
if let Err(e) = std::fs::rename(&tmp, &config_path) { crate::util::fs::replace_file(&tmp, &config_path)?;
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
info!( info!(
path = %config_path.display(), path = %config_path.display(),
added = applied.total_added(), added = applied.total_added(),
@@ -1147,7 +1147,7 @@ fn persist_chat_history_jsonl_sync(session_info: &SessionInfo, conversation: &[C
buf.push(b'\n'); buf.push(b'\n');
} }
std::fs::File::create(&tmp_path)?.write_all(&buf)?; std::fs::File::create(&tmp_path)?.write_all(&buf)?;
std::fs::rename(&tmp_path, &final_path)?; crate::util::fs::replace_file(&tmp_path, &final_path)?;
Ok(()) Ok(())
})(); })();
if let Err(e) = result { if let Err(e) = result {
@@ -594,10 +594,10 @@ async fn write_patch_file_atomic(path: &Path, body: &str) -> std::io::Result<()>
.unwrap_or("goal-classifier.patch"); .unwrap_or("goal-classifier.patch");
let tmp = dir.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7())); let tmp = dir.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()));
tokio::fs::write(&tmp, body).await?; tokio::fs::write(&tmp, body).await?;
if let Err(err) = tokio::fs::rename(&tmp, path).await { let dest = path.to_path_buf();
let _ = tokio::fs::remove_file(&tmp).await; tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
return Err(err); .await
} .map_err(std::io::Error::other)??;
Ok(()) Ok(())
} }
@@ -895,7 +895,8 @@ impl GoalTracker {
}; };
let dest = goal_dir.join(name); let dest = goal_dir.join(name);
let _ = std::fs::create_dir_all(&goal_dir); let _ = std::fs::create_dir_all(&goal_dir);
if std::fs::rename(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok() { if crate::util::fs::replace_file(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok()
{
append_skeptic_reports(&scratch_root, &dest); append_skeptic_reports(&scratch_root, &dest);
o.last_classifier_details_path = Some(dest.to_string_lossy().into_owned()); o.last_classifier_details_path = Some(dest.to_string_lossy().into_owned());
} }
@@ -120,7 +120,7 @@ pub fn project(dir: &Path, state: &GraphOrchestration) -> std::io::Result<()> {
f.write_all(&buf)?; f.write_all(&buf)?;
f.sync_all()?; f.sync_all()?;
} }
std::fs::rename(&tmp, &target) crate::util::fs::replace_file(&tmp, &target)
} }
/// Load the projected graph, `Ok(None)` when absent. Malformed content /// Load the projected graph, `Ok(None)` when absent. Malformed content
@@ -98,7 +98,7 @@ pub fn truncate_if_needed(cwd: &str) -> io::Result<()> {
} }
} }
std::fs::rename(temp_path, path)?; crate::util::fs::replace_file(&temp_path, &path)?;
Ok(()) Ok(())
} }
@@ -13,6 +13,16 @@ use std::fs::OpenOptions;
use std::io::{self, Read}; use std::io::{self, Read};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
/// Commit `tmp` over `target` with the shared Windows-safe replace
/// (`util::fs::replace_file`): a bare async rename silently lost session
/// state — including the switched model — on Windows whenever AV/indexer
/// held the destination open.
async fn replace_file_async(tmp: PathBuf, target: PathBuf) -> io::Result<()> {
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &target))
.await
.unwrap_or_else(|e| Err(io::Error::other(e)))
}
/// How the adapter resolves the session directory on disk. /// How the adapter resolves the session directory on disk.
/// ///
/// - `FromRoot` (default): computes `{root}/sessions/{urlencoded(cwd)}/{session_id}/` /// - `FromRoot` (default): computes `{root}/sessions/{urlencoded(cwd)}/{session_id}/`
@@ -289,7 +299,7 @@ impl JsonlStorageAdapter {
} }
let tmp = path.with_extension("jsonl.tmp"); let tmp = path.with_extension("jsonl.tmp");
tokio::fs::write(&tmp, &content).await?; tokio::fs::write(&tmp, &content).await?;
tokio::fs::rename(&tmp, &path).await replace_file_async(tmp, path).await
} }
fn read_jsonl<T: serde::de::DeserializeOwned>(&self, path: PathBuf) -> io::Result<Vec<T>> { fn read_jsonl<T: serde::de::DeserializeOwned>(&self, path: PathBuf) -> io::Result<Vec<T>> {
if !path.exists() { if !path.exists() {
@@ -378,7 +388,7 @@ impl JsonlStorageAdapter {
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = summary_path.with_extension("json.tmp"); let tmp = summary_path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes)?; std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, &summary_path) crate::util::fs::replace_file(&tmp, &summary_path)
} }
fn read_summary_sync(&self, info: &Info) -> io::Result<Summary> { fn read_summary_sync(&self, info: &Info) -> io::Result<Summary> {
let path = self.summary_file(info); let path = self.summary_file(info);
@@ -1057,7 +1067,7 @@ impl StorageAdapter for JsonlStorageAdapter {
let target = self.plan_mode_state_file(info); let target = self.plan_mode_state_file(info);
let tmp = target.with_extension("json.tmp"); let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?; tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await replace_file_async(tmp, target).await
} }
async fn write_signals( async fn write_signals(
&self, &self,
@@ -1069,7 +1079,7 @@ impl StorageAdapter for JsonlStorageAdapter {
let target = self.signals_file(info); let target = self.signals_file(info);
let tmp = target.with_extension("json.tmp"); let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, signals_json).await?; tokio::fs::write(&tmp, signals_json).await?;
tokio::fs::rename(&tmp, &target).await replace_file_async(tmp, target).await
} }
async fn write_announcement_state( async fn write_announcement_state(
&self, &self,
@@ -1081,7 +1091,7 @@ impl StorageAdapter for JsonlStorageAdapter {
let target = self.announcement_state_file(info); let target = self.announcement_state_file(info);
let tmp = target.with_extension("json.tmp"); let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?; tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await replace_file_async(tmp, target).await
} }
async fn write_goal_mode_state( async fn write_goal_mode_state(
&self, &self,
@@ -1096,7 +1106,7 @@ impl StorageAdapter for JsonlStorageAdapter {
} }
let tmp = target.with_extension("json.tmp"); let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?; tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await replace_file_async(tmp, target).await
} }
async fn write_graph_mode_state( async fn write_graph_mode_state(
&self, &self,
@@ -1119,7 +1129,7 @@ impl StorageAdapter for JsonlStorageAdapter {
} }
let tmp = target.with_extension("json.tmp"); let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?; tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await replace_file_async(tmp, target).await
} }
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> { async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
let summary = self.read_summary_sync(info)?; let summary = self.read_summary_sync(info)?;
@@ -230,7 +230,10 @@ fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = summary_path.with_extension("json.tmp"); let tmp = summary_path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes)?; std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, summary_path) // Windows-safe replace: this is the write that persists a session's
// CURRENT MODEL — a bare rename made a switched model silently revert
// on resume whenever AV/indexer held summary.json open on Windows.
crate::util::fs::replace_file(&tmp, summary_path)
} }
#[cfg(test)] #[cfg(test)]
@@ -114,9 +114,7 @@ fn dismiss_campaign_ids_at(
let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed); let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce)); let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce));
std::fs::write(&tmp, &json)?; std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path).inspect_err(|_| { crate::util::fs::replace_file(&tmp, &path)
let _ = std::fs::remove_file(&tmp);
})
} }
/// `KIGI_CAMPAIGNS_OVERRIDE` JSON array replaces all sources (`[]` = none; beats /// `KIGI_CAMPAIGNS_OVERRIDE` JSON array replaces all sources (`[]` = none; beats
@@ -367,7 +367,9 @@ pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String
let _ = tokio::fs::create_dir_all(parent).await; let _ = tokio::fs::create_dir_all(parent).await;
} }
tokio::fs::write(&tmp, &toml_str).await?; tokio::fs::write(&tmp, &toml_str).await?;
tokio::fs::rename(&tmp, &path).await?; tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
.await
.map_err(std::io::Error::other)??;
Ok(()) Ok(())
} }
@@ -419,7 +421,9 @@ pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result
let _ = tokio::fs::create_dir_all(parent).await; let _ = tokio::fs::create_dir_all(parent).await;
} }
tokio::fs::write(&tmp, &toml_str).await?; tokio::fs::write(&tmp, &toml_str).await?;
tokio::fs::rename(&tmp, &path).await?; tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
.await
.map_err(std::io::Error::other)??;
Ok(()) Ok(())
} }
@@ -476,7 +480,10 @@ pub async fn save_mcp_server_config_at(
let _ = tokio::fs::create_dir_all(parent).await; let _ = tokio::fs::create_dir_all(parent).await;
} }
tokio::fs::write(&tmp, &toml_str).await?; tokio::fs::write(&tmp, &toml_str).await?;
tokio::fs::rename(&tmp, &path).await?; let dest = path.to_path_buf();
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
.await
.map_err(std::io::Error::other)??;
Ok(()) Ok(())
} }
@@ -553,7 +560,12 @@ pub async fn delete_mcp_server_config_at(
let _ = tokio::fs::create_dir_all(parent).await; let _ = tokio::fs::create_dir_all(parent).await;
} }
tokio::fs::write(&tmp, &toml_str).await?; tokio::fs::write(&tmp, &toml_str).await?;
tokio::fs::rename(&tmp, &path).await?; {
let dest = path.to_path_buf();
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
.await
.map_err(std::io::Error::other)??;
}
// Clean up OAuth credentials for the deleted server. // Clean up OAuth credentials for the deleted server.
if let Ok(mut cred_store) = kigi_mcp::credentials::McpCredentialStore::load_default() { if let Ok(mut cred_store) = kigi_mcp::credentials::McpCredentialStore::load_default() {
@@ -88,7 +88,12 @@ pub async fn save_config(config: &Config) -> Result<()> {
} }
let _ = prior_mode; let _ = prior_mode;
tokio::fs::rename(&tmp, &path).await?; // Windows-safe replace (delete-first + retry on sharing violations) —
// a bare rename made `/model` persistence silently fail on Windows
// whenever AV/indexer/cloud-sync held config.toml open.
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
.await
.map_err(|e| anyhow::anyhow!("config replace task: {e}"))??;
Ok(()) Ok(())
} }
@@ -138,11 +143,8 @@ pub(crate) fn atomic_write_string(path: &std::path::Path, content: &str) -> std:
} }
let _ = prior_mode; let _ = prior_mode;
if let Err(e) = std::fs::rename(&tmp, path) { // Windows-safe replace; cleans up the tmp file on failure itself.
let _ = std::fs::remove_file(&tmp); crate::util::fs::replace_file(&tmp, path)
return Err(e);
}
Ok(())
} }
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is /// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
@@ -472,7 +472,12 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
"\n", "\n",
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#, r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
"\n", "\n",
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy kigi reasoning","encrypted":"ENC_BLOB_xyz","id":"rs_kigibuild_legacy"},"model_id":"kigi"}"#, // model_id matches the test client's request model: this test
// covers the SAME-MODEL continuation (the byte-stable
// SGLang-prefix path). A mismatched model_id is the provenance
// gate's territory (`transform_items_for_responses`) and drops
// the reasoning by design.
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy kigi reasoning","encrypted":"ENC_BLOB_xyz","id":"rs_kigibuild_legacy"},"model_id":"test-model"}"#,
"\n", "\n",
), ),
) )
@@ -535,13 +540,20 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
/// Upgrade path, Anthropic Messages API: a legacy session whose assistant /// Upgrade path, Anthropic Messages API: a legacy session whose assistant
/// carries inline `reasoning: {text, encrypted, id}` (text = thinking, /// carries inline `reasoning: {text, encrypted, id}` (text = thinking,
/// encrypted = signature) must, on load, reconstruct a sibling Reasoning /// encrypted = signature) must, on load, reconstruct a sibling Reasoning
/// item that emits a Anthropic Messages `thinking` content block (with `thinking` /// item — and when that turn is the ACTIVE tool-use continuation, its
/// + `signature`) on the outgoing `/v1/messages` request. /// `thinking` block (text + signature) must reach the outgoing
/// `/v1/messages` request verbatim.
///
/// Outside an active tool loop the block must be STRIPPED: Anthropic
/// validates every replayed signature (model-bound), so replaying stale
/// thinking is exactly what 400'd with "Invalid `signature` in `thinking`
/// block" after cross-model histories (see `prune_replayed_thinking`).
#[tokio::test] #[tokio::test]
async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() { async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loop() {
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl. Anthropic Messages // 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl whose
// thinking blocks never carried an id (stream/messages.rs sets // assistant turn issued a tool call (thinking blocks never carried an
// id=""), and the signature lives in `encrypted`. // id — stream/messages.rs sets id="" and the signature lives in
// `encrypted`). The pending tool_result makes this the active loop.
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
std::fs::write( std::fs::write(
dir.path().join("chat_history.jsonl"), dir.path().join("chat_history.jsonl"),
@@ -550,7 +562,9 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
"\n", "\n",
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#, r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
"\n", "\n",
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5"}"#, r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5","tool_calls":[{"id":"tc1","name":"read_file","arguments":"{}"}]}"#,
"\n",
r#"{"type":"tool_result","tool_call_id":"tc1","content":"file contents"}"#,
"\n", "\n",
), ),
) )
@@ -558,7 +572,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
// 2. Load + upgrade. // 2. Load + upgrade.
let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf()); let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf());
let mut items = adapter.load_chat_history_from_dir(dir.path()).unwrap(); let items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
assert!( assert!(
items items
.iter() .iter()
@@ -566,20 +580,18 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
"legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}" "legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}"
); );
// 3. Continue and send over the Messages API, capturing the body. // 3. Send the tool-loop continuation over the Messages API.
items.push(ConversationItem::user("q2"));
let server = MockInferenceServer::start().await.unwrap(); let server = MockInferenceServer::start().await.unwrap();
server.set_response("ok"); server.set_response("ok");
let client = create_test_client(&server.url(), ApiBackend::Messages); let client = create_test_client(&server.url(), ApiBackend::Messages);
let _ = client let _ = client
.conversation_collect(ConversationRequest::from_items(items)) .conversation_collect(ConversationRequest::from_items(items.clone()))
.await .await
.unwrap(); .unwrap();
// 4. The reconstructed reasoning must emit a Anthropic Messages `thinking` // 4. The active loop's reconstructed reasoning must emit an Anthropic
// content block carrying the thinking text + signature. // `thinking` content block carrying the thinking text + signature.
let body = server.request_bodies().pop().unwrap(); let body = server.request_bodies().pop().unwrap();
let messages = body.get("messages").unwrap().as_array().unwrap(); let messages = body.get("messages").unwrap().as_array().unwrap();
let thinking_block = messages let thinking_block = messages
@@ -593,7 +605,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
}) })
.find(|b| b.get("type").and_then(Value::as_str) == Some("thinking")) .find(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
.unwrap_or_else(|| { .unwrap_or_else(|| {
panic!("reconstructed reasoning must emit an Anthropic thinking block; messages: {messages:#?}") panic!("active-loop reasoning must emit an Anthropic thinking block; messages: {messages:#?}")
}); });
assert_eq!( assert_eq!(
thinking_block.get("thinking").and_then(Value::as_str), thinking_block.get("thinking").and_then(Value::as_str),
@@ -605,6 +617,25 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
Some("SIGNATURE_abc"), Some("SIGNATURE_abc"),
"signature (encrypted) preserved — required to reuse the thought server-side" "signature (encrypted) preserved — required to reuse the thought server-side"
); );
// 5. A follow-up user turn CLOSES the loop: the same history plus a new
// user message must replay NO thinking block at all.
let mut closed = items;
closed.push(ConversationItem::user("q2"));
let _ = client
.conversation_collect(ConversationRequest::from_items(closed))
.await
.unwrap();
let body = server.request_bodies().pop().unwrap();
let any_thinking = body["messages"].as_array().unwrap().iter().any(|m| {
m.get("content")
.and_then(Value::as_array)
.is_some_and(|c| c.iter().any(|b| b["type"] == "thinking"))
});
assert!(
!any_thinking,
"stale thinking must be stripped outside the active tool loop; body: {body:#?}"
);
} }
// ============================================================================ // ============================================================================
@@ -1443,3 +1474,79 @@ async fn test_chat_completions_backend_hits_chat_endpoint_not_responses() {
"Should NOT have called /v1/responses" "Should NOT have called /v1/responses"
); );
} }
/// ChatGPT/Codex backend body contract (`openai_codex = true`): the
/// `/codex/responses` endpoint rejects `role: system` input outright
/// (400 {"detail":"System messages are not allowed"}) — system content
/// must ride the top-level `instructions` field, and stateless reasoning
/// replay needs `include: ["reasoning.encrypted_content"]`. Ported from
/// the official Codex CLI + Pi's api/openai-codex-responses.ts, like the
/// identity headers.
#[tokio::test]
async fn codex_responses_body_hoists_system_into_instructions() {
let server = MockInferenceServer::start().await.unwrap();
server.set_response("ok");
let mut config = common::test_sampler_config(&server.url(), ApiBackend::Responses, &[]);
config.openai_codex = true;
let client = Client::new(config).unwrap();
let _ = client
.conversation_collect(ConversationRequest::from_items(vec![
ConversationItem::system("You are Kigi."),
ConversationItem::user("test"),
]))
.await
.unwrap();
let body = server.request_bodies().pop().unwrap();
let input = body["input"].as_array().unwrap();
assert!(
input
.iter()
.all(|i| i.get("role").and_then(Value::as_str) != Some("system")),
"codex backend must never receive system-role input: {body:#?}"
);
assert_eq!(
body["instructions"].as_str(),
Some("You are Kigi."),
"system prompt must ride the instructions field: {body:#?}"
);
assert_eq!(
body["include"],
serde_json::json!(["reasoning.encrypted_content"]),
"stateless reasoning replay requires the include: {body:#?}"
);
}
/// Control: the API-key `openai` Responses path (`openai_codex = false`)
/// stays byte-compatible — system-role input preserved, no codex fields.
#[tokio::test]
async fn plain_responses_body_keeps_system_role_input() {
let server = MockInferenceServer::start().await.unwrap();
server.set_response("ok");
let client = create_test_client(&server.url(), ApiBackend::Responses);
let _ = client
.conversation_collect(ConversationRequest::from_items(vec![
ConversationItem::system("You are Kigi."),
ConversationItem::user("test"),
]))
.await
.unwrap();
let body = server.request_bodies().pop().unwrap();
assert!(
body["input"]
.as_array()
.unwrap()
.iter()
.any(|i| i.get("role").and_then(Value::as_str) == Some("system")),
"api-key openai keeps system-role input: {body:#?}"
);
assert!(
body.get("instructions")
.map(|v| v.is_null())
.unwrap_or(true),
"no instructions hoist outside codex: {body:#?}"
);
}
@@ -3,7 +3,7 @@
use super::setters::{ use super::setters::{
pr13_effective_default, set_ask_user_question_timeout_enabled_inner, set_auto_dark_theme_inner, pr13_effective_default, set_ask_user_question_timeout_enabled_inner, set_auto_dark_theme_inner,
set_auto_light_theme_inner, set_auto_update_inner, set_collapsed_edit_blocks_inner, set_auto_light_theme_inner, set_auto_update_inner, set_collapsed_edit_blocks_inner,
set_compact_mode, set_compact_mode_inner, set_contextual_hint_inner, set_default_model_inner, set_compact_mode, set_compact_mode_inner, set_contextual_hint_inner,
set_default_selected_permission_inner, set_display_refresh_auto_cadence_inner, set_default_selected_permission_inner, set_display_refresh_auto_cadence_inner,
set_fork_secondary_model_inner, set_group_tool_verbs_inner, set_hunk_tracker_mode_inner, set_fork_secondary_model_inner, set_group_tool_verbs_inner, set_hunk_tracker_mode_inner,
set_invert_scroll_inner, set_keep_text_selection_inner, set_max_thoughts_width_inner, set_invert_scroll_inner, set_keep_text_selection_inner, set_max_thoughts_width_inner,
@@ -844,7 +844,7 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
rollback_value: &crate::settings::SettingValue, rollback_value: &crate::settings::SettingValue,
) -> Vec<Effect> { ) -> Vec<Effect> {
use crate::settings::SettingValue; use crate::settings::SettingValue;
let mut companion_effects: Vec<Effect> = Vec::new(); let companion_effects: Vec<Effect> = Vec::new();
match (key, rollback_value) { match (key, rollback_value) {
("compact_mode", SettingValue::Bool(b)) => set_compact_mode_inner(app, *b), ("compact_mode", SettingValue::Bool(b)) => set_compact_mode_inner(app, *b),
("show_timestamps", SettingValue::Bool(b)) => set_timestamps_inner(app, *b), ("show_timestamps", SettingValue::Bool(b)) => set_timestamps_inner(app, *b),
@@ -934,65 +934,24 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
// other rollback arms must not clobber it from the global canonical. // other rollback arms must not clobber it from the global canonical.
sync_active_auto_flag(app); sync_active_auto_flag(app);
} }
// default_model: best-effort rollback. If the prior model no // default_model: deliberately NO revert. The session switch already
// longer resolves, leave optimistic value + log. // succeeded independently (its own failure path reports via
("default_model", SettingValue::String(s)) => { // `handle_switch_model_complete`); this arm fires when only the
if s.is_empty() { // DISK write of the next-launch default failed — which must not
// undo a working switch. Same policy as `PersistPreferredModel`
// ("still active for this session"). Regression: reverting here
// (plus a reverse SwitchModel) made every picker selection appear
// to not take on Windows, where AV/indexer file locks routinely
// fail config.toml persists.
("default_model", SettingValue::String(prior)) => {
tracing::warn!( tracing::warn!(
target: "settings", target: "settings",
key = "default_model", key = "default_model",
"rollback to empty string requested but no \ prior = %prior,
'clear current model' API exists leaving live \ "default-model persist failed; keeping the live session's \
state at optimistic value (next session reload \ model (the switch succeeded) only the next-launch \
will resolve via shell default-resolution chain)", default is unsaved",
); );
} else {
// Resolve the prior model ID back to a ModelId
// and call the typed inner. If resolution fails
// (catalog changed mid-flight), log + leave
// optimistic.
let (resolved, session_id) = if let ActiveView::Agent(aid) = app.active_view
&& let Some(agent) = app.agents.get(&aid)
{
(
agent.session.models.resolve_by_name_or_id(s),
agent.session.session_id.clone(),
)
} else {
(None, None)
};
match resolved {
Some(id) => {
let _ = set_default_model_inner(app, &id);
// Emit reverse SwitchModel so the ACP session
// matches the rolled-back pager mirror.
if let ActiveView::Agent(aid) = app.active_view
&& let Some(sid) = session_id
{
if let Some(agent) = app.agents.get_mut(&aid) {
agent.session.model_switch_pending = true;
}
companion_effects.push(Effect::SwitchModel {
agent_id: aid,
session_id: sid,
model_id: id,
effort: None,
prev_model_id: None,
});
}
}
None => {
tracing::warn!(
target: "settings",
key = "default_model",
value = %s,
"rollback model id no longer resolves in catalog — \
in-memory state stays at optimistic value; ACP session \
may diverge from pager mirror until next setter dispatch",
);
}
}
}
} }
// max_thoughts_width: direct inner call. // max_thoughts_width: direct inner call.
("max_thoughts_width", SettingValue::Int(i)) => set_max_thoughts_width_inner(app, *i), ("max_thoughts_width", SettingValue::Int(i)) => set_max_thoughts_width_inner(app, *i),
@@ -1592,6 +1592,78 @@ fn rollback_reverts_thread_local_cache_too() {
.unwrap(); .unwrap();
} }
/// A default_model persist failure must NOT revert the live session's
/// model. The switch already succeeded in the session (its own failure
/// path reports separately); a DISK write failure only means the default
/// won't stick for the next launch — same policy as PersistPreferredModel
/// ("still active for this session"). Regression: the rollback arm
/// re-showed the ORIGINAL model and issued a reverse SwitchModel, which
/// on Windows (persist failures from AV/indexer file locks) made every
/// picker selection appear to not take.
#[test]
fn default_model_persist_failure_keeps_live_model() {
use crate::settings::SettingValue;
let mut app = test_app_with_agent();
let id = AgentId(0);
for (mid, name) in [("old-model", "Old Model"), ("new-model", "New Model")] {
let model_id = acp::ModelId::new(std::sync::Arc::from(mid));
let info = acp::ModelInfo::new(model_id.clone(), name.to_string());
app.agents
.get_mut(&id)
.unwrap()
.session
.models
.available
.insert(model_id.clone(), info.clone());
app.models.available.insert(model_id, info);
}
// User picked the new model; optimistic update applied.
let _ = dispatch(
Action::SetDefaultModel(acp::ModelId::new(std::sync::Arc::from("new-model"))),
&mut app,
);
assert_eq!(
app.agents[&id]
.session
.models
.current
.as_ref()
.map(|m| m.0.as_ref()),
Some("new-model"),
);
// Disk persist fails (the Windows sharing-violation shape).
let effects = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "default_model",
rollback_value: SettingValue::String("Old Model".into()),
error: "Access is denied. (os error 5)".into(),
}),
&mut app,
);
assert_eq!(
app.agents[&id]
.session
.models
.current
.as_ref()
.map(|m| m.0.as_ref()),
Some("new-model"),
"a persist failure must not revert the live session's model"
);
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::SwitchModel { .. })),
"no reverse SwitchModel may be issued for a disk-persist failure"
);
assert!(
read_toast(&app).contains("Could not save"),
"the save failure must still be surfaced"
);
}
/// `set_yolo_mode_inner` is the backstop: even a (stale) rollback /// `set_yolo_mode_inner` is the backstop: even a (stale) rollback
/// value of "always-approve" must not re-enable yolo under the pin. /// value of "always-approve" must not re-enable yolo under the pin.
#[test] #[test]
+7 -9
View File
@@ -149,15 +149,13 @@ try {
Write-Host "Run 'kigi' to get started." Write-Host "Run 'kigi' to get started."
} }
# Graph engineering ships enabled by default. Respect an explicit # Graph engineering is enabled by default IN THE BINARY (resolve_graph
# user choice: only set the variable when it is not already defined # defaults true) — no environment plumbing needed. The installer used
# (so a persisted opt-out of "0" survives reinstalls). # to persist KIGI_GRAPH=1 into the User registry env, but running
$Graph = [Environment]::GetEnvironmentVariable("KIGI_GRAPH", "User") # terminals (and new tabs of an open Windows Terminal) never pick up
if ($null -eq $Graph -or $Graph -eq "") { # freshly-written registry variables, which made /graph "missing on
[Environment]::SetEnvironmentVariable("KIGI_GRAPH", "1", "User") # Windows" while the shell-rc path worked on macOS/Linux. Opt out any
Write-Host "Enabled graph engineering (KIGI_GRAPH=1)." # time with: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')
Write-Host "Disable: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')"
}
} finally { } finally {
Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
} }
+4 -8
View File
@@ -209,7 +209,6 @@ case "${SHELL:-}" in
*/zsh) */zsh)
RC_FILE="${ZDOTDIR:-$HOME}/.zshrc" RC_FILE="${ZDOTDIR:-$HOME}/.zshrc"
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\"" PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
GRAPH_LINE="export KIGI_GRAPH=1"
;; ;;
*/bash) */bash)
# macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc. # macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc.
@@ -219,7 +218,6 @@ case "${SHELL:-}" in
RC_FILE="$HOME/.bashrc" RC_FILE="$HOME/.bashrc"
fi fi
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\"" PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
GRAPH_LINE="export KIGI_GRAPH=1"
;; ;;
*/fish) */fish)
# fish_add_path in config.fish is fish's own idempotent way # fish_add_path in config.fish is fish's own idempotent way
@@ -228,12 +226,10 @@ case "${SHELL:-}" in
mkdir -p "$FISH_CONF_DIR" mkdir -p "$FISH_CONF_DIR"
RC_FILE="$FISH_CONF_DIR/config.fish" RC_FILE="$FISH_CONF_DIR/config.fish"
PATH_LINE="fish_add_path $BIN_DIR" PATH_LINE="fish_add_path $BIN_DIR"
GRAPH_LINE="set -gx KIGI_GRAPH 1"
;; ;;
*) *)
RC_FILE="$HOME/.profile" RC_FILE="$HOME/.profile"
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\"" PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
GRAPH_LINE="export KIGI_GRAPH=1"
;; ;;
esac esac
@@ -247,8 +243,8 @@ case ":$PATH:" in
;; ;;
esac esac
# Graph engineering ships enabled by default. The KIGI_GRAPH guard makes # Graph engineering is enabled by default IN THE BINARY (resolve_graph
# this idempotent AND respects an explicit user opt-out (an existing # defaults true) — the installer no longer writes KIGI_GRAPH=1 into shell
# `export KIGI_GRAPH=0` line is left untouched). Disable any time with: # rc files (per-shell env plumbing was fragile and diverged per platform).
# Disable any time with:
# echo 'export KIGI_GRAPH=0' >> <your shell rc> # echo 'export KIGI_GRAPH=0' >> <your shell rc>
persist_line "$RC_FILE" "$GRAPH_LINE" "KIGI_GRAPH" "graph engineering (KIGI_GRAPH=1)"