11 Commits
Author SHA1 Message Date
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 1008 additions and 243 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 }}
+31 -7
View File
@@ -32,9 +32,19 @@ import) or any `KIMI_*` env var.
- **Observability is local**: `kigi-log` (unified session log, `--debug`
firehose, subsystem file logs, opt-in instrumentation) writes under
`~/.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
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
@@ -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
`gn-final` verification node depending on every planner node.
- Feature flag `KIGI_GRAPH=1` (default off); availability additionally
requires the goal harness (`BuiltinGate::Graph`).
- Enabled by default (`KIGI_GRAPH=0` is the off-switch; the G0 gray
release is over); availability additionally requires the goal harness
(`BuiltinGate::Graph`).
- Key modules (kigi-shell): `session/graph_tracker.rs` (pure state
machine; reuses `GoalStatus`/`GoalPhase`/`GoalPauseReason`),
`session/graph_plan.rs` (planner-JSON contract + validation + fnv id
@@ -209,7 +220,14 @@ edges stay deterministic Rust. The harness appends a terminal
system prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max
only), so API-key `anthropic`/`minimax` Messages requests stay
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".
- `openai-codex` (ChatGPT Plus/Pro, `scope_key oauth/openai-codex`, port
1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`,
client `app_EMoam…`, scope `openid profile email offline_access`, the 3
@@ -226,9 +244,15 @@ edges stay deterministic Rust. The harness appends a terminal
`PlatformId::sends_codex_responses_headers()`): headers
`chatgpt-account-id` (per-request from the JWT), `originator codex_cli_rs`,
`OpenAI-Beta responses=experimental`, a codex `User-Agent`; `store:false`
is the shared Responses default. API-key `openai` Responses requests carry
NONE of this (byte-identical). `reasoning.effort` carries the thinking
level (incl. the codex-only `ultra`). NO websocket, NO base_instructions.
is the shared Responses default. BODY adaptation
(`adapt_body_for_codex_backend`, same gate): the backend 400s
`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` →
`openai_codex_wire_models`, mapped through the SAME
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
Generated
+62 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"async-openai",
"async-stream",
@@ -6154,7 +6154,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6171,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"chrono",
@@ -6192,7 +6192,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6230,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6367,7 +6367,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6392,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6403,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6418,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6426,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6457,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.4"
version = "0.1.6"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6472,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6500,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6577,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6590,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.4"
version = "0.1.6"
[[package]]
name = "kigi-tty-utils"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6604,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6691,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6720,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6806,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"base64",
"chrono",
@@ -8840,7 +8840,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8858,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.4"
version = "0.1.6"
edition = "2024"
license = "Apache-2.0"
@@ -1183,6 +1183,9 @@ impl SamplingClient {
// old raw_output machinery.
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
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 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_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`
// (check disabled) sends no header and does no peek work per event.
let doom_loop = self
@@ -3201,6 +3201,8 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
flush_assistant(&mut pending_assistant, &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
if let Some(last) = system_blocks.last_mut() {
last.cache_control = Some(CacheControl {
@@ -3290,6 +3292,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`.
///
/// Note: Anthropic `Thinking` blocks are dropped here because this `From`
@@ -5332,6 +5402,160 @@ mod tests {
/// messages while setting top-level `thinking: null` — the Messages API
/// rejects this with a 400. Verify that stripped reasoning produces a
/// 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]
fn test_btw_stripped_reasoning_produces_no_thinking_blocks() {
// Simulate a conversation where the model responded with thinking.
@@ -1041,6 +1041,71 @@ 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.
///
/// 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);
}
}
}
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
/// (`max`): remove it so response deserialization succeeds. The turn's
/// canonical effort lives in the session sampling config regardless; only
@@ -1551,6 +1616,66 @@ mod tests {
use super::*;
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);
}
/// 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
/// answer verbatim with no thinking — byte-identical to the pre-change
/// 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 fs;
pub mod kigi_home;
pub mod secure_file;
pub mod tips;
+35 -13
View File
@@ -109,21 +109,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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 resp = reqwest::blocking::get(&url).map_err(|e| {
format!(
"Failed to download ripgrep: {}\nSet KIGI_SHELL_BUNDLE_RG_PATH to a local rg for offline builds.",
e
)
})?;
if !resp.status().is_success() {
return Err(format!(
"HTTP {} downloading ripgrep. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.",
resp.status()
)
.into());
let mut last_err = String::new();
let mut bytes = None;
for (attempt, backoff_secs) in [0u64, 2, 8].into_iter().enumerate() {
if backoff_secs > 0 {
std::thread::sleep(std::time::Duration::from_secs(backoff_secs));
}
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
);
}
resp.bytes()?.to_vec()
bytes.ok_or_else(|| {
format!("{last_err}. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.")
})?
};
let gz = flate2::read::GzDecoder::new(&bytes[..]);
@@ -170,9 +170,7 @@ fn write_data_file_atomic(
let json = serde_json::to_string_pretty(sessions)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(tmp_path, json.as_bytes())?;
fs::rename(tmp_path, data_path).inspect_err(|_| {
let _ = fs::remove_file(tmp_path);
})
crate::util::fs::replace_file(tmp_path, data_path)
}
fn is_pid_alive(pid: u32) -> bool {
+28 -5
View File
@@ -1881,12 +1881,17 @@ impl Config {
.default(true)
.resolve()
}
/// Graph mode (`/graph`) master switch. Default OFF — gray-released via
/// `KIGI_GRAPH=1` only (plan.md G0 gate). Graph mode additionally
/// requires the goal harness (nodes execute as goals), enforced at
/// availability time, not here.
/// Graph mode (`/graph`) master switch. Default ON in the binary: the
/// README ships graph engineering enabled for every install, but the
/// old `default(false)` delegated enablement to installer env plumbing
/// (`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> {
BoolFlag::env("KIGI_GRAPH").default(false).resolve()
BoolFlag::env("KIGI_GRAPH").default(true).resolve()
}
/// Max graph nodes running concurrently (`KIGI_GRAPH_CONCURRENCY`).
/// 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`):
/// `{platform_id}/{model_id}` for `crate::models::default_model()`.
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]
fn main_cli_tools_override_preserves_profile_injection_policy() {
let overrides = CliAgentOverrides {
+39 -11
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) {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Ok(json) = serde_json::to_vec_pretty(cache)
&& std::fs::write(&tmp, &json).is_ok()
{
let _ = std::fs::rename(&tmp, &self.path);
let tmp = self.tmp_path();
let result = serde_json::to_vec_pretty(cache)
.map_err(std::io::Error::other)
.and_then(|json| std::fs::write(&tmp, &json))
.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() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let tmp = self.path.with_extension("json.tmp");
let Ok(json) = serde_json::to_vec_pretty(cache) else {
return;
let tmp = self.tmp_path();
let json = match serde_json::to_vec_pretty(cache) {
Ok(json) => json,
Err(e) => {
tracing::warn!(error = %e, "models cache serialize failed");
return;
}
};
if tokio::fs::write(&tmp, &json).await.is_ok() {
let _ = tokio::fs::rename(&tmp, &self.path).await;
let result = match tokio::fs::write(&tmp, &json).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(())
}
/// Atomic write: tmp + rename. Unix `rename(2)` replaces atomically;
/// Windows `rename` requires removing the target first.
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`,
/// which this site's inline delete-first pattern graduated into).
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()));
write_store_to(&tmp, auth_store)?;
#[cfg(windows)]
{
let _ = std::fs::remove_file(auth_file);
}
std::fs::rename(&tmp, auth_file)?;
Ok(())
crate::util::fs::replace_file(&tmp, auth_file)
}
/// 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);
return Err(e.into());
}
if let Err(e) = std::fs::rename(&tmp, config_path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
crate::util::fs::replace_file(&tmp, config_path)?;
Ok(())
}
@@ -854,7 +851,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
std::fs::create_dir_all(parent)?;
}
std::fs::write(&tmp, &toml_str)?;
std::fs::rename(&tmp, config_path)?;
crate::util::fs::replace_file(&tmp, config_path)?;
info!(
path = %config_path.display(),
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 tmp = target.with_extension("json.tmp");
std::fs::write(&tmp, &json_str)?;
std::fs::rename(&tmp, &target)?;
crate::util::fs::replace_file(&tmp, &target)?;
info!(
path = %target.display(),
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).
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path)?;
crate::util::fs::replace_file(&tmp, &path)?;
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);
return Err(e.into());
}
if let Err(e) = std::fs::rename(&tmp, &config_path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
crate::util::fs::replace_file(&tmp, &config_path)?;
info!(
path = %config_path.display(),
added = applied.total_added(),
@@ -1147,7 +1147,7 @@ fn persist_chat_history_jsonl_sync(session_info: &SessionInfo, conversation: &[C
buf.push(b'\n');
}
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(())
})();
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");
let tmp = dir.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()));
tokio::fs::write(&tmp, body).await?;
if let Err(err) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(err);
}
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(())
}
@@ -895,7 +895,8 @@ impl GoalTracker {
};
let dest = goal_dir.join(name);
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);
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.sync_all()?;
}
std::fs::rename(&tmp, &target)
crate::util::fs::replace_file(&tmp, &target)
}
/// 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(())
}
@@ -13,6 +13,16 @@ use std::fs::OpenOptions;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
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.
///
/// - `FromRoot` (default): computes `{root}/sessions/{urlencoded(cwd)}/{session_id}/`
@@ -289,7 +299,7 @@ impl JsonlStorageAdapter {
}
let tmp = path.with_extension("jsonl.tmp");
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>> {
if !path.exists() {
@@ -378,7 +388,7 @@ impl JsonlStorageAdapter {
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = summary_path.with_extension("json.tmp");
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> {
let path = self.summary_file(info);
@@ -1057,7 +1067,7 @@ impl StorageAdapter for JsonlStorageAdapter {
let target = self.plan_mode_state_file(info);
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
replace_file_async(tmp, target).await
}
async fn write_signals(
&self,
@@ -1069,7 +1079,7 @@ impl StorageAdapter for JsonlStorageAdapter {
let target = self.signals_file(info);
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, signals_json).await?;
tokio::fs::rename(&tmp, &target).await
replace_file_async(tmp, target).await
}
async fn write_announcement_state(
&self,
@@ -1081,7 +1091,7 @@ impl StorageAdapter for JsonlStorageAdapter {
let target = self.announcement_state_file(info);
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
replace_file_async(tmp, target).await
}
async fn write_goal_mode_state(
&self,
@@ -1096,7 +1106,7 @@ impl StorageAdapter for JsonlStorageAdapter {
}
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
replace_file_async(tmp, target).await
}
async fn write_graph_mode_state(
&self,
@@ -1119,7 +1129,7 @@ impl StorageAdapter for JsonlStorageAdapter {
}
let tmp = target.with_extension("json.tmp");
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> {
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))?;
let tmp = summary_path.with_extension("json.tmp");
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)]
@@ -114,9 +114,7 @@ fn dismiss_campaign_ids_at(
let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce));
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
crate::util::fs::replace_file(&tmp, &path)
}
/// `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;
}
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(())
}
@@ -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;
}
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(())
}
@@ -476,7 +480,10 @@ pub async fn save_mcp_server_config_at(
let _ = tokio::fs::create_dir_all(parent).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(())
}
@@ -553,7 +560,12 @@ pub async fn delete_mcp_server_config_at(
let _ = tokio::fs::create_dir_all(parent).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.
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;
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(())
}
@@ -138,11 +143,8 @@ pub(crate) fn atomic_write_string(path: &std::path::Path, content: &str) -> std:
}
let _ = prior_mode;
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
// Windows-safe replace; cleans up the tmp file on failure itself.
crate::util::fs::replace_file(&tmp, path)
}
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
@@ -535,13 +535,20 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
/// Upgrade path, Anthropic Messages API: a legacy session whose assistant
/// carries inline `reasoning: {text, encrypted, id}` (text = thinking,
/// encrypted = signature) must, on load, reconstruct a sibling Reasoning
/// item that emits a Anthropic Messages `thinking` content block (with `thinking`
/// + `signature`) on the outgoing `/v1/messages` request.
/// item — and when that turn is the ACTIVE tool-use continuation, its
/// `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]
async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl. Anthropic Messages
// thinking blocks never carried an id (stream/messages.rs sets
// id=""), and the signature lives in `encrypted`.
async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loop() {
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl whose
// assistant turn issued a tool call (thinking blocks never carried an
// 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();
std::fs::write(
dir.path().join("chat_history.jsonl"),
@@ -550,7 +557,9 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
"\n",
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
"\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",
),
)
@@ -558,7 +567,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
// 2. Load + upgrade.
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!(
items
.iter()
@@ -566,20 +575,18 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
"legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}"
);
// 3. Continue and send over the Messages API, capturing the body.
items.push(ConversationItem::user("q2"));
// 3. Send the tool-loop continuation over the Messages API.
let server = MockInferenceServer::start().await.unwrap();
server.set_response("ok");
let client = create_test_client(&server.url(), ApiBackend::Messages);
let _ = client
.conversation_collect(ConversationRequest::from_items(items))
.conversation_collect(ConversationRequest::from_items(items.clone()))
.await
.unwrap();
// 4. The reconstructed reasoning must emit a Anthropic Messages `thinking`
// content block carrying the thinking text + signature.
// 4. The active loop's reconstructed reasoning must emit an Anthropic
// `thinking` content block carrying the thinking text + signature.
let body = server.request_bodies().pop().unwrap();
let messages = body.get("messages").unwrap().as_array().unwrap();
let thinking_block = messages
@@ -593,7 +600,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
})
.find(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
.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!(
thinking_block.get("thinking").and_then(Value::as_str),
@@ -605,6 +612,25 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
Some("SIGNATURE_abc"),
"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 +1469,79 @@ async fn test_chat_completions_backend_hits_chat_endpoint_not_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::{
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_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_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,
@@ -844,7 +844,7 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
rollback_value: &crate::settings::SettingValue,
) -> Vec<Effect> {
use crate::settings::SettingValue;
let mut companion_effects: Vec<Effect> = Vec::new();
let companion_effects: Vec<Effect> = Vec::new();
match (key, rollback_value) {
("compact_mode", SettingValue::Bool(b)) => set_compact_mode_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.
sync_active_auto_flag(app);
}
// default_model: best-effort rollback. If the prior model no
// longer resolves, leave optimistic value + log.
("default_model", SettingValue::String(s)) => {
if s.is_empty() {
tracing::warn!(
target: "settings",
key = "default_model",
"rollback to empty string requested but no \
'clear current model' API exists leaving live \
state at optimistic value (next session reload \
will resolve via shell default-resolution chain)",
);
} 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",
);
}
}
}
// default_model: deliberately NO revert. The session switch already
// succeeded independently (its own failure path reports via
// `handle_switch_model_complete`); this arm fires when only the
// 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!(
target: "settings",
key = "default_model",
prior = %prior,
"default-model persist failed; keeping the live session's \
model (the switch succeeded) only the next-launch \
default is unsaved",
);
}
// max_thoughts_width: direct inner call.
("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();
}
/// 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
/// value of "always-approve" must not re-enable yolo under the pin.
#[test]
+7 -9
View File
@@ -149,15 +149,13 @@ try {
Write-Host "Run 'kigi' to get started."
}
# Graph engineering ships enabled by default. Respect an explicit
# user choice: only set the variable when it is not already defined
# (so a persisted opt-out of "0" survives reinstalls).
$Graph = [Environment]::GetEnvironmentVariable("KIGI_GRAPH", "User")
if ($null -eq $Graph -or $Graph -eq "") {
[Environment]::SetEnvironmentVariable("KIGI_GRAPH", "1", "User")
Write-Host "Enabled graph engineering (KIGI_GRAPH=1)."
Write-Host "Disable: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')"
}
# Graph engineering is enabled by default IN THE BINARY (resolve_graph
# defaults true) — no environment plumbing needed. The installer used
# to persist KIGI_GRAPH=1 into the User registry env, but running
# terminals (and new tabs of an open Windows Terminal) never pick up
# freshly-written registry variables, which made /graph "missing on
# Windows" while the shell-rc path worked on macOS/Linux. Opt out any
# time with: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')
} finally {
Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
+4 -8
View File
@@ -209,7 +209,6 @@ case "${SHELL:-}" in
*/zsh)
RC_FILE="${ZDOTDIR:-$HOME}/.zshrc"
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
GRAPH_LINE="export KIGI_GRAPH=1"
;;
*/bash)
# macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc.
@@ -219,7 +218,6 @@ case "${SHELL:-}" in
RC_FILE="$HOME/.bashrc"
fi
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
GRAPH_LINE="export KIGI_GRAPH=1"
;;
*/fish)
# fish_add_path in config.fish is fish's own idempotent way
@@ -228,12 +226,10 @@ case "${SHELL:-}" in
mkdir -p "$FISH_CONF_DIR"
RC_FILE="$FISH_CONF_DIR/config.fish"
PATH_LINE="fish_add_path $BIN_DIR"
GRAPH_LINE="set -gx KIGI_GRAPH 1"
;;
*)
RC_FILE="$HOME/.profile"
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
GRAPH_LINE="export KIGI_GRAPH=1"
;;
esac
@@ -247,8 +243,8 @@ case ":$PATH:" in
;;
esac
# Graph engineering ships enabled by default. The KIGI_GRAPH guard makes
# this idempotent AND respects an explicit user opt-out (an existing
# `export KIGI_GRAPH=0` line is left untouched). Disable any time with:
# Graph engineering is enabled by default IN THE BINARY (resolve_graph
# defaults true) — the installer no longer writes KIGI_GRAPH=1 into shell
# rc files (per-shell env plumbing was fragile and diverged per platform).
# Disable any time with:
# echo 'export KIGI_GRAPH=0' >> <your shell rc>
persist_line "$RC_FILE" "$GRAPH_LINE" "KIGI_GRAPH" "graph engineering (KIGI_GRAPH=1)"