Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0fb56c67 | ||
|
|
2524c33a5b | ||
|
|
301eb61de4 | ||
|
|
d3c9380307 | ||
|
|
c950f8087c | ||
|
|
40c71a8343 | ||
|
|
a3e3973453 | ||
|
|
9cdc0ccfa3 | ||
|
|
6f9f550308 | ||
|
|
2b43f54669 | ||
|
|
6979407f22 | ||
|
|
1fa87566d9 | ||
|
|
d6e49bcc7d | ||
|
|
d2358b037c | ||
|
|
6ba24db019 | ||
|
|
10149f50dd | ||
|
|
e53a66d113 | ||
|
|
f6eaafa3da | ||
|
|
13ccab980c | ||
|
|
b9b7e6c989 | ||
|
|
f403c38a94 | ||
|
|
27d009cb6e | ||
|
|
815bd99356 |
@@ -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 }}
|
||||||
@@ -32,9 +32,19 @@ import) or any `KIMI_*` env var.
|
|||||||
- **Observability is local**: `kigi-log` (unified session log, `--debug`
|
- **Observability is local**: `kigi-log` (unified session log, `--debug`
|
||||||
firehose, subsystem file logs, opt-in instrumentation) writes under
|
firehose, subsystem file logs, opt-in instrumentation) writes under
|
||||||
`~/.kigi` only. Its zero-network property is a contract.
|
`~/.kigi` only. Its zero-network property is a contract.
|
||||||
|
- **Atomic file replace goes through `util::fs::replace_file`** (tmp+rename
|
||||||
|
commit step; async callers wrap in `spawn_blocking`). Never inline a bare
|
||||||
|
`fs::rename` replace: Windows `MoveFileExW(REPLACE_EXISTING)` fails with a
|
||||||
|
sharing violation while AV/indexer/cloud-sync holds the destination open —
|
||||||
|
the "persists on macOS, silently doesn't on Windows" class (a /model
|
||||||
|
switch that never stuck). Plain rename stays correct only for true moves
|
||||||
|
whose destination doesn't pre-exist (worktree-pool markers, corrupt-file
|
||||||
|
backups). Write failures must at least `warn!` — never `let _ =`.
|
||||||
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
|
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
|
||||||
this repo). Members sorted; versions inherited from
|
this repo). Members sorted; versions inherited from
|
||||||
`workspace.package.version` (0.1.0).
|
`workspace.package.version` — the single source of truth for the release
|
||||||
|
version (`kigi_version::VERSION` derives from it; the release workflow
|
||||||
|
gates the `v*` tag against it).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -79,8 +89,9 @@ node as one ordinary goal — the agentic loop lives INSIDE the node; the
|
|||||||
edges stay deterministic Rust. The harness appends a terminal
|
edges stay deterministic Rust. The harness appends a terminal
|
||||||
`gn-final` verification node depending on every planner node.
|
`gn-final` verification node depending on every planner node.
|
||||||
|
|
||||||
- Feature flag `KIGI_GRAPH=1` (default off); availability additionally
|
- Enabled by default (`KIGI_GRAPH=0` is the off-switch; the G0 gray
|
||||||
requires the goal harness (`BuiltinGate::Graph`).
|
release is over); availability additionally requires the goal harness
|
||||||
|
(`BuiltinGate::Graph`).
|
||||||
- Key modules (kigi-shell): `session/graph_tracker.rs` (pure state
|
- Key modules (kigi-shell): `session/graph_tracker.rs` (pure state
|
||||||
machine; reuses `GoalStatus`/`GoalPhase`/`GoalPauseReason`),
|
machine; reuses `GoalStatus`/`GoalPhase`/`GoalPauseReason`),
|
||||||
`session/graph_plan.rs` (planner-JSON contract + validation + fnv id
|
`session/graph_plan.rs` (planner-JSON contract + validation + fnv id
|
||||||
@@ -209,7 +220,45 @@ edges stay deterministic Rust. The harness appends a terminal
|
|||||||
system prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max
|
system prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max
|
||||||
only), so API-key `anthropic`/`minimax` Messages requests stay
|
only), so API-key `anthropic`/`minimax` Messages requests stay
|
||||||
byte-identical. Its `/v1/models` listing rides the same Bearer +
|
byte-identical. Its `/v1/models` listing rides the same Bearer +
|
||||||
oauth-beta headers.
|
oauth-beta headers. THINKING REPLAY (`prune_replayed_thinking`,
|
||||||
|
all Messages requests): Anthropic validates every replayed
|
||||||
|
`thinking` block (signature model-bound, non-empty required), so
|
||||||
|
only the final assistant message's signed thinking is replayed and
|
||||||
|
only while its tool loop is open (request ends on the tool
|
||||||
|
results); everything else — unsigned cross-backend history, `tco_*`
|
||||||
|
Responses blobs, stale-model blocks — is stripped, or the request
|
||||||
|
400s "Invalid `signature` in `thinking` block".
|
||||||
|
- CROSS-PROVIDER REPLAY POLICY (Pi `transform-messages` pattern): the
|
||||||
|
conversation history is provider-agnostic and sessions switch
|
||||||
|
models/backends mid-history, so EACH wire builder owns emitting only
|
||||||
|
items valid for its target — never patch downstream except in the
|
||||||
|
per-backend body adapters. Concretely: the Responses input drops
|
||||||
|
Reasoning items without a native `rs_*` id (foreign capture is id "")
|
||||||
|
and provenance-gates whole turns via `transform_items_for_responses`
|
||||||
|
(`AssistantItem.model_id` vs the request model: foreign Reasoning
|
||||||
|
dropped, foreign BackendToolCall demoted to its `text_summary`);
|
||||||
|
the codex adapter additionally drops bare `rs_*` references (stateless
|
||||||
|
backend); tool-call ids pass through ONE shared ASCII
|
||||||
|
`sanitize_tool_call_id` symmetrically on call+result on BOTH the
|
||||||
|
Messages and Responses legs; Messages image sources go through
|
||||||
|
`parse_base64_image_data_uri` (raster whitelist, no `data:` url
|
||||||
|
sources) and empty user turns get a placeholder. Dangling tool calls
|
||||||
|
are already repaired item-level by `repair_dangling_tool_calls` on the
|
||||||
|
actor's build path. When a provider wire bug surfaces, fix the CLASS
|
||||||
|
across all three builders in the same pass — three sequential
|
||||||
|
single-provider fixes (thinking signature → codex system role → codex
|
||||||
|
reasoning id) motivated this policy.
|
||||||
|
- 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
|
- `openai-codex` (ChatGPT Plus/Pro, `scope_key oauth/openai-codex`, port
|
||||||
1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`,
|
1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`,
|
||||||
client `app_EMoam…`, scope `openid profile email offline_access`, the 3
|
client `app_EMoam…`, scope `openid profile email offline_access`, the 3
|
||||||
@@ -226,9 +275,15 @@ edges stay deterministic Rust. The harness appends a terminal
|
|||||||
`PlatformId::sends_codex_responses_headers()`): headers
|
`PlatformId::sends_codex_responses_headers()`): headers
|
||||||
`chatgpt-account-id` (per-request from the JWT), `originator codex_cli_rs`,
|
`chatgpt-account-id` (per-request from the JWT), `originator codex_cli_rs`,
|
||||||
`OpenAI-Beta responses=experimental`, a codex `User-Agent`; `store:false`
|
`OpenAI-Beta responses=experimental`, a codex `User-Agent`; `store:false`
|
||||||
is the shared Responses default. API-key `openai` Responses requests carry
|
is the shared Responses default. BODY adaptation
|
||||||
NONE of this (byte-identical). `reasoning.effort` carries the thinking
|
(`adapt_body_for_codex_backend`, same gate): the backend 400s
|
||||||
level (incl. the codex-only `ultra`). NO websocket, NO base_instructions.
|
`role:system` input ("System messages are not allowed") — system items
|
||||||
|
are hoisted into the top-level `instructions` field — and stateless
|
||||||
|
reasoning replay requires `include:["reasoning.encrypted_content"]`.
|
||||||
|
API-key `openai` Responses requests carry NONE of this
|
||||||
|
(byte-identical, pinned by a control wire test). `reasoning.effort`
|
||||||
|
carries the thinking level (incl. the codex-only `ultra`). NO
|
||||||
|
websocket, NO base_instructions.
|
||||||
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
|
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
|
||||||
`openai_codex_wire_models`, mapped through the SAME
|
`openai_codex_wire_models`, mapped through the SAME
|
||||||
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
|
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
|
||||||
|
|||||||
Generated
+62
-62
@@ -5442,7 +5442,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-acp-lib"
|
name = "kigi-acp-lib"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent"
|
name = "kigi-agent"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent-lifecycle"
|
name = "kigi-agent-lifecycle"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-auth"
|
name = "kigi-auth"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-bin"
|
name = "kigi-bin"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-chat-state"
|
name = "kigi-chat-state"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"kigi-compaction",
|
"kigi-compaction",
|
||||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-codebase-graph"
|
name = "kigi-codebase-graph"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-compaction"
|
name = "kigi-compaction"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config"
|
name = "kigi-config"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config-types"
|
name = "kigi-config-types"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-crash-handler"
|
name = "kigi-crash-handler"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-env"
|
name = "kigi-env"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fast-worktree"
|
name = "kigi-fast-worktree"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-file-utils"
|
name = "kigi-file-utils"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fsnotify"
|
name = "kigi-fsnotify"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"criterion",
|
"criterion",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-gix-status"
|
name = "kigi-gix-status"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gix",
|
"gix",
|
||||||
"kigi-test-utils",
|
"kigi-test-utils",
|
||||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks"
|
name = "kigi-hooks"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks-plugins-types"
|
name = "kigi-hooks-plugins-types"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-http"
|
name = "kigi-http"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-auth",
|
"kigi-auth",
|
||||||
"kigi-log",
|
"kigi-log",
|
||||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hunk-tracker"
|
name = "kigi-hunk-tracker"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-interjection-core"
|
name = "kigi-interjection-core"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-log"
|
name = "kigi-log"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown"
|
name = "kigi-markdown"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"anstyle-lossy",
|
"anstyle-lossy",
|
||||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown-core"
|
name = "kigi-markdown-core"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mcp"
|
name = "kigi-mcp"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-memory"
|
name = "kigi-memory"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mermaid"
|
name = "kigi-mermaid"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fontdb",
|
"fontdb",
|
||||||
"image",
|
"image",
|
||||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-models"
|
name = "kigi-models"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-env",
|
"kigi-env",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-minimal"
|
name = "kigi-pager-minimal"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-pty-harness"
|
name = "kigi-pager-pty-harness"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-render"
|
name = "kigi-pager-render"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-paths"
|
name = "kigi-paths"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"camino",
|
"camino",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-prompt-queue"
|
name = "kigi-prompt-queue"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-proto-build"
|
name = "kigi-proto-build"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pbjson-build",
|
"pbjson-build",
|
||||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-inline"
|
name = "kigi-ratatui-inline"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ansi-width",
|
"ansi-width",
|
||||||
"anstyle-parse 0.2.7",
|
"anstyle-parse 0.2.7",
|
||||||
@@ -6110,7 +6110,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-textarea"
|
name = "kigi-ratatui-textarea"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampler"
|
name = "kigi-sampler"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-openai",
|
"async-openai",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -6154,7 +6154,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampling-types"
|
name = "kigi-sampling-types"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert_matches",
|
"assert_matches",
|
||||||
"async-openai",
|
"async-openai",
|
||||||
@@ -6171,7 +6171,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sandbox"
|
name = "kigi-sandbox"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6192,7 +6192,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-secrets"
|
name = "kigi-secrets"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6230,7 +6230,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell"
|
name = "kigi-shell"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6367,7 +6367,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell-base"
|
name = "kigi-shell-base"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6392,7 +6392,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sqlite-journal"
|
name = "kigi-sqlite-journal"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -6403,7 +6403,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-subagent-resolution"
|
name = "kigi-subagent-resolution"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
@@ -6418,7 +6418,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-system-power"
|
name = "kigi-system-power"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -6426,7 +6426,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-support"
|
name = "kigi-test-support"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-utils"
|
name = "kigi-test-utils"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"runfiles",
|
"runfiles",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6457,11 +6457,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-token-estimation"
|
name = "kigi-token-estimation"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-protocol"
|
name = "kigi-tool-protocol"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6472,7 +6472,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-runtime"
|
name = "kigi-tool-runtime"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-types"
|
name = "kigi-tool-types"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
@@ -6500,7 +6500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools"
|
name = "kigi-tools"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -6577,7 +6577,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools-api"
|
name = "kigi-tools-api"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-proto-build",
|
"kigi-proto-build",
|
||||||
"kigi-tool-protocol",
|
"kigi-tool-protocol",
|
||||||
@@ -6590,11 +6590,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tracing-macros"
|
name = "kigi-tracing-macros"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tty-utils"
|
name = "kigi-tty-utils"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
@@ -6604,7 +6604,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tui"
|
name = "kigi-tui"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"ansi-to-tui",
|
"ansi-to-tui",
|
||||||
@@ -6691,7 +6691,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-update"
|
name = "kigi-update"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -6720,14 +6720,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-version"
|
name = "kigi-version"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace"
|
name = "kigi-workspace"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6806,7 +6806,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace-types"
|
name = "kigi-workspace-types"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -8840,7 +8840,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl"
|
name = "ptyctl"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -8858,7 +8858,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl-cli"
|
name = "ptyctl-cli"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,12 @@ pub enum PlatformChatCompat {
|
|||||||
Kimi,
|
Kimi,
|
||||||
DeepSeek,
|
DeepSeek,
|
||||||
Passthrough,
|
Passthrough,
|
||||||
/// Strict OpenAI-compatible validator (Mistral, Cerebras) — strips
|
/// Strict OpenAI-compatible validator (Cerebras, NVIDIA) — strips
|
||||||
/// `stream_options` and private fields.
|
/// `stream_options` and private fields.
|
||||||
StrictOpenAi,
|
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).
|
/// 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_serves_metadata: false,
|
||||||
wire_api: PlatformWireApi::ChatCompletions,
|
wire_api: PlatformWireApi::ChatCompletions,
|
||||||
listing: ListingDialect::OpenAi,
|
listing: ListingDialect::OpenAi,
|
||||||
// Mistral's strict validator 422s on `stream_options`, and its reasoning
|
// Mistral's strict validator 422s on `stream_options`, its reasoning
|
||||||
// models return array content — the StrictOpenAi dialect strips
|
// models return array content, and tool-call ids must be EXACTLY nine
|
||||||
// stream_options; the response deserializer handles arrays universally.
|
// `[a-zA-Z0-9]` chars — the Mistral dialect strips stream_options and
|
||||||
chat_compat: PlatformChatCompat::StrictOpenAi,
|
// deterministically remaps non-conforming (foreign/OpenAI-style) ids;
|
||||||
|
// the response deserializer handles arrays universally.
|
||||||
|
chat_compat: PlatformChatCompat::Mistral,
|
||||||
key_header: PlatformKeyHeader::Bearer,
|
key_header: PlatformKeyHeader::Bearer,
|
||||||
// The listing carries embed/moderation/OCR entries; keep tool-calling
|
// The listing carries embed/moderation/OCR entries; keep tool-calling
|
||||||
// chat models only.
|
// chat models only.
|
||||||
|
|||||||
@@ -1183,6 +1183,9 @@ impl SamplingClient {
|
|||||||
// old raw_output machinery.
|
// old raw_output machinery.
|
||||||
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
||||||
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
||||||
|
if self.defaults.openai_codex {
|
||||||
|
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
|
||||||
|
}
|
||||||
let http_request = self.post(self.endpoint("responses")).json(&request_body);
|
let http_request = self.post(self.endpoint("responses")).json(&request_body);
|
||||||
|
|
||||||
let response = http_request.send().await.map_err(|e| {
|
let response = http_request.send().await.map_err(|e| {
|
||||||
@@ -1321,6 +1324,9 @@ impl SamplingClient {
|
|||||||
}
|
}
|
||||||
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
||||||
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
||||||
|
if self.defaults.openai_codex {
|
||||||
|
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
|
||||||
|
}
|
||||||
// Fresh per attempt so signals never leak across retries; `None`
|
// Fresh per attempt so signals never leak across retries; `None`
|
||||||
// (check disabled) sends no header and does no peek work per event.
|
// (check disabled) sends no header and does no peek work per event.
|
||||||
let doom_loop = self
|
let doom_loop = self
|
||||||
|
|||||||
@@ -51,6 +51,91 @@ pub(crate) fn adapt_chat_completions_body_for(
|
|||||||
strip_kigi_private_message_fields(body);
|
strip_kigi_private_message_fields(body);
|
||||||
strip_stream_options(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["num"]["type"], json!("number"));
|
||||||
assert_eq!(props["free"]["type"], json!("string"));
|
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
@@ -1041,6 +1041,87 @@ pub fn patch_reasoning_effort(body: &mut Value, effort: Option<ReasoningEffort>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adapt a serialized Responses request body to the ChatGPT/Codex backend
|
||||||
|
/// contract (`chatgpt.com/backend-api/codex/responses`) — ported from the
|
||||||
|
/// same reference as the identity headers (official Codex CLI + Pi's
|
||||||
|
/// `api/openai-codex-responses.ts`):
|
||||||
|
///
|
||||||
|
/// 1. The backend rejects `role: system` input items outright
|
||||||
|
/// (400 `{"detail":"System messages are not allowed"}`); its system
|
||||||
|
/// channel is the top-level `instructions` field. Hoist every system
|
||||||
|
/// input message there (order preserved, blank-line joined, appended to
|
||||||
|
/// any existing instructions) and remove them from `input`.
|
||||||
|
/// 2. `store` is always `false` on this backend, so reasoning continuity
|
||||||
|
/// is stateless: `include: ["reasoning.encrypted_content"]` is required
|
||||||
|
/// for the response to carry replayable encrypted reasoning.
|
||||||
|
/// 3. For the same reason, a replayed reasoning item WITHOUT
|
||||||
|
/// `encrypted_content` (captured from a stateful api.openai.com session
|
||||||
|
/// that never requested the include) references server state
|
||||||
|
/// chatgpt.com does not have — drop it rather than 400.
|
||||||
|
///
|
||||||
|
/// openai-codex-GATED at the call sites — API-key `openai` Responses
|
||||||
|
/// bodies stay byte-identical.
|
||||||
|
pub fn adapt_body_for_codex_backend(body: &mut Value) {
|
||||||
|
// 1. Hoist system messages into `instructions`.
|
||||||
|
let mut hoisted: Vec<String> = Vec::new();
|
||||||
|
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
|
||||||
|
input.retain(|item| {
|
||||||
|
let is_system = item.get("role").and_then(|r| r.as_str()) == Some("system");
|
||||||
|
if is_system {
|
||||||
|
match item.get("content") {
|
||||||
|
Some(Value::String(s)) => hoisted.push(s.clone()),
|
||||||
|
Some(Value::Array(parts)) => {
|
||||||
|
for p in parts {
|
||||||
|
if let Some(t) = p.get("text").and_then(|t| t.as_str()) {
|
||||||
|
hoisted.push(t.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!is_system
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !hoisted.is_empty() {
|
||||||
|
let mut instructions = body
|
||||||
|
.get("instructions")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_default();
|
||||||
|
for part in hoisted {
|
||||||
|
if !instructions.is_empty() {
|
||||||
|
instructions.push_str("\n\n");
|
||||||
|
}
|
||||||
|
instructions.push_str(&part);
|
||||||
|
}
|
||||||
|
body["instructions"] = Value::String(instructions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Request replayable encrypted reasoning.
|
||||||
|
let include = body
|
||||||
|
.as_object_mut()
|
||||||
|
.map(|obj| obj.entry("include").or_insert_with(|| Value::Array(vec![])));
|
||||||
|
if let Some(Value::Array(entries)) = include {
|
||||||
|
let key = Value::String("reasoning.encrypted_content".to_string());
|
||||||
|
if !entries.contains(&key) {
|
||||||
|
entries.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Drop reasoning items with no encrypted payload: stateless codex
|
||||||
|
// cannot resolve a bare `rs_*` reference.
|
||||||
|
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
|
||||||
|
input.retain(|item| {
|
||||||
|
item.get("type").and_then(|t| t.as_str()) != Some("reasoning")
|
||||||
|
|| item
|
||||||
|
.get("encrypted_content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.is_some_and(|s| !s.is_empty())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
|
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
|
||||||
/// (`max`): remove it so response deserialization succeeds. The turn's
|
/// (`max`): remove it so response deserialization succeeds. The turn's
|
||||||
/// canonical effort lives in the session sampling config regardless; only
|
/// canonical effort lives in the session sampling config regardless; only
|
||||||
@@ -1179,15 +1260,21 @@ pub enum ChatCompat {
|
|||||||
DeepSeek,
|
DeepSeek,
|
||||||
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
|
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
|
||||||
Passthrough,
|
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`).
|
/// out-of-schema request field with a 4xx (`additionalProperties:false`).
|
||||||
/// kigi injects `stream_options.include_usage` on every streaming
|
/// kigi injects `stream_options.include_usage` on every streaming
|
||||||
/// request, which such validators reject, so it is stripped (streaming
|
/// request, which such validators reject, so it is stripped (streaming
|
||||||
/// usage falls back to token estimation). `reasoning_effort` passes
|
/// usage falls back to token estimation). `reasoning_effort` passes
|
||||||
/// through; private message fields are stripped like Passthrough.
|
/// through; private message fields are stripped like Passthrough.
|
||||||
/// (Serde alias `mistral` keeps sessions persisted before the rename.)
|
|
||||||
#[serde(alias = "mistral")]
|
|
||||||
StrictOpenAi,
|
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";
|
pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
|
||||||
@@ -1551,6 +1638,98 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// The Codex backend rejects `role: system` input outright
|
||||||
|
/// (400 `{"detail":"System messages are not allowed"}`) — its system
|
||||||
|
/// channel is the top-level `instructions` field, and stateless
|
||||||
|
/// (`store: false`) reasoning replay needs
|
||||||
|
/// `include: ["reasoning.encrypted_content"]`. The adapter must hoist
|
||||||
|
/// every system item (string or parts content, order preserved),
|
||||||
|
/// append to existing instructions, and leave the rest of the input
|
||||||
|
/// untouched.
|
||||||
|
#[test]
|
||||||
|
fn codex_adapter_hoists_system_messages_and_requests_encrypted_reasoning() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gpt-5.2-codex",
|
||||||
|
"instructions": "base",
|
||||||
|
"input": [
|
||||||
|
{"type": "message", "role": "system", "content": "sys head"},
|
||||||
|
{"type": "message", "role": "user", "content": "hello"},
|
||||||
|
{"type": "message", "role": "system", "content": [
|
||||||
|
{"type": "input_text", "text": "memory reminder"}
|
||||||
|
]},
|
||||||
|
{"type": "message", "role": "assistant", "content": "hi"}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
adapt_body_for_codex_backend(&mut body);
|
||||||
|
|
||||||
|
let input = body["input"].as_array().unwrap();
|
||||||
|
assert_eq!(input.len(), 2, "system items removed from input: {body:#}");
|
||||||
|
assert!(
|
||||||
|
input.iter().all(|i| i["role"] != "system"),
|
||||||
|
"no system role may remain: {body:#}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["instructions"], "base\n\nsys head\n\nmemory reminder",
|
||||||
|
"system content hoisted into instructions, order preserved"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["include"],
|
||||||
|
json!(["reasoning.encrypted_content"]),
|
||||||
|
"stateless reasoning replay requires the include"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Idempotent: a second pass changes nothing.
|
||||||
|
let before = body.clone();
|
||||||
|
adapt_body_for_codex_backend(&mut body);
|
||||||
|
assert_eq!(body, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateless codex cannot resolve a bare `rs_*` reference: reasoning
|
||||||
|
/// input items without an encrypted payload are dropped; items WITH
|
||||||
|
/// one pass through untouched.
|
||||||
|
#[test]
|
||||||
|
fn codex_adapter_drops_reasoning_without_encrypted_payload() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gpt-5.2-codex",
|
||||||
|
"input": [
|
||||||
|
{"type": "message", "role": "user", "content": "q"},
|
||||||
|
{"type": "reasoning", "id": "rs_bare", "summary": []},
|
||||||
|
{"type": "reasoning", "id": "rs_full", "summary": [],
|
||||||
|
"encrypted_content": "gAAAA-blob"},
|
||||||
|
{"type": "message", "role": "assistant", "content": "a"}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
adapt_body_for_codex_backend(&mut body);
|
||||||
|
let input = body["input"].as_array().unwrap();
|
||||||
|
assert_eq!(input.len(), 3, "bare rs_* item dropped: {body:#}");
|
||||||
|
assert!(
|
||||||
|
input
|
||||||
|
.iter()
|
||||||
|
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_full")),
|
||||||
|
"encrypted reasoning passes through: {body:#}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!input
|
||||||
|
.iter()
|
||||||
|
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_bare")),
|
||||||
|
"{body:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No system items and no prior instructions: input untouched, no
|
||||||
|
/// empty-string instructions invented, include still requested.
|
||||||
|
#[test]
|
||||||
|
fn codex_adapter_without_system_messages_only_adds_include() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gpt-5.2-codex",
|
||||||
|
"input": [{"type": "message", "role": "user", "content": "q"}]
|
||||||
|
});
|
||||||
|
adapt_body_for_codex_backend(&mut body);
|
||||||
|
assert!(body.get("instructions").is_none(), "{body:#}");
|
||||||
|
assert_eq!(body["input"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
|
||||||
|
}
|
||||||
|
|
||||||
/// String content (the only shape non-Mistral providers send) stays the
|
/// String content (the only shape non-Mistral providers send) stays the
|
||||||
/// answer verbatim with no thinking — byte-identical to the pre-change
|
/// answer verbatim with no thinking — byte-identical to the pre-change
|
||||||
/// deserialization.
|
/// deserialization.
|
||||||
@@ -1558,9 +1737,15 @@ mod tests {
|
|||||||
/// persisted before the rename still deserialize.
|
/// persisted before the rename still deserialize.
|
||||||
#[test]
|
#[test]
|
||||||
fn chat_compat_mistral_alias_deserializes_to_strict_openai() {
|
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();
|
let v: ChatCompat = serde_json::from_str("\"mistral\"").unwrap();
|
||||||
assert_eq!(v, ChatCompat::StrictOpenAi);
|
assert_eq!(v, ChatCompat::Mistral);
|
||||||
// New value round-trips as strict_open_ai.
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ChatCompat::Mistral).unwrap(),
|
||||||
|
"\"mistral\""
|
||||||
|
);
|
||||||
let v: ChatCompat = serde_json::from_str("\"strict_open_ai\"").unwrap();
|
let v: ChatCompat = serde_json::from_str("\"strict_open_ai\"").unwrap();
|
||||||
assert_eq!(v, ChatCompat::StrictOpenAi);
|
assert_eq!(v, ChatCompat::StrictOpenAi);
|
||||||
assert_eq!(
|
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 event_id;
|
||||||
|
pub mod fs;
|
||||||
pub mod kigi_home;
|
pub mod kigi_home;
|
||||||
pub mod secure_file;
|
pub mod secure_file;
|
||||||
pub mod tips;
|
pub mod tips;
|
||||||
|
|||||||
@@ -109,21 +109,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
t = asset_triple
|
t = asset_triple
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Transient CDN hiccups (502/503/timeouts) are retried with backoff: a
|
||||||
|
// single flaky response must not kill a ~50-minute release build (it
|
||||||
|
// did — the v0.1.5 tag build failed on one 502). Genuine failures
|
||||||
|
// (404, offline) still error out with the offline-build hint.
|
||||||
let bytes: Vec<u8> = {
|
let bytes: Vec<u8> = {
|
||||||
let resp = reqwest::blocking::get(&url).map_err(|e| {
|
let mut last_err = String::new();
|
||||||
format!(
|
let mut bytes = None;
|
||||||
"Failed to download ripgrep: {}\nSet KIGI_SHELL_BUNDLE_RG_PATH to a local rg for offline builds.",
|
for (attempt, backoff_secs) in [0u64, 2, 8].into_iter().enumerate() {
|
||||||
e
|
if backoff_secs > 0 {
|
||||||
)
|
std::thread::sleep(std::time::Duration::from_secs(backoff_secs));
|
||||||
})?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!(
|
|
||||||
"HTTP {} downloading ripgrep. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.",
|
|
||||||
resp.status()
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
}
|
||||||
resp.bytes()?.to_vec()
|
match reqwest::blocking::get(&url) {
|
||||||
|
Ok(resp) if resp.status().is_success() => match resp.bytes() {
|
||||||
|
Ok(b) => {
|
||||||
|
bytes = Some(b.to_vec());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => last_err = format!("reading ripgrep body: {e}"),
|
||||||
|
},
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
last_err = format!("HTTP {status} downloading ripgrep");
|
||||||
|
// Only server-side/transient statuses are worth retrying.
|
||||||
|
if !(status.is_server_error() || status.as_u16() == 429) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => last_err = format!("Failed to download ripgrep: {e}"),
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"cargo:warning=ripgrep download attempt {} failed: {last_err}",
|
||||||
|
attempt + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
bytes.ok_or_else(|| {
|
||||||
|
format!("{last_err}. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.")
|
||||||
|
})?
|
||||||
};
|
};
|
||||||
|
|
||||||
let gz = flate2::read::GzDecoder::new(&bytes[..]);
|
let gz = flate2::read::GzDecoder::new(&bytes[..]);
|
||||||
|
|||||||
@@ -170,9 +170,7 @@ fn write_data_file_atomic(
|
|||||||
let json = serde_json::to_string_pretty(sessions)
|
let json = serde_json::to_string_pretty(sessions)
|
||||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
fs::write(tmp_path, json.as_bytes())?;
|
fs::write(tmp_path, json.as_bytes())?;
|
||||||
fs::rename(tmp_path, data_path).inspect_err(|_| {
|
crate::util::fs::replace_file(tmp_path, data_path)
|
||||||
let _ = fs::remove_file(tmp_path);
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_pid_alive(pid: u32) -> bool {
|
fn is_pid_alive(pid: u32) -> bool {
|
||||||
|
|||||||
@@ -1881,12 +1881,17 @@ impl Config {
|
|||||||
.default(true)
|
.default(true)
|
||||||
.resolve()
|
.resolve()
|
||||||
}
|
}
|
||||||
/// Graph mode (`/graph`) master switch. Default OFF — gray-released via
|
/// Graph mode (`/graph`) master switch. Default ON in the binary: the
|
||||||
/// `KIGI_GRAPH=1` only (plan.md G0 gate). Graph mode additionally
|
/// README ships graph engineering enabled for every install, but the
|
||||||
/// requires the goal harness (nodes execute as goals), enforced at
|
/// old `default(false)` delegated enablement to installer env plumbing
|
||||||
/// availability time, not here.
|
/// (`install.sh` shell-rc export vs `install.ps1` registry write) — and
|
||||||
|
/// Windows terminals don't pick up freshly-written registry env, so
|
||||||
|
/// `/graph` went "missing on Windows". The product default lives HERE,
|
||||||
|
/// not in installers. `KIGI_GRAPH=0` is the off-switch. Graph mode
|
||||||
|
/// additionally requires the goal harness (nodes execute as goals),
|
||||||
|
/// enforced at availability time, not here.
|
||||||
pub(crate) fn resolve_graph(&self) -> Resolved<bool> {
|
pub(crate) fn resolve_graph(&self) -> Resolved<bool> {
|
||||||
BoolFlag::env("KIGI_GRAPH").default(false).resolve()
|
BoolFlag::env("KIGI_GRAPH").default(true).resolve()
|
||||||
}
|
}
|
||||||
/// Max graph nodes running concurrently (`KIGI_GRAPH_CONCURRENCY`).
|
/// Max graph nodes running concurrently (`KIGI_GRAPH_CONCURRENCY`).
|
||||||
/// 1 = serial (G0-identical); clamped to [1, 8] — the coordinator has
|
/// 1 = serial (G0-identical); clamped to [1, 8] — the coordinator has
|
||||||
@@ -4094,8 +4099,12 @@ pub fn sampling_config_for_model(
|
|||||||
&credentials.base_url,
|
&credentials.base_url,
|
||||||
);
|
);
|
||||||
let api_backend = info.api_backend.clone();
|
let api_backend = info.api_backend.clone();
|
||||||
// Managed platform entries speak their registry dialect; BYOK/custom
|
// Managed platform entries speak their registry dialect. BYOK/custom
|
||||||
// entries keep the historical Kimi body adaptation.
|
// 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
|
let chat_compat = info
|
||||||
.id
|
.id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -4109,8 +4118,15 @@ pub fn sampling_config_for_model(
|
|||||||
kigi_models::PlatformChatCompat::StrictOpenAi => {
|
kigi_models::PlatformChatCompat::StrictOpenAi => {
|
||||||
kigi_sampling_types::ChatCompat::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
|
// Claude Pro/Max OAuth Messages adaptation: a managed key whose platform is
|
||||||
// a generic-OAuth Messages provider (claude-pro-max) drives the OAuth
|
// a generic-OAuth Messages provider (claude-pro-max) drives the OAuth
|
||||||
// identity headers + "You are Claude Code" system prefix in the sampler.
|
// 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`):
|
/// Catalog key of the bundled fallback default (`default_models.json`):
|
||||||
/// `{platform_id}/{model_id}` for `crate::models::default_model()`.
|
/// `{platform_id}/{model_id}` for `crate::models::default_model()`.
|
||||||
const BUNDLED_DEFAULT_KEY: &str = "kimi-code/kimi-for-coding";
|
const BUNDLED_DEFAULT_KEY: &str = "kimi-code/kimi-for-coding";
|
||||||
|
|
||||||
|
/// `/graph` ships ON by default: the G0 gray release (`KIGI_GRAPH=1`
|
||||||
|
/// only) made the command exist solely on machines with the dev env
|
||||||
|
/// var — which read as "missing on Windows". A fresh install with no
|
||||||
|
/// env must resolve `true`; `KIGI_GRAPH=0` stays the off-switch.
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn graph_defaults_on_and_env_zero_disables() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
{
|
||||||
|
let _unset = EnvGuard::unset("KIGI_GRAPH");
|
||||||
|
assert!(cfg.resolve_graph().value, "fresh install must offer /graph");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let _off = EnvGuard::set("KIGI_GRAPH", "0");
|
||||||
|
assert!(!cfg.resolve_graph().value, "KIGI_GRAPH=0 must disable");
|
||||||
|
}
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn main_cli_tools_override_preserves_profile_injection_policy() {
|
fn main_cli_tools_override_preserves_profile_injection_policy() {
|
||||||
let overrides = CliAgentOverrides {
|
let overrides = CliAgentOverrides {
|
||||||
@@ -5940,6 +5974,72 @@ reasoning_effort = "low"
|
|||||||
"agentType should always be in meta, defaulting to DEFAULT_AGENT_TYPE"
|
"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
|
/// Managed `{platform}/{model}` entries stamp `meta.provider` with the
|
||||||
/// platform's display name so the client's model picker can say which
|
/// platform's display name so the client's model picker can say which
|
||||||
/// connected provider each model belongs to. User-defined `[model.*]`
|
/// connected provider each model belongs to. User-defined `[model.*]`
|
||||||
|
|||||||
@@ -1637,16 +1637,31 @@ impl ModelsCacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync; see `load_fresh` note.
|
/// Unique tmp suffix (PID + nanos) so concurrent writers never share an
|
||||||
|
/// inode (mirrors `util::config::persist`).
|
||||||
|
fn tmp_path(&self) -> std::path::PathBuf {
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.path
|
||||||
|
.with_extension(format!("json.tmp.{}.{}", std::process::id(), nanos))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync; see `load_fresh` note. Best-effort, but NEVER silent: a failed
|
||||||
|
/// cache write leaves a stale catalog on disk, which on Windows (sharing
|
||||||
|
/// violations) previously diverged picker behavior with zero trace.
|
||||||
fn atomic_write(&self, cache: &ModelsCache) {
|
fn atomic_write(&self, cache: &ModelsCache) {
|
||||||
if let Some(parent) = self.path.parent() {
|
if let Some(parent) = self.path.parent() {
|
||||||
let _ = std::fs::create_dir_all(parent);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
let tmp = self.path.with_extension("json.tmp");
|
let tmp = self.tmp_path();
|
||||||
if let Ok(json) = serde_json::to_vec_pretty(cache)
|
let result = serde_json::to_vec_pretty(cache)
|
||||||
&& std::fs::write(&tmp, &json).is_ok()
|
.map_err(std::io::Error::other)
|
||||||
{
|
.and_then(|json| std::fs::write(&tmp, &json))
|
||||||
let _ = std::fs::rename(&tmp, &self.path);
|
.and_then(|()| crate::util::fs::replace_file(&tmp, &self.path));
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::warn!(error = %e, path = %self.path.display(), "models cache write failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1654,12 +1669,25 @@ impl ModelsCacheManager {
|
|||||||
if let Some(parent) = self.path.parent() {
|
if let Some(parent) = self.path.parent() {
|
||||||
let _ = tokio::fs::create_dir_all(parent).await;
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
}
|
}
|
||||||
let tmp = self.path.with_extension("json.tmp");
|
let tmp = self.tmp_path();
|
||||||
let Ok(json) = serde_json::to_vec_pretty(cache) else {
|
let json = match serde_json::to_vec_pretty(cache) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "models cache serialize failed");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if tokio::fs::write(&tmp, &json).await.is_ok() {
|
let result = match tokio::fs::write(&tmp, &json).await {
|
||||||
let _ = tokio::fs::rename(&tmp, &self.path).await;
|
Ok(()) => {
|
||||||
|
let dest = self.path.clone();
|
||||||
|
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| Err(std::io::Error::other(e)))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
};
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::warn!(error = %e, path = %self.path.display(), "models cache write failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1841,8 +1841,9 @@ mod tests {
|
|||||||
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
|
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.chat_compat,
|
cfg.chat_compat,
|
||||||
kigi_sampling_types::ChatCompat::StrictOpenAi,
|
kigi_sampling_types::ChatCompat::Mistral,
|
||||||
"mistral entries use the StrictOpenAi dialect (stream_options strip)"
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomic write: tmp + rename. Unix `rename(2)` replaces atomically;
|
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`,
|
||||||
/// Windows `rename` requires removing the target first.
|
/// which this site's inline delete-first pattern graduated into).
|
||||||
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
||||||
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
|
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
|
||||||
write_store_to(&tmp, auth_store)?;
|
write_store_to(&tmp, auth_store)?;
|
||||||
#[cfg(windows)]
|
crate::util::fs::replace_file(&tmp, auth_file)
|
||||||
{
|
|
||||||
let _ = std::fs::remove_file(auth_file);
|
|
||||||
}
|
|
||||||
std::fs::rename(&tmp, auth_file)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-atomic fallback: truncate and rewrite `auth.json` in place.
|
/// Non-atomic fallback: truncate and rewrite `auth.json` in place.
|
||||||
|
|||||||
@@ -670,10 +670,7 @@ fn write_import_marker(config_path: &Path) -> anyhow::Result<()> {
|
|||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::rename(&tmp, config_path) {
|
crate::util::fs::replace_file(&tmp, config_path)?;
|
||||||
let _ = std::fs::remove_file(&tmp);
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,7 +851,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
|
|||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
std::fs::write(&tmp, &toml_str)?;
|
std::fs::write(&tmp, &toml_str)?;
|
||||||
std::fs::rename(&tmp, config_path)?;
|
crate::util::fs::replace_file(&tmp, config_path)?;
|
||||||
info!(
|
info!(
|
||||||
path = %config_path.display(),
|
path = %config_path.display(),
|
||||||
count,
|
count,
|
||||||
@@ -1204,7 +1201,7 @@ fn apply_hooks_to_dir(hooks_dir: &Path, items: &[ImportableItem]) -> anyhow::Res
|
|||||||
let json_str = serde_json::to_string_pretty(&root)?;
|
let json_str = serde_json::to_string_pretty(&root)?;
|
||||||
let tmp = target.with_extension("json.tmp");
|
let tmp = target.with_extension("json.tmp");
|
||||||
std::fs::write(&tmp, &json_str)?;
|
std::fs::write(&tmp, &json_str)?;
|
||||||
std::fs::rename(&tmp, &target)?;
|
crate::util::fs::replace_file(&tmp, &target)?;
|
||||||
info!(
|
info!(
|
||||||
path = %target.display(),
|
path = %target.display(),
|
||||||
count,
|
count,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ pub fn save_import_state(state: &ImportState) -> std::io::Result<()> {
|
|||||||
// `claude_import_state.json.tmp` (the last extension is replaced).
|
// `claude_import_state.json.tmp` (the last extension is replaced).
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
std::fs::write(&tmp, &json)?;
|
std::fs::write(&tmp, &json)?;
|
||||||
std::fs::rename(&tmp, &path)?;
|
crate::util::fs::replace_file(&tmp, &path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -528,10 +528,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
|
|||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::rename(&tmp, &config_path) {
|
crate::util::fs::replace_file(&tmp, &config_path)?;
|
||||||
let _ = std::fs::remove_file(&tmp);
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
info!(
|
info!(
|
||||||
path = %config_path.display(),
|
path = %config_path.display(),
|
||||||
added = applied.total_added(),
|
added = applied.total_added(),
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@ fn persist_chat_history_jsonl_sync(session_info: &SessionInfo, conversation: &[C
|
|||||||
buf.push(b'\n');
|
buf.push(b'\n');
|
||||||
}
|
}
|
||||||
std::fs::File::create(&tmp_path)?.write_all(&buf)?;
|
std::fs::File::create(&tmp_path)?.write_all(&buf)?;
|
||||||
std::fs::rename(&tmp_path, &final_path)?;
|
crate::util::fs::replace_file(&tmp_path, &final_path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
|
|||||||
@@ -594,10 +594,10 @@ async fn write_patch_file_atomic(path: &Path, body: &str) -> std::io::Result<()>
|
|||||||
.unwrap_or("goal-classifier.patch");
|
.unwrap_or("goal-classifier.patch");
|
||||||
let tmp = dir.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()));
|
let tmp = dir.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()));
|
||||||
tokio::fs::write(&tmp, body).await?;
|
tokio::fs::write(&tmp, body).await?;
|
||||||
if let Err(err) = tokio::fs::rename(&tmp, path).await {
|
let dest = path.to_path_buf();
|
||||||
let _ = tokio::fs::remove_file(&tmp).await;
|
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||||
return Err(err);
|
.await
|
||||||
}
|
.map_err(std::io::Error::other)??;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -895,7 +895,8 @@ impl GoalTracker {
|
|||||||
};
|
};
|
||||||
let dest = goal_dir.join(name);
|
let dest = goal_dir.join(name);
|
||||||
let _ = std::fs::create_dir_all(&goal_dir);
|
let _ = std::fs::create_dir_all(&goal_dir);
|
||||||
if std::fs::rename(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok() {
|
if crate::util::fs::replace_file(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok()
|
||||||
|
{
|
||||||
append_skeptic_reports(&scratch_root, &dest);
|
append_skeptic_reports(&scratch_root, &dest);
|
||||||
o.last_classifier_details_path = Some(dest.to_string_lossy().into_owned());
|
o.last_classifier_details_path = Some(dest.to_string_lossy().into_owned());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ pub fn project(dir: &Path, state: &GraphOrchestration) -> std::io::Result<()> {
|
|||||||
f.write_all(&buf)?;
|
f.write_all(&buf)?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
}
|
}
|
||||||
std::fs::rename(&tmp, &target)
|
crate::util::fs::replace_file(&tmp, &target)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the projected graph, `Ok(None)` when absent. Malformed content
|
/// Load the projected graph, `Ok(None)` when absent. Malformed content
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ pub fn truncate_if_needed(cwd: &str) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::rename(temp_path, path)?;
|
crate::util::fs::replace_file(&temp_path, &path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ use std::fs::OpenOptions;
|
|||||||
use std::io::{self, Read};
|
use std::io::{self, Read};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
/// Commit `tmp` over `target` with the shared Windows-safe replace
|
||||||
|
/// (`util::fs::replace_file`): a bare async rename silently lost session
|
||||||
|
/// state — including the switched model — on Windows whenever AV/indexer
|
||||||
|
/// held the destination open.
|
||||||
|
async fn replace_file_async(tmp: PathBuf, target: PathBuf) -> io::Result<()> {
|
||||||
|
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &target))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| Err(io::Error::other(e)))
|
||||||
|
}
|
||||||
/// How the adapter resolves the session directory on disk.
|
/// How the adapter resolves the session directory on disk.
|
||||||
///
|
///
|
||||||
/// - `FromRoot` (default): computes `{root}/sessions/{urlencoded(cwd)}/{session_id}/`
|
/// - `FromRoot` (default): computes `{root}/sessions/{urlencoded(cwd)}/{session_id}/`
|
||||||
@@ -289,7 +299,7 @@ impl JsonlStorageAdapter {
|
|||||||
}
|
}
|
||||||
let tmp = path.with_extension("jsonl.tmp");
|
let tmp = path.with_extension("jsonl.tmp");
|
||||||
tokio::fs::write(&tmp, &content).await?;
|
tokio::fs::write(&tmp, &content).await?;
|
||||||
tokio::fs::rename(&tmp, &path).await
|
replace_file_async(tmp, path).await
|
||||||
}
|
}
|
||||||
fn read_jsonl<T: serde::de::DeserializeOwned>(&self, path: PathBuf) -> io::Result<Vec<T>> {
|
fn read_jsonl<T: serde::de::DeserializeOwned>(&self, path: PathBuf) -> io::Result<Vec<T>> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -378,7 +388,7 @@ impl JsonlStorageAdapter {
|
|||||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
let tmp = summary_path.with_extension("json.tmp");
|
let tmp = summary_path.with_extension("json.tmp");
|
||||||
std::fs::write(&tmp, &bytes)?;
|
std::fs::write(&tmp, &bytes)?;
|
||||||
std::fs::rename(&tmp, &summary_path)
|
crate::util::fs::replace_file(&tmp, &summary_path)
|
||||||
}
|
}
|
||||||
fn read_summary_sync(&self, info: &Info) -> io::Result<Summary> {
|
fn read_summary_sync(&self, info: &Info) -> io::Result<Summary> {
|
||||||
let path = self.summary_file(info);
|
let path = self.summary_file(info);
|
||||||
@@ -1057,7 +1067,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
let target = self.plan_mode_state_file(info);
|
let target = self.plan_mode_state_file(info);
|
||||||
let tmp = target.with_extension("json.tmp");
|
let tmp = target.with_extension("json.tmp");
|
||||||
tokio::fs::write(&tmp, json).await?;
|
tokio::fs::write(&tmp, json).await?;
|
||||||
tokio::fs::rename(&tmp, &target).await
|
replace_file_async(tmp, target).await
|
||||||
}
|
}
|
||||||
async fn write_signals(
|
async fn write_signals(
|
||||||
&self,
|
&self,
|
||||||
@@ -1069,7 +1079,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
let target = self.signals_file(info);
|
let target = self.signals_file(info);
|
||||||
let tmp = target.with_extension("json.tmp");
|
let tmp = target.with_extension("json.tmp");
|
||||||
tokio::fs::write(&tmp, signals_json).await?;
|
tokio::fs::write(&tmp, signals_json).await?;
|
||||||
tokio::fs::rename(&tmp, &target).await
|
replace_file_async(tmp, target).await
|
||||||
}
|
}
|
||||||
async fn write_announcement_state(
|
async fn write_announcement_state(
|
||||||
&self,
|
&self,
|
||||||
@@ -1081,7 +1091,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
let target = self.announcement_state_file(info);
|
let target = self.announcement_state_file(info);
|
||||||
let tmp = target.with_extension("json.tmp");
|
let tmp = target.with_extension("json.tmp");
|
||||||
tokio::fs::write(&tmp, json).await?;
|
tokio::fs::write(&tmp, json).await?;
|
||||||
tokio::fs::rename(&tmp, &target).await
|
replace_file_async(tmp, target).await
|
||||||
}
|
}
|
||||||
async fn write_goal_mode_state(
|
async fn write_goal_mode_state(
|
||||||
&self,
|
&self,
|
||||||
@@ -1096,7 +1106,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
}
|
}
|
||||||
let tmp = target.with_extension("json.tmp");
|
let tmp = target.with_extension("json.tmp");
|
||||||
tokio::fs::write(&tmp, json).await?;
|
tokio::fs::write(&tmp, json).await?;
|
||||||
tokio::fs::rename(&tmp, &target).await
|
replace_file_async(tmp, target).await
|
||||||
}
|
}
|
||||||
async fn write_graph_mode_state(
|
async fn write_graph_mode_state(
|
||||||
&self,
|
&self,
|
||||||
@@ -1119,7 +1129,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
}
|
}
|
||||||
let tmp = target.with_extension("json.tmp");
|
let tmp = target.with_extension("json.tmp");
|
||||||
tokio::fs::write(&tmp, json).await?;
|
tokio::fs::write(&tmp, json).await?;
|
||||||
tokio::fs::rename(&tmp, &target).await
|
replace_file_async(tmp, target).await
|
||||||
}
|
}
|
||||||
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
|
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
|
||||||
let summary = self.read_summary_sync(info)?;
|
let summary = self.read_summary_sync(info)?;
|
||||||
|
|||||||
@@ -230,7 +230,10 @@ fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()
|
|||||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
let tmp = summary_path.with_extension("json.tmp");
|
let tmp = summary_path.with_extension("json.tmp");
|
||||||
std::fs::write(&tmp, &bytes)?;
|
std::fs::write(&tmp, &bytes)?;
|
||||||
std::fs::rename(&tmp, summary_path)
|
// Windows-safe replace: this is the write that persists a session's
|
||||||
|
// CURRENT MODEL — a bare rename made a switched model silently revert
|
||||||
|
// on resume whenever AV/indexer held summary.json open on Windows.
|
||||||
|
crate::util::fs::replace_file(&tmp, summary_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -114,9 +114,7 @@ fn dismiss_campaign_ids_at(
|
|||||||
let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
|
let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
|
||||||
let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce));
|
let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce));
|
||||||
std::fs::write(&tmp, &json)?;
|
std::fs::write(&tmp, &json)?;
|
||||||
std::fs::rename(&tmp, &path).inspect_err(|_| {
|
crate::util::fs::replace_file(&tmp, &path)
|
||||||
let _ = std::fs::remove_file(&tmp);
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `KIGI_CAMPAIGNS_OVERRIDE` JSON array replaces all sources (`[]` = none; beats
|
/// `KIGI_CAMPAIGNS_OVERRIDE` JSON array replaces all sources (`[]` = none; beats
|
||||||
|
|||||||
@@ -367,7 +367,9 @@ pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String
|
|||||||
let _ = tokio::fs::create_dir_all(parent).await;
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
}
|
}
|
||||||
tokio::fs::write(&tmp, &toml_str).await?;
|
tokio::fs::write(&tmp, &toml_str).await?;
|
||||||
tokio::fs::rename(&tmp, &path).await?;
|
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)??;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +421,9 @@ pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result
|
|||||||
let _ = tokio::fs::create_dir_all(parent).await;
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
}
|
}
|
||||||
tokio::fs::write(&tmp, &toml_str).await?;
|
tokio::fs::write(&tmp, &toml_str).await?;
|
||||||
tokio::fs::rename(&tmp, &path).await?;
|
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)??;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +480,10 @@ pub async fn save_mcp_server_config_at(
|
|||||||
let _ = tokio::fs::create_dir_all(parent).await;
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
}
|
}
|
||||||
tokio::fs::write(&tmp, &toml_str).await?;
|
tokio::fs::write(&tmp, &toml_str).await?;
|
||||||
tokio::fs::rename(&tmp, &path).await?;
|
let dest = path.to_path_buf();
|
||||||
|
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)??;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +560,12 @@ pub async fn delete_mcp_server_config_at(
|
|||||||
let _ = tokio::fs::create_dir_all(parent).await;
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
}
|
}
|
||||||
tokio::fs::write(&tmp, &toml_str).await?;
|
tokio::fs::write(&tmp, &toml_str).await?;
|
||||||
tokio::fs::rename(&tmp, &path).await?;
|
{
|
||||||
|
let dest = path.to_path_buf();
|
||||||
|
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)??;
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up OAuth credentials for the deleted server.
|
// Clean up OAuth credentials for the deleted server.
|
||||||
if let Ok(mut cred_store) = kigi_mcp::credentials::McpCredentialStore::load_default() {
|
if let Ok(mut cred_store) = kigi_mcp::credentials::McpCredentialStore::load_default() {
|
||||||
|
|||||||
@@ -88,7 +88,12 @@ pub async fn save_config(config: &Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
let _ = prior_mode;
|
let _ = prior_mode;
|
||||||
|
|
||||||
tokio::fs::rename(&tmp, &path).await?;
|
// Windows-safe replace (delete-first + retry on sharing violations) —
|
||||||
|
// a bare rename made `/model` persistence silently fail on Windows
|
||||||
|
// whenever AV/indexer/cloud-sync held config.toml open.
|
||||||
|
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("config replace task: {e}"))??;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,11 +143,8 @@ pub(crate) fn atomic_write_string(path: &std::path::Path, content: &str) -> std:
|
|||||||
}
|
}
|
||||||
let _ = prior_mode;
|
let _ = prior_mode;
|
||||||
|
|
||||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
// Windows-safe replace; cleans up the tmp file on failure itself.
|
||||||
let _ = std::fs::remove_file(&tmp);
|
crate::util::fs::replace_file(&tmp, path)
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
|
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
|
||||||
|
|||||||
@@ -472,7 +472,12 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
|
|||||||
"\n",
|
"\n",
|
||||||
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
|
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
|
||||||
"\n",
|
"\n",
|
||||||
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy kigi reasoning","encrypted":"ENC_BLOB_xyz","id":"rs_kigibuild_legacy"},"model_id":"kigi"}"#,
|
// model_id matches the test client's request model: this test
|
||||||
|
// covers the SAME-MODEL continuation (the byte-stable
|
||||||
|
// SGLang-prefix path). A mismatched model_id is the provenance
|
||||||
|
// gate's territory (`transform_items_for_responses`) and drops
|
||||||
|
// the reasoning by design.
|
||||||
|
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy kigi reasoning","encrypted":"ENC_BLOB_xyz","id":"rs_kigibuild_legacy"},"model_id":"test-model"}"#,
|
||||||
"\n",
|
"\n",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -535,13 +540,20 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
|
|||||||
/// Upgrade path, Anthropic Messages API: a legacy session whose assistant
|
/// Upgrade path, Anthropic Messages API: a legacy session whose assistant
|
||||||
/// carries inline `reasoning: {text, encrypted, id}` (text = thinking,
|
/// carries inline `reasoning: {text, encrypted, id}` (text = thinking,
|
||||||
/// encrypted = signature) must, on load, reconstruct a sibling Reasoning
|
/// encrypted = signature) must, on load, reconstruct a sibling Reasoning
|
||||||
/// item that emits a Anthropic Messages `thinking` content block (with `thinking`
|
/// item — and when that turn is the ACTIVE tool-use continuation, its
|
||||||
/// + `signature`) on the outgoing `/v1/messages` request.
|
/// `thinking` block (text + signature) must reach the outgoing
|
||||||
|
/// `/v1/messages` request verbatim.
|
||||||
|
///
|
||||||
|
/// Outside an active tool loop the block must be STRIPPED: Anthropic
|
||||||
|
/// validates every replayed signature (model-bound), so replaying stale
|
||||||
|
/// thinking is exactly what 400'd with "Invalid `signature` in `thinking`
|
||||||
|
/// block" after cross-model histories (see `prune_replayed_thinking`).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loop() {
|
||||||
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl. Anthropic Messages
|
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl whose
|
||||||
// thinking blocks never carried an id (stream/messages.rs sets
|
// assistant turn issued a tool call (thinking blocks never carried an
|
||||||
// id=""), and the signature lives in `encrypted`.
|
// id — stream/messages.rs sets id="" — and the signature lives in
|
||||||
|
// `encrypted`). The pending tool_result makes this the active loop.
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
dir.path().join("chat_history.jsonl"),
|
dir.path().join("chat_history.jsonl"),
|
||||||
@@ -550,7 +562,9 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
|||||||
"\n",
|
"\n",
|
||||||
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
|
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
|
||||||
"\n",
|
"\n",
|
||||||
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5"}"#,
|
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5","tool_calls":[{"id":"tc1","name":"read_file","arguments":"{}"}]}"#,
|
||||||
|
"\n",
|
||||||
|
r#"{"type":"tool_result","tool_call_id":"tc1","content":"file contents"}"#,
|
||||||
"\n",
|
"\n",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -558,7 +572,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
|||||||
|
|
||||||
// 2. Load + upgrade.
|
// 2. Load + upgrade.
|
||||||
let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf());
|
let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf());
|
||||||
let mut items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
|
let items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -566,20 +580,18 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
|||||||
"legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}"
|
"legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Continue and send over the Messages API, capturing the body.
|
// 3. Send the tool-loop continuation over the Messages API.
|
||||||
items.push(ConversationItem::user("q2"));
|
|
||||||
|
|
||||||
let server = MockInferenceServer::start().await.unwrap();
|
let server = MockInferenceServer::start().await.unwrap();
|
||||||
server.set_response("ok");
|
server.set_response("ok");
|
||||||
let client = create_test_client(&server.url(), ApiBackend::Messages);
|
let client = create_test_client(&server.url(), ApiBackend::Messages);
|
||||||
|
|
||||||
let _ = client
|
let _ = client
|
||||||
.conversation_collect(ConversationRequest::from_items(items))
|
.conversation_collect(ConversationRequest::from_items(items.clone()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// 4. The reconstructed reasoning must emit a Anthropic Messages `thinking`
|
// 4. The active loop's reconstructed reasoning must emit an Anthropic
|
||||||
// content block carrying the thinking text + signature.
|
// `thinking` content block carrying the thinking text + signature.
|
||||||
let body = server.request_bodies().pop().unwrap();
|
let body = server.request_bodies().pop().unwrap();
|
||||||
let messages = body.get("messages").unwrap().as_array().unwrap();
|
let messages = body.get("messages").unwrap().as_array().unwrap();
|
||||||
let thinking_block = messages
|
let thinking_block = messages
|
||||||
@@ -593,7 +605,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
|||||||
})
|
})
|
||||||
.find(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
|
.find(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
panic!("reconstructed reasoning must emit an Anthropic thinking block; messages: {messages:#?}")
|
panic!("active-loop reasoning must emit an Anthropic thinking block; messages: {messages:#?}")
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
thinking_block.get("thinking").and_then(Value::as_str),
|
thinking_block.get("thinking").and_then(Value::as_str),
|
||||||
@@ -605,6 +617,25 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
|||||||
Some("SIGNATURE_abc"),
|
Some("SIGNATURE_abc"),
|
||||||
"signature (encrypted) preserved — required to reuse the thought server-side"
|
"signature (encrypted) preserved — required to reuse the thought server-side"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 5. A follow-up user turn CLOSES the loop: the same history plus a new
|
||||||
|
// user message must replay NO thinking block at all.
|
||||||
|
let mut closed = items;
|
||||||
|
closed.push(ConversationItem::user("q2"));
|
||||||
|
let _ = client
|
||||||
|
.conversation_collect(ConversationRequest::from_items(closed))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = server.request_bodies().pop().unwrap();
|
||||||
|
let any_thinking = body["messages"].as_array().unwrap().iter().any(|m| {
|
||||||
|
m.get("content")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|c| c.iter().any(|b| b["type"] == "thinking"))
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
!any_thinking,
|
||||||
|
"stale thinking must be stripped outside the active tool loop; body: {body:#?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1443,3 +1474,79 @@ async fn test_chat_completions_backend_hits_chat_endpoint_not_responses() {
|
|||||||
"Should NOT have called /v1/responses"
|
"Should NOT have called /v1/responses"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ChatGPT/Codex backend body contract (`openai_codex = true`): the
|
||||||
|
/// `/codex/responses` endpoint rejects `role: system` input outright
|
||||||
|
/// (400 {"detail":"System messages are not allowed"}) — system content
|
||||||
|
/// must ride the top-level `instructions` field, and stateless reasoning
|
||||||
|
/// replay needs `include: ["reasoning.encrypted_content"]`. Ported from
|
||||||
|
/// the official Codex CLI + Pi's api/openai-codex-responses.ts, like the
|
||||||
|
/// identity headers.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn codex_responses_body_hoists_system_into_instructions() {
|
||||||
|
let server = MockInferenceServer::start().await.unwrap();
|
||||||
|
server.set_response("ok");
|
||||||
|
let mut config = common::test_sampler_config(&server.url(), ApiBackend::Responses, &[]);
|
||||||
|
config.openai_codex = true;
|
||||||
|
let client = Client::new(config).unwrap();
|
||||||
|
|
||||||
|
let _ = client
|
||||||
|
.conversation_collect(ConversationRequest::from_items(vec![
|
||||||
|
ConversationItem::system("You are Kigi."),
|
||||||
|
ConversationItem::user("test"),
|
||||||
|
]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let body = server.request_bodies().pop().unwrap();
|
||||||
|
let input = body["input"].as_array().unwrap();
|
||||||
|
assert!(
|
||||||
|
input
|
||||||
|
.iter()
|
||||||
|
.all(|i| i.get("role").and_then(Value::as_str) != Some("system")),
|
||||||
|
"codex backend must never receive system-role input: {body:#?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["instructions"].as_str(),
|
||||||
|
Some("You are Kigi."),
|
||||||
|
"system prompt must ride the instructions field: {body:#?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["include"],
|
||||||
|
serde_json::json!(["reasoning.encrypted_content"]),
|
||||||
|
"stateless reasoning replay requires the include: {body:#?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Control: the API-key `openai` Responses path (`openai_codex = false`)
|
||||||
|
/// stays byte-compatible — system-role input preserved, no codex fields.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plain_responses_body_keeps_system_role_input() {
|
||||||
|
let server = MockInferenceServer::start().await.unwrap();
|
||||||
|
server.set_response("ok");
|
||||||
|
let client = create_test_client(&server.url(), ApiBackend::Responses);
|
||||||
|
|
||||||
|
let _ = client
|
||||||
|
.conversation_collect(ConversationRequest::from_items(vec![
|
||||||
|
ConversationItem::system("You are Kigi."),
|
||||||
|
ConversationItem::user("test"),
|
||||||
|
]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let body = server.request_bodies().pop().unwrap();
|
||||||
|
assert!(
|
||||||
|
body["input"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|i| i.get("role").and_then(Value::as_str) == Some("system")),
|
||||||
|
"api-key openai keeps system-role input: {body:#?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.get("instructions")
|
||||||
|
.map(|v| v.is_null())
|
||||||
|
.unwrap_or(true),
|
||||||
|
"no instructions hoist outside codex: {body:#?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use super::setters::{
|
use super::setters::{
|
||||||
pr13_effective_default, set_ask_user_question_timeout_enabled_inner, set_auto_dark_theme_inner,
|
pr13_effective_default, set_ask_user_question_timeout_enabled_inner, set_auto_dark_theme_inner,
|
||||||
set_auto_light_theme_inner, set_auto_update_inner, set_collapsed_edit_blocks_inner,
|
set_auto_light_theme_inner, set_auto_update_inner, set_collapsed_edit_blocks_inner,
|
||||||
set_compact_mode, set_compact_mode_inner, set_contextual_hint_inner, set_default_model_inner,
|
set_compact_mode, set_compact_mode_inner, set_contextual_hint_inner,
|
||||||
set_default_selected_permission_inner, set_display_refresh_auto_cadence_inner,
|
set_default_selected_permission_inner, set_display_refresh_auto_cadence_inner,
|
||||||
set_fork_secondary_model_inner, set_group_tool_verbs_inner, set_hunk_tracker_mode_inner,
|
set_fork_secondary_model_inner, set_group_tool_verbs_inner, set_hunk_tracker_mode_inner,
|
||||||
set_invert_scroll_inner, set_keep_text_selection_inner, set_max_thoughts_width_inner,
|
set_invert_scroll_inner, set_keep_text_selection_inner, set_max_thoughts_width_inner,
|
||||||
@@ -844,7 +844,7 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
|
|||||||
rollback_value: &crate::settings::SettingValue,
|
rollback_value: &crate::settings::SettingValue,
|
||||||
) -> Vec<Effect> {
|
) -> Vec<Effect> {
|
||||||
use crate::settings::SettingValue;
|
use crate::settings::SettingValue;
|
||||||
let mut companion_effects: Vec<Effect> = Vec::new();
|
let companion_effects: Vec<Effect> = Vec::new();
|
||||||
match (key, rollback_value) {
|
match (key, rollback_value) {
|
||||||
("compact_mode", SettingValue::Bool(b)) => set_compact_mode_inner(app, *b),
|
("compact_mode", SettingValue::Bool(b)) => set_compact_mode_inner(app, *b),
|
||||||
("show_timestamps", SettingValue::Bool(b)) => set_timestamps_inner(app, *b),
|
("show_timestamps", SettingValue::Bool(b)) => set_timestamps_inner(app, *b),
|
||||||
@@ -934,65 +934,24 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
|
|||||||
// other rollback arms must not clobber it from the global canonical.
|
// other rollback arms must not clobber it from the global canonical.
|
||||||
sync_active_auto_flag(app);
|
sync_active_auto_flag(app);
|
||||||
}
|
}
|
||||||
// default_model: best-effort rollback. If the prior model no
|
// default_model: deliberately NO revert. The session switch already
|
||||||
// longer resolves, leave optimistic value + log.
|
// succeeded independently (its own failure path reports via
|
||||||
("default_model", SettingValue::String(s)) => {
|
// `handle_switch_model_complete`); this arm fires when only the
|
||||||
if s.is_empty() {
|
// DISK write of the next-launch default failed — which must not
|
||||||
|
// undo a working switch. Same policy as `PersistPreferredModel`
|
||||||
|
// ("still active for this session"). Regression: reverting here
|
||||||
|
// (plus a reverse SwitchModel) made every picker selection appear
|
||||||
|
// to not take on Windows, where AV/indexer file locks routinely
|
||||||
|
// fail config.toml persists.
|
||||||
|
("default_model", SettingValue::String(prior)) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "settings",
|
target: "settings",
|
||||||
key = "default_model",
|
key = "default_model",
|
||||||
"rollback to empty string requested but no \
|
prior = %prior,
|
||||||
'clear current model' API exists — leaving live \
|
"default-model persist failed; keeping the live session's \
|
||||||
state at optimistic value (next session reload \
|
model (the switch succeeded) — only the next-launch \
|
||||||
will resolve via shell default-resolution chain)",
|
default is unsaved",
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// Resolve the prior model ID back to a ModelId
|
|
||||||
// and call the typed inner. If resolution fails
|
|
||||||
// (catalog changed mid-flight), log + leave
|
|
||||||
// optimistic.
|
|
||||||
let (resolved, session_id) = if let ActiveView::Agent(aid) = app.active_view
|
|
||||||
&& let Some(agent) = app.agents.get(&aid)
|
|
||||||
{
|
|
||||||
(
|
|
||||||
agent.session.models.resolve_by_name_or_id(s),
|
|
||||||
agent.session.session_id.clone(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
match resolved {
|
|
||||||
Some(id) => {
|
|
||||||
let _ = set_default_model_inner(app, &id);
|
|
||||||
// Emit reverse SwitchModel so the ACP session
|
|
||||||
// matches the rolled-back pager mirror.
|
|
||||||
if let ActiveView::Agent(aid) = app.active_view
|
|
||||||
&& let Some(sid) = session_id
|
|
||||||
{
|
|
||||||
if let Some(agent) = app.agents.get_mut(&aid) {
|
|
||||||
agent.session.model_switch_pending = true;
|
|
||||||
}
|
|
||||||
companion_effects.push(Effect::SwitchModel {
|
|
||||||
agent_id: aid,
|
|
||||||
session_id: sid,
|
|
||||||
model_id: id,
|
|
||||||
effort: None,
|
|
||||||
prev_model_id: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "settings",
|
|
||||||
key = "default_model",
|
|
||||||
value = %s,
|
|
||||||
"rollback model id no longer resolves in catalog — \
|
|
||||||
in-memory state stays at optimistic value; ACP session \
|
|
||||||
may diverge from pager mirror until next setter dispatch",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// max_thoughts_width: direct inner call.
|
// max_thoughts_width: direct inner call.
|
||||||
("max_thoughts_width", SettingValue::Int(i)) => set_max_thoughts_width_inner(app, *i),
|
("max_thoughts_width", SettingValue::Int(i)) => set_max_thoughts_width_inner(app, *i),
|
||||||
|
|||||||
@@ -1592,6 +1592,78 @@ fn rollback_reverts_thread_local_cache_too() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A default_model persist failure must NOT revert the live session's
|
||||||
|
/// model. The switch already succeeded in the session (its own failure
|
||||||
|
/// path reports separately); a DISK write failure only means the default
|
||||||
|
/// won't stick for the next launch — same policy as PersistPreferredModel
|
||||||
|
/// ("still active for this session"). Regression: the rollback arm
|
||||||
|
/// re-showed the ORIGINAL model and issued a reverse SwitchModel, which
|
||||||
|
/// on Windows (persist failures from AV/indexer file locks) made every
|
||||||
|
/// picker selection appear to not take.
|
||||||
|
#[test]
|
||||||
|
fn default_model_persist_failure_keeps_live_model() {
|
||||||
|
use crate::settings::SettingValue;
|
||||||
|
let mut app = test_app_with_agent();
|
||||||
|
let id = AgentId(0);
|
||||||
|
for (mid, name) in [("old-model", "Old Model"), ("new-model", "New Model")] {
|
||||||
|
let model_id = acp::ModelId::new(std::sync::Arc::from(mid));
|
||||||
|
let info = acp::ModelInfo::new(model_id.clone(), name.to_string());
|
||||||
|
app.agents
|
||||||
|
.get_mut(&id)
|
||||||
|
.unwrap()
|
||||||
|
.session
|
||||||
|
.models
|
||||||
|
.available
|
||||||
|
.insert(model_id.clone(), info.clone());
|
||||||
|
app.models.available.insert(model_id, info);
|
||||||
|
}
|
||||||
|
// User picked the new model; optimistic update applied.
|
||||||
|
let _ = dispatch(
|
||||||
|
Action::SetDefaultModel(acp::ModelId::new(std::sync::Arc::from("new-model"))),
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.agents[&id]
|
||||||
|
.session
|
||||||
|
.models
|
||||||
|
.current
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.0.as_ref()),
|
||||||
|
Some("new-model"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Disk persist fails (the Windows sharing-violation shape).
|
||||||
|
let effects = dispatch(
|
||||||
|
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
||||||
|
key: "default_model",
|
||||||
|
rollback_value: SettingValue::String("Old Model".into()),
|
||||||
|
error: "Access is denied. (os error 5)".into(),
|
||||||
|
}),
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.agents[&id]
|
||||||
|
.session
|
||||||
|
.models
|
||||||
|
.current
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.0.as_ref()),
|
||||||
|
Some("new-model"),
|
||||||
|
"a persist failure must not revert the live session's model"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!effects
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, Effect::SwitchModel { .. })),
|
||||||
|
"no reverse SwitchModel may be issued for a disk-persist failure"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
read_toast(&app).contains("Could not save"),
|
||||||
|
"the save failure must still be surfaced"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `set_yolo_mode_inner` is the backstop: even a (stale) rollback
|
/// `set_yolo_mode_inner` is the backstop: even a (stale) rollback
|
||||||
/// value of "always-approve" must not re-enable yolo under the pin.
|
/// value of "always-approve" must not re-enable yolo under the pin.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+7
-9
@@ -149,15 +149,13 @@ try {
|
|||||||
Write-Host "Run 'kigi' to get started."
|
Write-Host "Run 'kigi' to get started."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Graph engineering ships enabled by default. Respect an explicit
|
# Graph engineering is enabled by default IN THE BINARY (resolve_graph
|
||||||
# user choice: only set the variable when it is not already defined
|
# defaults true) — no environment plumbing needed. The installer used
|
||||||
# (so a persisted opt-out of "0" survives reinstalls).
|
# to persist KIGI_GRAPH=1 into the User registry env, but running
|
||||||
$Graph = [Environment]::GetEnvironmentVariable("KIGI_GRAPH", "User")
|
# terminals (and new tabs of an open Windows Terminal) never pick up
|
||||||
if ($null -eq $Graph -or $Graph -eq "") {
|
# freshly-written registry variables, which made /graph "missing on
|
||||||
[Environment]::SetEnvironmentVariable("KIGI_GRAPH", "1", "User")
|
# Windows" while the shell-rc path worked on macOS/Linux. Opt out any
|
||||||
Write-Host "Enabled graph engineering (KIGI_GRAPH=1)."
|
# time with: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')
|
||||||
Write-Host "Disable: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')"
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-8
@@ -209,7 +209,6 @@ case "${SHELL:-}" in
|
|||||||
*/zsh)
|
*/zsh)
|
||||||
RC_FILE="${ZDOTDIR:-$HOME}/.zshrc"
|
RC_FILE="${ZDOTDIR:-$HOME}/.zshrc"
|
||||||
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
||||||
GRAPH_LINE="export KIGI_GRAPH=1"
|
|
||||||
;;
|
;;
|
||||||
*/bash)
|
*/bash)
|
||||||
# macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc.
|
# macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc.
|
||||||
@@ -219,7 +218,6 @@ case "${SHELL:-}" in
|
|||||||
RC_FILE="$HOME/.bashrc"
|
RC_FILE="$HOME/.bashrc"
|
||||||
fi
|
fi
|
||||||
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
||||||
GRAPH_LINE="export KIGI_GRAPH=1"
|
|
||||||
;;
|
;;
|
||||||
*/fish)
|
*/fish)
|
||||||
# fish_add_path in config.fish is fish's own idempotent way
|
# fish_add_path in config.fish is fish's own idempotent way
|
||||||
@@ -228,12 +226,10 @@ case "${SHELL:-}" in
|
|||||||
mkdir -p "$FISH_CONF_DIR"
|
mkdir -p "$FISH_CONF_DIR"
|
||||||
RC_FILE="$FISH_CONF_DIR/config.fish"
|
RC_FILE="$FISH_CONF_DIR/config.fish"
|
||||||
PATH_LINE="fish_add_path $BIN_DIR"
|
PATH_LINE="fish_add_path $BIN_DIR"
|
||||||
GRAPH_LINE="set -gx KIGI_GRAPH 1"
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
RC_FILE="$HOME/.profile"
|
RC_FILE="$HOME/.profile"
|
||||||
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
||||||
GRAPH_LINE="export KIGI_GRAPH=1"
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -247,8 +243,8 @@ case ":$PATH:" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Graph engineering ships enabled by default. The KIGI_GRAPH guard makes
|
# Graph engineering is enabled by default IN THE BINARY (resolve_graph
|
||||||
# this idempotent AND respects an explicit user opt-out (an existing
|
# defaults true) — the installer no longer writes KIGI_GRAPH=1 into shell
|
||||||
# `export KIGI_GRAPH=0` line is left untouched). Disable any time with:
|
# rc files (per-shell env plumbing was fragile and diverged per platform).
|
||||||
|
# Disable any time with:
|
||||||
# echo 'export KIGI_GRAPH=0' >> <your shell rc>
|
# echo 'export KIGI_GRAPH=0' >> <your shell rc>
|
||||||
persist_line "$RC_FILE" "$GRAPH_LINE" "KIGI_GRAPH" "graph engineering (KIGI_GRAPH=1)"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user