23 Commits
Author SHA1 Message Date
ZacharyZhang-NY ff0fb56c67 release: v0.1.8
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.7 — chat-completions dialect correctness (Pi-referenced):
- fix(chat): BYOK/custom endpoints default to Passthrough (vanilla
  OpenAI semantics; Kimi body mutations no longer leak to third-party
  servers); house/Kimi coding endpoints keep the Kimi dialect via
  base-url detection
- fix(chat): dedicated Mistral dialect — exactly-nine-alphanumeric
  tool-call id normalizer (deterministic, call/result-symmetric)
- fix(chat): tool-result images relocate to a batched user message
  (tool messages are text-only on the OpenAI chat wire)
- docs: AGENTS.md records the dialect-selection contract
2026-07-23 10:39:18 -04:00
ZacharyZhang-NY 2524c33a5b fix(chat): relocate tool-result images to a batched user message
C3 (Pi openai-completions relocation): tool messages are text-only on
the OpenAI chat wire — image parts inside role:tool content 400 on
strict validators. Images from a consecutive tool-result run now batch
into ONE synthetic user message appended after the run (a user message
may not interrupt tool responses answering the same assistant's
tool_calls); an image-only result carries a '(see attached image)'
pointer placeholder. Applied at the typed conversion layer, so every
chat dialect gets the universally-valid shape.

Verified: models+sampling-types+sampler+chat-state+shell all green,
clippy clean.
2026-07-23 10:38:37 -04:00
ZacharyZhang-NY 301eb61de4 fix(chat): BYOK dialect defaults to Passthrough; dedicated Mistral dialect
C1 (decision): custom/BYOK ChatCompletions entries defaulted to the Kimi
dialect, leaking Kimi-specific body mutations (thinking:{…} control,
replayed reasoning_content, schema rewrites) to arbitrary third-party
OpenAI-compatible servers. The default is now Passthrough (vanilla
OpenAI semantics), with ONE exception mirroring Pi's base-url quirk
sniffing: entries pointed at the house/Kimi coding endpoint keep the
Kimi dialect. Registry platforms are unaffected (all declare
explicitly).

C2 (Pi mistral-conversations normalizer): Mistral requires tool-call ids
of EXACTLY nine [a-zA-Z0-9] chars; even same-session synthesized UUIDs
violate it. New ChatCompat::Mistral = StrictOpenAi behavior + the
normalizer — strip non-alphanumerics, keep exact-9 ids, else FNV-1a →
base36 (build-stable, deterministic across requests for prefix-cache
stability) with collision retry; ONE map covers tool_calls[].id and
tool_call_id so pairing survives. The mistral registry row and the
persisted 'mistral' serde value both resolve to it (pre-rename Mistral
sessions gain the contract automatically); Cerebras/NVIDIA stay on
StrictOpenAi untouched.

Verified: models+sampling-types+sampler+chat-state+shell all green,
clippy clean.
2026-07-23 10:31:45 -04:00
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
35 changed files with 2012 additions and 364 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 }}
+62 -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,45 @@ 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".
- 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.
- ChatCompletions dialect selection: registry platforms declare
`chat_compat` explicitly; BYOK/custom entries default to `Passthrough`
(vanilla OpenAI semantics) EXCEPT entries pointed at the house/Kimi
coding endpoint, which keep the `Kimi` dialect (base-url detection —
Pi-style quirk sniffing). `ChatCompat::Mistral` = StrictOpenAi plus the
exactly-nine-`[a-zA-Z0-9]` tool-call id normalizer
(`normalize_mistral_tool_call_ids`, deterministic FNV-1a→base36, one
map for call+result; persisted `mistral` values resolve here). Chat
tool messages are TEXT-ONLY: tool-result images batch into one
synthetic user message after the consecutive tool-result run
(`conversation_to_chat_messages`).
- `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 +275,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.8"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.4"
version = "0.1.8"
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.8"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"async-openai",
"async-stream",
@@ -6154,7 +6154,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6171,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"chrono",
@@ -6192,7 +6192,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6230,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6367,7 +6367,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6392,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6403,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6418,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6426,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6457,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.4"
version = "0.1.8"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6472,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6500,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6577,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6590,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.4"
version = "0.1.8"
[[package]]
name = "kigi-tty-utils"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6604,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6691,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6720,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6806,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"base64",
"chrono",
@@ -8840,7 +8840,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8858,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.4"
version = "0.1.8"
edition = "2024"
license = "Apache-2.0"
+10 -5
View File
@@ -70,9 +70,12 @@ pub enum PlatformChatCompat {
Kimi,
DeepSeek,
Passthrough,
/// Strict OpenAI-compatible validator (Mistral, Cerebras) — strips
/// Strict OpenAI-compatible validator (Cerebras, NVIDIA) — strips
/// `stream_options` and private fields.
StrictOpenAi,
/// Mistral: StrictOpenAi plus its exactly-9-alphanumeric tool-call id
/// contract (foreign/OpenAI-style ids are deterministically remapped).
Mistral,
}
/// How a platform's API key rides requests (listing, validation, inference).
@@ -599,10 +602,12 @@ const MISTRAL_SPEC: PlatformSpec = PlatformSpec {
wire_serves_metadata: false,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
// Mistral's strict validator 422s on `stream_options`, and its reasoning
// models return array content — the StrictOpenAi dialect strips
// stream_options; the response deserializer handles arrays universally.
chat_compat: PlatformChatCompat::StrictOpenAi,
// Mistral's strict validator 422s on `stream_options`, its reasoning
// models return array content, and tool-call ids must be EXACTLY nine
// `[a-zA-Z0-9]` chars — the Mistral dialect strips stream_options and
// deterministically remaps non-conforming (foreign/OpenAI-style) ids;
// the response deserializer handles arrays universally.
chat_compat: PlatformChatCompat::Mistral,
key_header: PlatformKeyHeader::Bearer,
// The listing carries embed/moderation/OCR entries; keep tool-calling
// chat models only.
@@ -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
@@ -51,6 +51,91 @@ pub(crate) fn adapt_chat_completions_body_for(
strip_kigi_private_message_fields(body);
strip_stream_options(body);
}
kigi_sampling_types::ChatCompat::Mistral => {
strip_kigi_private_message_fields(body);
strip_stream_options(body);
normalize_mistral_tool_call_ids(body);
}
}
}
/// Mistral's validator requires tool-call ids of EXACTLY nine
/// `[a-zA-Z0-9]` characters. Foreign backends mint arbitrary ids
/// (OpenAI `call_…`, UUIDs, Anthropic `toolu_…`), so non-conforming ids
/// are remapped deterministically — ported from Pi's
/// `mistral-conversations.ts` normalizer: strip non-alphanumerics, keep
/// the id when the result is already exactly nine chars, otherwise hash
/// (FNV-1a → base36) down to nine, retrying with an attempt suffix on
/// collision. ONE map serves `tool_calls[].id` and `tool_call_id` alike,
/// so call/result pairing survives.
fn normalize_mistral_tool_call_ids(body: &mut Value) {
const LEN: usize = 9;
fn derive(id: &str, attempt: u32) -> String {
let normalized: String = id.chars().filter(char::is_ascii_alphanumeric).collect();
if attempt == 0 && normalized.len() == LEN {
return normalized;
}
let seed_base = if normalized.is_empty() {
id
} else {
&normalized
};
let seed = if attempt == 0 {
seed_base.to_string()
} else {
format!("{seed_base}:{attempt}")
};
// FNV-1a (stable across builds, unlike std's DefaultHasher) → base36.
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in seed.bytes() {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
let mut out = String::with_capacity(LEN);
let digits = b"0123456789abcdefghijklmnopqrstuvwxyz";
let mut h = hash;
while out.len() < LEN {
out.push(digits[(h % 36) as usize] as char);
h = h / 36 + 1; // +1 keeps the stream from collapsing to zeros
}
out
}
let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else {
return;
};
let mut forward: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut normalize = |id: &str| -> String {
if let Some(mapped) = forward.get(id) {
return mapped.clone();
}
let mut attempt = 0;
loop {
let candidate = derive(id, attempt);
if taken.insert(candidate.clone()) {
forward.insert(id.to_string(), candidate.clone());
return candidate;
}
attempt += 1;
}
};
for message in messages.iter_mut() {
if let Some(tool_calls) = message.get_mut("tool_calls").and_then(|t| t.as_array_mut()) {
for tc in tool_calls {
if let Some(id) = tc.get("id").and_then(|v| v.as_str()).map(str::to_owned) {
tc["id"] = Value::String(normalize(&id));
}
}
}
if let Some(id) = message
.get("tool_call_id")
.and_then(|v| v.as_str())
.map(str::to_owned)
{
message["tool_call_id"] = Value::String(normalize(&id));
}
}
}
@@ -666,4 +751,56 @@ mod tests {
assert_eq!(props["num"]["type"], json!("number"));
assert_eq!(props["free"]["type"], json!("string"));
}
/// Mistral dialect: exactly-nine `[a-zA-Z0-9]` tool-call ids. A
/// conforming id survives; foreign ids (OpenAI `call_…`, UUIDs) remap
/// deterministically; the SAME map serves `tool_calls[].id` and
/// `tool_call_id`, so pairing survives; distinct inputs never collide.
#[test]
fn mistral_dialect_normalizes_tool_call_ids_symmetrically() {
let mut body = serde_json::json!({
"messages": [
{"role": "assistant", "tool_calls": [
{"id": "abc123XYZ", "type": "function", "function": {"name": "a", "arguments": "{}"}},
{"id": "call_0123456789abcdef", "type": "function", "function": {"name": "b", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "abc123XYZ", "content": "r1"},
{"role": "tool", "tool_call_id": "call_0123456789abcdef", "content": "r2"},
],
"stream_options": {"include_usage": true}
});
adapt_chat_completions_body_for(kigi_sampling_types::ChatCompat::Mistral, &mut body);
let msgs = body["messages"].as_array().unwrap();
let ids: Vec<String> = msgs[0]["tool_calls"]
.as_array()
.unwrap()
.iter()
.map(|tc| tc["id"].as_str().unwrap().to_string())
.collect();
// Conforming id kept verbatim.
assert_eq!(ids[0], "abc123XYZ");
// Foreign id remapped to exactly nine alphanumerics.
assert_eq!(ids[1].len(), 9, "{ids:?}");
assert!(ids[1].chars().all(|ch| ch.is_ascii_alphanumeric()));
assert_ne!(ids[0], ids[1], "distinct inputs must not collide");
// Results carry the SAME mapped ids.
assert_eq!(msgs[1]["tool_call_id"].as_str().unwrap(), ids[0]);
assert_eq!(msgs[2]["tool_call_id"].as_str().unwrap(), ids[1]);
// StrictOpenAi base behavior rides along.
assert!(body.get("stream_options").is_none());
// Determinism: the same foreign id maps identically in a fresh body.
let mut body2 = serde_json::json!({
"messages": [
{"role": "tool", "tool_call_id": "call_0123456789abcdef", "content": "r"}
]
});
adapt_chat_completions_body_for(kigi_sampling_types::ChatCompat::Mistral, &mut body2);
assert_eq!(
body2["messages"][0]["tool_call_id"].as_str().unwrap(),
ids[1],
"remap must be deterministic across requests (prefix-cache stability)"
);
}
}
File diff suppressed because it is too large Load Diff
+190 -5
View File
@@ -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
/// (`max`): remove it so response deserialization succeeds. The turn's
/// canonical effort lives in the session sampling config regardless; only
@@ -1179,15 +1260,21 @@ pub enum ChatCompat {
DeepSeek,
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
Passthrough,
/// Strict OpenAI-compatible validators (Mistral, Cerebras) reject any
/// Strict OpenAI-compatible validators (Cerebras, NVIDIA) reject any
/// out-of-schema request field with a 4xx (`additionalProperties:false`).
/// kigi injects `stream_options.include_usage` on every streaming
/// request, which such validators reject, so it is stripped (streaming
/// usage falls back to token estimation). `reasoning_effort` passes
/// through; private message fields are stripped like Passthrough.
/// (Serde alias `mistral` keeps sessions persisted before the rename.)
#[serde(alias = "mistral")]
StrictOpenAi,
/// Mistral: [`Self::StrictOpenAi`] behavior plus its exactly-nine
/// `[a-zA-Z0-9]` tool-call id contract — foreign/OpenAI-style ids are
/// deterministically remapped on call+result in one shared map (the
/// Pi `mistral-conversations` normalizer). Serializes as `mistral`, so
/// sessions persisted before the StrictOpenAi rename (which carried
/// the `mistral` alias) resolve here — correct, they were Mistral
/// sessions.
Mistral,
}
pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
@@ -1551,6 +1638,98 @@ 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);
}
/// 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
/// answer verbatim with no thinking — byte-identical to the pre-change
/// deserialization.
@@ -1558,9 +1737,15 @@ mod tests {
/// persisted before the rename still deserialize.
#[test]
fn chat_compat_mistral_alias_deserializes_to_strict_openai() {
// `mistral` resolves to the dedicated Mistral dialect — including
// sessions persisted before the StrictOpenAi rename (they were
// Mistral sessions and now get the 9-char id contract too).
let v: ChatCompat = serde_json::from_str("\"mistral\"").unwrap();
assert_eq!(v, ChatCompat::StrictOpenAi);
// New value round-trips as strict_open_ai.
assert_eq!(v, ChatCompat::Mistral);
assert_eq!(
serde_json::to_string(&ChatCompat::Mistral).unwrap(),
"\"mistral\""
);
let v: ChatCompat = serde_json::from_str("\"strict_open_ai\"").unwrap();
assert_eq!(v, ChatCompat::StrictOpenAi);
assert_eq!(
@@ -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));
}
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[..]);
@@ -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 {
+108 -8
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
@@ -4094,8 +4099,12 @@ pub fn sampling_config_for_model(
&credentials.base_url,
);
let api_backend = info.api_backend.clone();
// Managed platform entries speak their registry dialect; BYOK/custom
// entries keep the historical Kimi body adaptation.
// Managed platform entries speak their registry dialect. BYOK/custom
// entries default to Passthrough (vanilla OpenAI semantics — the
// Kimi-specific body mutations `thinking:{…}` + replayed
// `reasoning_content` 400 on third-party OpenAI-compatible servers),
// EXCEPT entries pointed at the house/Kimi coding endpoint, which keep
// the historical Kimi dialect (mirrors Pi's base-url quirk sniffing).
let chat_compat = info
.id
.as_deref()
@@ -4109,8 +4118,15 @@ pub fn sampling_config_for_model(
kigi_models::PlatformChatCompat::StrictOpenAi => {
kigi_sampling_types::ChatCompat::StrictOpenAi
}
kigi_models::PlatformChatCompat::Mistral => kigi_sampling_types::ChatCompat::Mistral,
})
.unwrap_or_default();
.unwrap_or_else(|| {
if crate::util::is_effective_coding_endpoint_url(&credentials.base_url) {
kigi_sampling_types::ChatCompat::Kimi
} else {
kigi_sampling_types::ChatCompat::Passthrough
}
});
// Claude Pro/Max OAuth Messages adaptation: a managed key whose platform is
// a generic-OAuth Messages provider (claude-pro-max) drives the OAuth
// identity headers + "You are Claude Code" system prefix in the sampler.
@@ -4337,6 +4353,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 {
@@ -5940,6 +5974,72 @@ reasoning_effort = "low"
"agentType should always be in meta, defaulting to DEFAULT_AGENT_TYPE"
);
}
/// BYOK/custom entries (no managed platform key) default to the
/// Passthrough dialect — the historical Kimi default leaked
/// Kimi-specific body mutations (`thinking:{…}`, replayed
/// `reasoning_content`) to third-party OpenAI-compatible servers.
/// The one exception: entries pointed at the house/Kimi coding
/// endpoint keep the Kimi dialect (base-url detection, mirroring
/// Pi's quirk sniffing).
#[test]
fn byok_custom_entries_default_to_passthrough_except_house_endpoint() {
let make_cfg = |base_url: &str| {
let entry_cfg = ModelEntryConfig {
id: None, // BYOK: no managed platform key
model: "my-custom-model".to_string(),
base_url: base_url.to_string(),
name: None,
description: None,
max_completion_tokens: None,
temperature: None,
top_p: None,
api_key: None,
env_key: None,
api_backend: ApiBackend::default(),
auth_scheme: None,
extra_headers: IndexMap::new(),
context_window: NonZeroU64::new(200_000).unwrap(),
auto_compact_threshold_percent: None,
system_prompt_label: None,
api_base_url: None,
use_concise: true,
agent_type: default_agent_type(),
inference_idle_timeout_secs: None,
max_retries: None,
hidden: false,
supported_in_api: true,
reasoning_effort: None,
supports_reasoning_effort: false,
reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
show_model_fingerprint: false,
stream_tool_calls: None,
laziness_detector: LazinessDetectorPerModelConfig::default(),
};
let entry = ModelEntry::from_config_entry(&entry_cfg);
let creds = ResolvedCredentials {
api_key: Some("sk-byok".into()),
base_url: base_url.to_string(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
sampling_config_for_model(&entry, creds, None)
};
assert_eq!(
make_cfg("https://api.third-party.example/v1").chat_compat,
kigi_sampling_types::ChatCompat::Passthrough,
"third-party BYOK must get vanilla OpenAI semantics"
);
assert_eq!(
make_cfg("https://api.kimi.com/coding/v1").chat_compat,
kigi_sampling_types::ChatCompat::Kimi,
"the house coding endpoint keeps the Kimi dialect"
);
}
/// Managed `{platform}/{model}` entries stamp `meta.provider` with the
/// platform's display name so the client's model picker can say which
/// connected provider each model belongs to. User-defined `[model.*]`
+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) {
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 {
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");
}
}
}
@@ -1841,8 +1841,9 @@ mod tests {
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::StrictOpenAi,
"mistral entries use the StrictOpenAi dialect (stream_options strip)"
kigi_sampling_types::ChatCompat::Mistral,
"mistral entries use the Mistral dialect (StrictOpenAi behavior \
plus the exactly-nine-alphanumeric tool-call id contract)"
);
}
@@ -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
@@ -472,7 +472,12 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
"\n",
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
"\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",
),
)
@@ -535,13 +540,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 +562,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 +572,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 +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:?}"
);
// 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 +605,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 +617,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 +1474,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() {
// 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",
"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)",
prior = %prior,
"default-model persist failed; keeping the live session's \
model (the switch succeeded) only the next-launch \
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", 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)"