Compare commits

...
23 Commits
Author SHA1 Message Date
ZacharyZhang-NY 0e4e446fe4 docs(agents): record where Shift+Enter can and cannot arrive
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-09-21 19:25:20 -04:00
ZacharyZhang-NY 008b6623f6 docs(tui): correct the VTE and WezTerm notes around the Shift+Enter hint 2026-09-21 19:25:04 -04:00
ZacharyZhang-NY f8cd33d19d fix(tui): the Shift+Enter hint follows the protocol, not a brand list
`shift_enter_unavailable` kept its own list of brands and missed every
other reason the Kitty keyboard protocol gets skipped: JetBrains, screen,
tmux below 3.3 and tmux with `extended-keys off` all swallow it, yet the
prompt still advertised Shift+Enter there. It now reads
`kitty_skip_reason` directly. Two skips deliver the modifier another way
and stay available: Apple Terminal, where `route_enter` polls
CoreGraphics, and Windows Terminal, where the console key record carries
SHIFT — a bare ConHost, only optimistically refined to Windows Terminal,
keeps advertising Alt+Enter.

The old list also carried a `VTE_VERSION >= 8200` cutoff, citing VTE
0.82.0 as the first release with the protocol. It is not: at tag 0.82.0
the string "kitty" appears nowhere in `src/parser-seq.py`, `src/vte.cc`
or `src/widget.cc`, and `_vte_keymap_GDK_Return` encodes only Alt
(`ESC CR`), so Shift+Enter leaves a VTE terminal as the same bare CR as
Enter. Every VTE now advertises Alt+Enter, and `/terminal-setup` stops
telling GNOME Terminal users to upgrade to a version that would not
help. It also stops advertising Alt+Enter to WezTerm, which binds that
chord to ToggleFullScreen.
2026-09-21 19:19:21 -04:00
ZacharyZhang-NY 2e40ebb08b docs(agents): the codex table is five models, and how to refresh it 2026-09-21 18:43:46 -04:00
ZacharyZhang-NY fcb7588312 feat(models): gpt-6-astra joins the compiled-in codex catalog
The Codex backend served five `visibility=list` + `supported_in_api`
models on 2026-09-21: `gpt-6-astra` ahead of `gpt-5.6-sol/terra/luna`
and `gpt-5.5`. Astra carries context 272000 and efforts
low/medium/high/xhigh/max, default medium; the `ultra` tier it also
advertises has no kigi level and is left out. `gpt-reserve` and
`codex-auto-review` stay hidden.

Inference checked against the live backend: `POST
/backend-api/codex/responses` with `model: gpt-6-astra` and the codex
`User-Agent` kigi sends answers 200 and streams, so the model works on
the compiled-in path.
2026-09-21 18:42:22 -04:00
ZacharyZhang-NY 9ab038f573 docs(agents): rewrap the reasoning-effort paragraph 2026-09-21 18:39:46 -04:00
ZacharyZhang-NY dd5711f24f docs(agents): the codex backend does advertise an ultra tier 2026-09-21 18:39:40 -04:00
ZacharyZhang-NY a690987ea2 revert: openai-codex goes back to the compiled-in catalog
Reverts c61977c. The live listing needed no codex CLI on the user's
machine — kigi issued the request itself and `client_version` was a
compiled-in string — but the project keeps the catalog in the binary, so
the fetch path, the `OpenaiCodex` listing dialect, its parser and the
pinned client version are gone again, and the codex `User-Agent` returns
to its previous value.

The models.dev provider rename `kimi-for-coding` → `kimi-code-plan-cn`
rode in the same commit and stays: the regenerated snapshot ships under
the new id.
2026-09-21 18:36:46 -04:00
ZacharyZhang-NY 95d4f19650 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.17 workspace version 2026-09-21 15:16:47 -04:00
ZacharyZhang-NY 99e09cfaf5 chore(release): bump workspace version to 0.1.17 2026-09-21 15:16:13 -04:00
ZacharyZhang-NY 0c580afb5f chore(models): regenerate the models.dev enrichment snapshot
`xai` and `openai` carry `restrict_to_enriched`, so a live listing id the
enrichment catalog does not know is dropped. The bundled snapshot knew
neither `grok-4.7` nor `gpt-6-astra`, which left both invisible offline
and until the 24h models.dev refresh landed.

Regeneration also tripped the generator's fail-fast: models.dev renamed
the Kimi coding provider. `kimi-for-coding` is now `kimi-code-plan-cn`
(api.kimi.com — kigi's endpoint), with `kimi-code-plan-global` the
api.kimi.ai twin; the model set is unchanged. Both registry rows, the
generator target and the two snapshot lookups follow the new id, and the
cache `keep_set` guard invalidates any cache filtered under the old one.
2026-09-21 15:13:00 -04:00
ZacharyZhang-NY c61977cb70 feat(models): fetch the openai-codex catalog live
The ChatGPT Codex catalog was compiled in: four models read once from the
Codex CLI cache. The backend moved on — `gpt-6-astra` shipped, and
`gpt-5.3-codex-spark` left — so the picker showed a table that no longer
matched the account.

`GET https://chatgpt.com/backend-api/codex/models?client_version=<v>`
serves that table. Verified against a live ChatGPT account: the OAuth
Bearer alone returns 200, the query is required (400 without it), and the
backend gates the catalog on it — `0.104.0` answers with
`codex-auto-review` alone, `0.150.0` omits `gpt-6-astra`, `0.155.1`
serves the full set. `CODEX_CLIENT_VERSION` pins the current upstream
release (openai/codex rust-v0.155.1) and the codex `User-Agent` now
derives from it, so the two can never drift.

`ListingDialect::OpenaiCodex` + `parse_openai_codex_listing` keep the
`visibility == "list" && supported_in_api` models and carry the wire
context window and per-model reasoning levels; `hardcoded_catalog` and
its short-circuit in the fetch path are gone. The catalog advertises an
`ultra` tier kigi has no level for — `think_efforts_to_options` drops it
with a warning, as it does any unknown token.
2026-09-21 15:12:54 -04:00
ZacharyZhang-NY 781fb44659 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.16 dependency set
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 5s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
`cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md` (cargo-about
0.9.2) on the 0.1.16 lock: kigi crates at 0.1.16, rustls 0.23.45,
rustls-webpki 0.103.15, aws-lc-rs 1.18.1, aws-lc-sys 0.45.0. The set of
listed crates is otherwise unchanged; the large diff is block order.
2026-09-19 04:21:53 -04:00
ZacharyZhang-NY a640816c38 chore(release): bump workspace version to 0.1.16 2026-09-19 04:20:50 -04:00
ZacharyZhang-NY 256ff2528e test(config): two e2e routing tests stop racing KIGI_CODE_BASE_URL
`e2e_default_model_with_session_routes_to_proxy` and
`e2e_duplicate_model_field_both_entries_survive` assert the default
model's base URL. They were not serial and read `KIGI_CODE_BASE_URL`
through `EndpointsConfig`, while two serial tests (auth_method.rs,
models_fetch.rs) point that variable at wiremock. When the runs
overlapped, the base URL came back as the wiremock URI: the Linux suite
failed in 2 of 13 full runs. Presetting the variable fails both tests
every time, on v0.1.15 as well.

Both tests are now `#[serial]` and unset the variable for their scope,
the same cure `e2e_enterprise_endpoints_only_no_model_override` got for
the moonshot variable. With the variable preset they pass.
2026-09-19 04:16:35 -04:00
ZacharyZhang-NY 2820dd5f2b docs(agents): record the 0.1.16 upstream sync window and what it left behind
The sync record now ends at upstream `a28ee2b` (2026-09-17, ≈v1.0.35).
The items evaluated in this window and left for a later cycle are listed
with the reason each one waits, so the next sync does not triage them
again. `pager.toml` joins that list: its writer still ends in a bare
`fs::rename`.

Two stale lines go: `ultra` left the reasoning-effort vocabulary in
649c5f5, yet the file still described it as a codex-only tier.
2026-09-19 03:53:47 -04:00
ZacharyZhang-NY 11b5a47ef0 chore(deps): rustls 0.23.45 closes RUSTSEC-2026-0285
`cargo deny check advisories` failed on rustls 0.23.42: TLS 1.3 handshake
messages were accepted across encryption-level boundaries. `cargo update
-p rustls` moves rustls to 0.23.45 and, with it, rustls-webpki to
0.103.15, aws-lc-rs to 1.18.1 and aws-lc-sys to 0.45.0.

The same update also re-picked six unrelated edges among versions
already in the lock (errno, quinn-udp, rustix, rustls-platform-verifier
and tempfile from windows-sys 0.52.0 to 0.59.0; tempfile from getrandom
0.4.3 to 0.3.4). Those edges are restored, so the diff is the four
packages only: version and checksum, sixteen lines. `cargo metadata
--locked` accepts the lock.

Advisories pass. `cargo clippy --workspace --all-targets --locked` is
clean on Windows.
2026-09-19 03:45:52 -04:00
ZacharyZhang-NY bc93b540cd fix(clipboard): the macOS image probe writes into a private scratch dir
The osascript fallback hands the pasted raster to kigi through a file.
Every kigi process used the same three paths in the shared temp dir
(`kigi-clipboard-probe.{png,tiff,jpg}`). Two sessions pasting at once, or
a stale file from an earlier probe, could swap the file between the
AppleScript's write and kigi's read: the wrong image went to the model.

`ProbeTemps` owns a fresh 0700 directory per probe and removes it on
drop, so the hand-written removals are gone. The script builder, the
raster reader, and the read flow move out of the cfg(macos) module into
`clipboard/osascript_probe.rs`, which compiles under test on every
platform; the flow takes the script runner as a closure. The script text
with a scratch dir is byte-identical to the old builder's (checked once
against the old function, then the copy was dropped).

A file paste needs no scratch file. When the directory cannot be made,
the probe warns, sends the file-URL half of the script only, and answers
file URLs as before; with no file URL either, the scratch error surfaces
instead of an empty board. `get_image` needs the file and fails fast.

Tests (all platforms): probes never share a path, the directory goes
away with the probe, mode 0700 on unix (Linux under WSL), the reported
class is read from this probe's file, file URLs survive a missing
scratch dir, and no dir plus no file URL is an error. The remaining
cfg(macos) callers were type-checked in a stubbed scratch crate; the
warm-cache workflow builds both darwin targets before the tag.

Ported from upstream (grok-build shared/clipboard.rs ProbeTemps and
osascript_attachments, 1.0.33).
2026-09-19 03:33:02 -04:00
ZacharyZhang-NY 0189009822 fix(config): a symlinked ~/.kigi/config.toml survives a save
Every config.toml writer wrote a tmp beside the path and renamed it over
the path. With config.toml symlinked into a dotfiles repo, the rename
replaced the link with a regular file: the repo copy went stale and the
link was gone.

`config_write_dest` resolves the destination before a writer names its
tmp. For the user's own config path a symlink resolves to its referent,
so the tmp sits on the referent's filesystem and the rename stays atomic.
A dangling link is an error, not a clobber. All eight writers call it:
`save_config`, the four MCP writers, and the three import writers.

Only the user's config path is followed, and only while
`user_kigi_home()` resolves. `kigi mcp add --scope project` and the
Claude project import write `.kigi/config.toml` inside the repo; a
hostile repo could ship that file as a symlink to any file the user
owns. With no home, `kigi_home()` falls back to `./.kigi`, which is that
same project tree, so nothing is followed then. Untrusted paths come
back unchanged and the link itself is replaced, as before. AGENTS.md
records the rule.

Tests (unix; run on Linux under WSL): helper cases including the no-home
case, a project-scope save that spares the link's target, and an
end-to-end save under KIGI_SHARE_DIR. The end-to-end test fails with
following disabled. Windows symlinks were not exercised: the host lacks
the privilege.

Ported from upstream (grok-build config/fs_atomic.rs LeafSymlink::Follow,
1.0.35): the rule, not the 1375-line module.
2026-09-19 03:15:01 -04:00
ZacharyZhang-NY 206711bed5 fix(skills): a skill body stops at the 25k-token read cap
`read_file` exempted SKILL.md from every limit, and both injection paths
(`/skill` expansion and the `skill` tool) pasted the whole body. A 220 KB
skill put 55k tokens into the context on each call.

`cap_skill_body` cuts an over-cap body at the last line boundary that
fits (a char boundary when the body is one long line) and appends a note
that points at offset and limit. It runs before `apply_substitutions`, so
the arguments land after the cut. Both injection paths call it and log
the cut.

`read_file` returns a SKILL.md whole only while it fits the cap. Over the
cap it reads like any other file: offset and limit apply, and a window
that is still too large answers FileTooLarge. Without that the note would
send the model to a read that ignores its range.

`skill_file_skips_token_limit` pinned the exemption; it gives way to
`skill_file_over_cap_returns_file_too_large` and
`skill_file_over_cap_honors_offset_and_limit`, as upstream did.

Ported from upstream (grok-build tools skills/skill.rs cap_skill_body and
read_file skill_full, 1.0.33). Upstream's `max_output_bytes` read budget
in the same hunk is a feature and stays out.
2026-09-19 02:31:24 -04:00
ZacharyZhang-NY 443de79e2f fix(textarea): Up and Down land on the soft-wrapped row they target
A soft-wrapped row and the next row share a byte: the row's exclusive end
is the next row's start. Up or Down with a goal column past the target
row's width put the cursor on that byte, so it drew on the wrong row. Up
made no progress and Down skipped a row.

`row_with_end` tells a soft wrap from a hard break (byte-adjacent rows
are soft). `move_to_display_col_on_line` takes the row and its `RowEnd`
and clamps a soft row to its last grapheme, or to the start of an element
that closes the row. A hard-break row still reaches its newline.
`clamp_to_line` now cuts on graphemes, not chars, so a ZWJ cluster stays
whole.

Kigi differs from upstream's premise that elements wrap whole: with no
display override in the buffer, `wrapped_lines` uses plain textwrap and
an element can span rows. Snapping to such an element's start would
leave the target row (Down from byte 0 stuck at 0), so an element that
began on an earlier row keeps the boundary `display_col_to_buffer_pos`
chose, as before this change.

`wrapped_navigation_with_zwj_graphemes` asserted the bug: Up from the
end stayed on the last row (byte 22). It now expects row 1's cluster
start (byte 11), as upstream does.

Tests: upstream's four cases, the row-spanning element, and a seeded
property test (500 cases): each Up or Down moves exactly one row. The
property test fails with the clamp disabled.

Ported from upstream (grok-build ratatui-textarea/textarea.rs, 1.0.28).
2026-09-19 02:17:19 -04:00
ZacharyZhang-NY be0983237e fix(mcp): an abandoned tool call tells the server to stop
`try_call_tool` wrapped `call_tool` in a bare `tokio::time::timeout`.
On a timeout or a cancelled turn the future dropped and the server kept
working: no `notifications/cancelled` ever left the client.

`call_tool_cancel_aware` sends the request through
`send_cancellable_request` and holds a guard armed with the request id.
A settled call disarms it. A dropped future (cancelled turn) spawns the
cancel from the guard's drop. A timeout sends the cancel inline, before
returning: the HTTP timeout arm resets the transport next, and dropping
the last service handle killed a spawned cancel before it left.

The inline send waits 500 ms at most. rmcp 2.2.0 awaits its own
timeout-cancel without a bound, and its streamable-HTTP worker awaits
each POST in turn, so against a JSON-mode server the cancel queues
behind the hung call: with rmcp's timeout path the call returned after
3.0 s against a 1 s budget. That server class cannot receive the cancel
until rmcp 3.x; the budget elapses, a warn is logged, and AGENTS.md
records the limitation. stdio and SSE-mode servers receive it.

Tests: dropped call, timed-out call followed by a service drop, settled
call; the HTTP outer-timeout test now asserts the return time.

Ported from upstream (grok-build mcp/servers.rs call_tool_cancel_aware,
1.0.33); upstream leans on rmcp 3.2's timeout path.
2026-09-19 02:07:30 -04:00
ZacharyZhang-NY 3ccb6e5e91 fix(config): reasoning_effort accepts mixed case
`reasoning_effort = "High"` in config.toml failed the whole parse: the
derived Deserialize matched lowercase variants only, while FromStr
lowercased its input. Deserialize now routes through FromStr, so both
paths share one vocabulary. Unknown tokens and non-strings still error.

Ported from upstream (grok-build sampling-types/types.rs, 1.0.35).
2026-09-19 01:51:55 -04:00
34 changed files with 6377 additions and 5568 deletions

No files matched your search

+62 -13
View File
@@ -17,8 +17,9 @@ import) or any `KIMI_*` env var.
Upstream sync record (update on every sync): fork baseline is upstream
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
the 0.1.13 cycle and through `3794978` (2026-09-09, ≈v1.0.24) in the
0.1.15 cycle. Upstream is daily "Synced from monorepo" snapshots —
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
cycle, and through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle.
Upstream is daily "Synced from monorepo" snapshots —
the commit BODIES carry per-change bullet lists, and
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
start any future sync from those two, not from raw diffs.
@@ -30,6 +31,23 @@ that stops reading still blocks exit-path prints behind std's stderr lock
builder re-checks `Path::exists("/data")` and can omit the devbox bind on
a stat error that the profile classification treated as present.
Known limitation left by the 0.1.16 sync: `notifications/cancelled` for a
timed-out MCP call cannot reach a streamable-HTTP server that answers
`tools/call` with a plain JSON body. rmcp 2.2.0's HTTP worker awaits each
POST in turn, so the cancel queues behind the hung call;
`servers/cancel_aware.rs` waits 500 ms, warns, and the transport reset
then drops the connection. stdio and SSE-mode servers get the cancel.
The cure is rmcp 3.x (upstream runs 3.2), a major bump not yet taken.
Evaluated in the 0.1.16 window and left for a later cycle: MCP qualified
tool names past 64 chars (first prove the name never reaches a provider
function list), the auto-update guard for a binary still in use (needs
per-OS process scans), the DA1 fence around the kitty keyboard pop (needs
a design against the 0.1.15 teardown), MCP servers on protocol 2026-07-28
(rmcp 3.x again), and `pager.toml`, whose writer in
`kigi-pager-render/src/appearance/config.rs` still ends in a bare
`fs::rename` and replaces a symlink.
## Hard constraints
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
@@ -56,6 +74,15 @@ a stat error that the profile classification treated as present.
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 _ =`.
- **config.toml writers resolve their destination through
`util::config::config_write_dest` before they name the tmp file.** A
symlinked `~/.kigi/config.toml` (dotfiles) keeps its link: the tmp sits
beside the referent and replaces it. Only that one path is followed, and
only while `user_kigi_home()` resolves (with no home, `kigi_home()` is the
project's own `./.kigi`). A project `.kigi/config.toml` is
repo-controlled, and following its symlink would overwrite whatever file
a hostile repo points it at; there the link itself is replaced.
Credential and managed stores never follow symlinks.
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
this repo). Members sorted; versions inherited from
`workspace.package.version` — the single source of truth for the release
@@ -89,6 +116,24 @@ a stat error that the profile classification treated as present.
- Graph node worktrees are removed right after a successful merge-back;
only FAILED nodes keep theirs for postmortem.
## Terminal keyboard
`Shift+Enter` inserts a newline in the prompt wherever the terminal can
report the modifier, and `kitty_skip_reason` decides where that is —
`shift_enter_unavailable` matches on it rather than keeping a second brand
list, because the two drifted once and the UI advertised a chord that never
arrived. Two skips still deliver the modifier: Apple Terminal, where
`route_enter` polls CoreGraphics, and Windows Terminal, where the console
key record carries SHIFT with no protocol at all (a bare ConHost is only
optimistically refined to Windows Terminal, so it keeps advertising
Alt+Enter). Everywhere else — VTE, xterm.js, JediTerm, screen, tmux below
3.3, tmux with `extended-keys off` — the byte on the wire is a plain CR and
no client-side trick recovers it: crossterm 0.28 parses kitty `CSI 13;2u`
but not xterm's `CSI 27;2;13~`, so `modifyOtherKeys` buys nothing. NO VTE
release carries the protocol (checked at tag 0.82.0:
`_vte_keymap_GDK_Return` encodes only Alt); a version cutoff for it is a
bug, not a feature. `/terminal-setup` owns the per-terminal recipe.
## Test seams
Cross-crate test hooks are behind the `test-support` cargo feature
@@ -355,15 +400,18 @@ client-side, no backend surface.
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.
carries the thinking level. NO websocket, NO base_instructions.
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
`openai_codex_wire_models`, mapped through the SAME
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
`supported_in_api=true` models (`gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx
272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI /
`~/.codex` dependency; `gpt-5.3-codex-spark` (api=false) and
`gpt-5.4`/`gpt-5.4-mini`/`codex-auto-review` (hidden) are EXCLUDED.
`platform_wire_model_to_entry` output): the 5 `visibility=list` &&
`supported_in_api=true` models the backend served on 2026-09-21
(`gpt-6-astra`, `gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx 272000,
per-model efforts) — NO live `/models` fetch, NO codex-CLI / `~/.codex`
dependency; the hidden `gpt-reserve` and `codex-auto-review` are
EXCLUDED. Refresh the table by reading
`GET {base}/models?client_version=<a current codex release>` with a
ChatGPT bearer: an old `client_version` is answered with a truncated
list, so a stale version reads as "the model does not exist".
- `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE).
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an
@@ -505,10 +553,11 @@ client-side, no backend surface.
`parse_api_json` for bundled + runtime refresh; 24h cache
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
never invents model availability. Canonical reasoning efforts:
none/minimal/low/medium/high/xhigh/max/ultra (`max` split from `xhigh`
2026-07; `ultra` is codex-only, above `max`, surfaced only via a model's
server-declared effort menu; Kimi wire spells its top tier `max`, kimi_compat
renames).
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07;
the Codex backend advertises an `ultra` tier above `max` that kigi has no
level for; Kimi wire spells its top tier `max`, kimi_compat renames).
config.toml accepts any letter case: `ReasoningEffort` deserializes
through `FromStr`.
## Milestones (PRD §8.3)
Generated
+70 -70
View File
@@ -720,9 +720,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.17.1"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -731,9 +731,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.42.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"async-openai",
"async-stream",
@@ -6155,7 +6155,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"assert_matches",
"async-openai",
@@ -6172,7 +6172,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"chrono",
@@ -6194,7 +6194,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"regex",
"serde_json",
@@ -6232,7 +6232,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6370,7 +6370,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"chrono",
@@ -6395,7 +6395,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"libc",
"rusqlite",
@@ -6406,7 +6406,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6421,7 +6421,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6429,7 +6429,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"runfiles",
"tracing",
@@ -6460,11 +6460,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.15"
version = "0.1.17"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"async-trait",
@@ -6493,7 +6493,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6503,7 +6503,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"arc-swap",
@@ -6580,7 +6580,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6593,11 +6593,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.15"
version = "0.1.17"
[[package]]
name = "kigi-tty-utils"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6607,7 +6607,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6694,7 +6694,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"dunce",
@@ -6723,14 +6723,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6809,7 +6809,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"base64",
"chrono",
@@ -8843,7 +8843,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8861,7 +8861,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.15"
version = "0.1.17"
dependencies = [
"anyhow",
"axum",
@@ -9636,9 +9636,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -9701,9 +9701,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.15"
version = "0.1.17"
edition = "2024"
license = "Apache-2.0"
+5028 -5029
View File
File diff suppressed because it is too large. Load diff
+35 -29
View File
@@ -32,6 +32,9 @@ use rmcp::{
use crate::oauth_config::McpOAuthConfig;
mod cancel_aware;
use cancel_aware::call_tool_cancel_aware;
use kigi_tools::types::{
output::{MCPOutput, MCPOutputDetails, ToolOutput},
tool::{ToolKind, ToolNamespace},
@@ -1599,12 +1602,24 @@ impl McpErasedTool {
let mut params = CallToolRequestParams::new(self.tool.name.clone());
params.arguments = raw.as_object().cloned();
let result =
tokio::time::timeout(timeout_duration, mcp_service.call_tool(params.clone())).await;
match result {
Ok(Ok(call_result)) => Ok(call_result),
Ok(Err(service_err))
match call_tool_cancel_aware(&mcp_service, params.clone(), timeout_duration).await {
Ok(call_result) => Ok(call_result),
Err(ServiceError::Timeout { .. }) => {
*is_timeout = true;
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
if client.is_http() && !*reconnect_attempted {
client.reset_transport().await;
*reconnect_attempted = true;
}
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
format!(
"MCP tool '{}' timed out after {} seconds",
self.tool.name, tool_timeout
),
))
}
Err(service_err)
if should_recover_service_error(
&service_err,
client.is_http(),
@@ -1623,25 +1638,10 @@ impl McpErasedTool {
)
.await
}
Ok(Err(e)) => Err(kigi_tool_runtime::ToolError::custom(
Err(e) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
e.to_string(),
)),
Err(_) => {
*is_timeout = true;
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
if client.is_http() && !*reconnect_attempted {
client.reset_transport().await;
*reconnect_attempted = true;
}
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
format!(
"MCP tool '{}' timed out after {} seconds",
self.tool.name, tool_timeout
),
))
}
}
}
@@ -1692,13 +1692,9 @@ impl McpErasedTool {
));
}
};
match tokio::time::timeout(timeout_duration, mcp_service.call_tool(params)).await {
Ok(Ok(call_result)) => Ok(call_result),
Ok(Err(retry_err)) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
retry_err.to_string(),
)),
Err(_) => {
match call_tool_cancel_aware(&mcp_service, params, timeout_duration).await {
Ok(call_result) => Ok(call_result),
Err(ServiceError::Timeout { .. }) => {
*is_timeout = true;
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
@@ -1708,6 +1704,10 @@ impl McpErasedTool {
),
))
}
Err(retry_err) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
retry_err.to_string(),
)),
}
}
}
@@ -6223,11 +6223,17 @@ mod tests {
let mut reconnect = false;
let mut is_timeout = false;
let raw = serde_json::json!({});
let started = std::time::Instant::now();
let err = tool
.try_call_tool(&client, &raw, &mut reconnect, &mut is_timeout, &ew)
.await
.expect_err("call must time out");
assert!(
started.elapsed() < std::time::Duration::from_millis(2500),
"cancel must not wait out the 3s hang: {:?}",
started.elapsed()
);
assert!(err.to_string().contains("timed out"), "got: {err}");
assert!(is_timeout, "is_timeout must be set");
assert!(reconnect, "timeout arm flags the reconnect after resetting");
@@ -0,0 +1,224 @@
//! `tools/call` that tells the server when the host stops waiting.
use rmcp::model::{
CallToolRequest, CallToolRequestParams, CallToolResult, CancelledNotificationParam,
ClientRequest, RequestId, ServerResult,
};
use rmcp::service::{Peer, PeerRequestOptions};
use rmcp::{RoleClient, ServiceError};
use super::McpService;
/// rmcp 2.2.0's HTTP worker queues the cancel behind a hung POST, so the wait is bounded.
const CANCEL_DELIVERY_BUDGET: std::time::Duration = std::time::Duration::from_millis(500);
/// A timeout cancels inline, before the caller can reset the transport; a drop cancels from a task.
pub(super) async fn call_tool_cancel_aware(
service: &McpService,
params: CallToolRequestParams,
timeout: std::time::Duration,
) -> Result<CallToolResult, ServiceError> {
let handle = service
.peer()
.send_cancellable_request(
ClientRequest::CallToolRequest(CallToolRequest::new(params)),
PeerRequestOptions::no_options(),
)
.await?;
let mut guard = CancelOnDrop {
peer: service.peer().clone(),
request_id: Some(handle.id.clone()),
};
let Ok(response) = tokio::time::timeout(timeout, handle.await_response()).await else {
guard.cancel_within_budget().await;
return Err(ServiceError::Timeout { timeout });
};
guard.request_id = None;
match response? {
ServerResult::CallToolResult(result) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}
}
/// Armed while the request is in flight; disarmed once it settles.
struct CancelOnDrop {
peer: Peer<RoleClient>,
request_id: Option<RequestId>,
}
impl CancelOnDrop {
async fn cancel_within_budget(&mut self) {
let Some(request_id) = self.request_id.clone() else {
return;
};
let delivery = notify_cancelled(self.peer.clone(), request_id.clone());
if tokio::time::timeout(CANCEL_DELIVERY_BUDGET, delivery)
.await
.is_err()
{
tracing::warn!(
?request_id,
"notifications/cancelled still queued after budget"
);
}
self.request_id = None;
}
}
impl Drop for CancelOnDrop {
fn drop(&mut self) {
let Some(request_id) = self.request_id.take() else {
return;
};
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
tracing::warn!(
?request_id,
"MCP call dropped outside a runtime; cancel not sent"
);
return;
};
runtime.spawn(notify_cancelled(self.peer.clone(), request_id));
}
}
async fn notify_cancelled(peer: Peer<RoleClient>, request_id: RequestId) {
let params = CancelledNotificationParam::new(
Some(request_id.clone()),
Some("client cancelled".to_owned()),
);
if let Err(e) = peer.notify_cancelled(params).await {
tracing::warn!(?request_id, error = %e, "notifications/cancelled not delivered");
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use rmcp::ServiceExt;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use super::super::{KigiClientHandler, McpClient};
use super::*;
type Received = Arc<parking_lot::Mutex<Vec<Value>>>;
/// Duplex MCP server: answers `initialize`, records all, answers `tools/call` only when `reply`.
async fn recording_service(reply: bool) -> (McpService, Received) {
let received = Received::default();
let (client_read, mut server_write) = tokio::io::duplex(64 * 1024);
let (server_read, client_write) = tokio::io::duplex(64 * 1024);
let sink = received.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(server_read).lines();
while let Ok(Some(line)) = lines.next_line().await {
let msg: Value = serde_json::from_str(&line).expect("client sends JSON");
sink.lock().push(msg.clone());
let result = match msg["method"].as_str() {
Some("initialize") => serde_json::json!({
"protocolVersion": msg["params"]["protocolVersion"],
"capabilities": { "tools": {} },
"serverInfo": { "name": "recording", "version": "0.0.0" },
}),
Some("tools/call") if reply => serde_json::json!({
"content": [{ "type": "text", "text": "done" }],
"isError": false,
}),
_ => continue,
};
let response =
serde_json::json!({ "jsonrpc": "2.0", "id": msg["id"], "result": result });
let encoded = format!("{response}\n");
server_write
.write_all(encoded.as_bytes())
.await
.expect("client reads replies");
}
});
let handler = KigiClientHandler {
info: McpClient::make_client_info("recording"),
server_name: "recording".to_string(),
notify_tx: Arc::default(),
};
let transport = rmcp::transport::async_rw::AsyncRwTransport::<RoleClient, _, _>::new(
client_read,
client_write,
);
let service = Arc::new(handler.serve(transport).await.expect("handshake"));
(service, received)
}
fn values_at(received: &Received, method: &str, pointer: &str) -> Vec<Value> {
received
.lock()
.iter()
.filter(|m| m["method"] == method)
.map(|m| m.pointer(pointer).cloned().unwrap_or(Value::Null))
.collect()
}
fn call_ids(received: &Received) -> Vec<Value> {
values_at(received, "tools/call", "/id")
}
fn cancelled_ids(received: &Received) -> Vec<Value> {
values_at(received, "notifications/cancelled", "/params/requestId")
}
#[tokio::test]
async fn dropped_call_cancels_its_request() {
let (service, received) = recording_service(false).await;
let mut call = Box::pin(call_tool_cancel_aware(
&service,
CallToolRequestParams::new("slow"),
Duration::from_secs(30),
));
let pending = tokio::time::timeout(Duration::from_millis(100), &mut call).await;
assert!(pending.is_err(), "server never replies");
drop(call);
tokio::time::sleep(Duration::from_millis(100)).await;
let ids = call_ids(&received);
assert_eq!(ids.len(), 1, "{ids:?}");
assert_eq!(cancelled_ids(&received), ids);
}
#[tokio::test]
async fn timed_out_call_cancels_its_request_once() {
let (service, received) = recording_service(false).await;
let round = call_tool_cancel_aware(
&service,
CallToolRequestParams::new("slow"),
Duration::from_millis(50),
)
.await;
assert!(
matches!(round, Err(ServiceError::Timeout { .. })),
"{round:?}"
);
// The HTTP timeout arm resets the transport, which drops the last service handle.
drop(service);
tokio::time::sleep(Duration::from_millis(100)).await;
let ids = call_ids(&received);
assert_eq!(ids.len(), 1, "{ids:?}");
assert_eq!(cancelled_ids(&received), ids);
}
#[tokio::test]
async fn completed_call_sends_no_cancellation() {
let (service, received) = recording_service(true).await;
let result = call_tool_cancel_aware(
&service,
CallToolRequestParams::new("fast"),
Duration::from_secs(5),
)
.await
.expect("server replies");
assert_eq!(result.is_error, Some(false));
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(cancelled_ids(&received), Vec::<Value>::new());
}
}
@@ -1 +1 @@
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"kimi-k2.5":{"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":16384},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"Qwen3.7 Max","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-plus":{"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image"],"output":["text"]},"name":"Qwen3.7 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.8-max-preview":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.8 Max Preview","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","xhigh"]},{"max":262144,"min":0,"type":"budget_tokens"}],"tool_call":true},"wan2.7-image":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image","reasoning":false,"tool_call":false},"wan2.7-image-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image Pro","reasoning":false,"tool_call":false}}},"alibaba-token-plan-cn":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":3Line truncated
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":32768},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-flash-0731":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash 0731","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro-0813":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro 0813","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4.1-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"],"output":["text"]},"name":"DeepSeek V4.1 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5.3":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.3","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"tool_call":true},"happyhorse-1.1-i2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Image-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-r2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Reference-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-t2v":{"limit":{"context":0,"output":0},"modalities":{"input":["text"],"output":["video"]},"name":"HappyHorse 1.1 Text-to-Video","reasoning":false,"tool_call":false},"kimi-k2.5":{"limit":{"context":262144,"output":98304},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,Line truncated
+16 -1
View File
@@ -223,7 +223,7 @@ mod tests {
"snapshot lost providers: {}",
catalog.len()
);
let k3 = lookup(catalog, "kimi-for-coding", "k3").expect("k3 present");
let k3 = lookup(catalog, "kimi-code-plan-cn", "k3").expect("k3 present");
assert_eq!(k3.context, 1_048_576, "k3 context must match the live wire");
assert_eq!(k3.efforts, ["low", "high", "max"]);
assert!(k3.reasoning);
@@ -232,6 +232,21 @@ mod tests {
assert_eq!(opus.efforts, ["low", "medium", "high", "xhigh", "max"]);
}
/// `restrict_to_enriched` drops a live listing id the enrichment catalog
/// does not know, so the bundled snapshot must admit the current flagship
/// models offline: grok-4.7 on xai (the `xai`/`xai-grok` rows) and
/// gpt-6-astra on the API-key `openai` row.
#[test]
fn bundled_snapshot_admits_the_current_flagship_models() {
let catalog = bundled_enrichment();
let grok = lookup(catalog, "xai", "grok-4.7").expect("grok-4.7 present");
assert!(grok.tool_call, "a non-tool-calling model is dropped");
assert_eq!(grok.context, 500_000);
let astra = lookup(catalog, "openai", "gpt-6-astra").expect("gpt-6-astra present");
assert!(astra.tool_call);
assert_eq!(astra.context, 1_050_000);
}
/// Wire-served fields are never overwritten; absent fields are filled.
#[test]
fn enrich_fills_gaps_and_never_overwrites_wire() {
+40 -25
View File
@@ -400,7 +400,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
vendor: "Kimi",
console_host: None,
login_label: None,
models_dev_id: Some("kimi-for-coding"),
models_dev_id: Some("kimi-code-plan-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
@@ -987,7 +987,7 @@ const KIMI_CODING_SPEC: PlatformSpec = PlatformSpec {
vendor: "Kimi",
console_host: Some("www.kimi.com"),
login_label: Some("Kimi For Coding (API key)"),
models_dev_id: Some("kimi-for-coding"),
models_dev_id: Some("kimi-code-plan-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
@@ -1570,9 +1570,10 @@ impl PlatformId {
/// The compiled-in catalog for a platform that serves NO live `/models`
/// listing (openai-codex), or `None` when the catalog comes from the wire.
///
/// openai-codex's 4 models are HARDCODED (read from the official Codex CLI's
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
/// set) because OpenAI exposes no stable public models endpoint for the
/// openai-codex's 5 models are HARDCODED (read from the Codex backend
/// catalog on 2026-09-21, the `visibility=="list"` AND
/// `supported_in_api==true` set) because OpenAI exposes no stable public
/// models endpoint for the
/// ChatGPT Codex backend. Each entry carries context window + per-model
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`
/// tiers), so the fetch path maps them through the SAME
@@ -1606,14 +1607,21 @@ fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &
}
}
/// The HARDCODED openai-codex catalog: exactly the 4 `visibility=="list"` AND
/// `supported_in_api==true` models from the Codex CLI model cache. The
/// `gpt-5.3-codex-spark` (supported_in_api=false → not served by /responses),
/// `gpt-5.4`, `gpt-5.4-mini`, and `codex-auto-review` (visibility="hide") models
/// are deliberately EXCLUDED — they would list-but-not-work or are not
/// user-facing (fail-fast: never advertise a model the backend rejects).
/// The HARDCODED openai-codex catalog: exactly the 5 `visibility=="list"` AND
/// `supported_in_api==true` models the Codex backend served on 2026-09-21, in
/// its own priority order. `gpt-reserve` and `codex-auto-review`
/// (visibility="hide") are deliberately EXCLUDED — they are not user-facing
/// (fail-fast: never advertise a model the backend rejects). `gpt-6-astra`
/// also advertises an `ultra` tier above `max`; kigi has no such level, so it
/// is not listed here.
fn openai_codex_wire_models() -> Vec<WireModel> {
vec![
codex_wire_model(
"gpt-6-astra",
"GPT-6-Astra",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-5.6-sol",
"GPT-5.6-Sol",
@@ -2653,29 +2661,30 @@ mod tests {
assert_eq!(c.base_url(), "https://mock.codex/codex");
}
/// The HARDCODED openai-codex catalog is exactly the 4 supported+listed
/// The HARDCODED openai-codex catalog is exactly the 5 supported+listed
/// models, keyed by slug, ctx 272000, each exposing its exact supported
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The
/// list-but-broken / hidden models are absent. Every other platform serves
/// NO hardcoded catalog (its models come from the live wire).
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The hidden models
/// are absent. Every other platform serves NO hardcoded catalog (its
/// models come from the live wire).
#[test]
fn openai_codex_hardcoded_catalog_is_the_four_supported_models() {
fn openai_codex_hardcoded_catalog_is_the_five_supported_models() {
let catalog = PlatformId::OpenaiCodex
.hardcoded_catalog()
.expect("openai-codex serves a hardcoded catalog");
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"],
"exactly the 4 visibility=list AND supported_in_api=true models"
vec![
"gpt-6-astra",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5"
],
"exactly the 5 visibility=list AND supported_in_api=true models"
);
// Excluded: list-but-not-served + hidden models never appear.
for absent in [
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
"codex-auto-review",
] {
// Excluded: hidden models never appear.
for absent in ["gpt-reserve", "codex-auto-review"] {
assert!(
!ids.contains(&absent),
"{absent} must be excluded from the hardcoded catalog"
@@ -2700,6 +2709,11 @@ mod tests {
.valid_efforts
.clone()
};
assert_eq!(
efforts("gpt-6-astra"),
["low", "medium", "high", "xhigh", "max"],
"`ultra` is advertised above `max` but kigi has no such level"
);
assert_eq!(
efforts("gpt-5.6-sol"),
["low", "medium", "high", "xhigh", "max"]
@@ -2725,6 +2739,7 @@ mod tests {
.default_effort
.clone()
};
assert_eq!(default("gpt-6-astra").as_deref(), Some("medium"));
assert_eq!(default("gpt-5.6-sol").as_deref(), Some("low"));
assert_eq!(default("gpt-5.6-terra").as_deref(), Some("medium"));
// Only openai-codex has a hardcoded catalog; every wire platform is None.
@@ -375,7 +375,6 @@ impl TerminalContext {
return Some("apple_terminal");
}
if self.is_vte_based() {
// WHY: central helper replaces VTE duplication
return Some("vte");
}
if self.brand == TerminalName::WindowsTerminal {
@@ -429,74 +428,29 @@ impl TerminalContext {
}
/// Whether the running terminal cannot distinguish `Shift+Enter` from
/// bare `Enter` at the byte level, so the UI should advertise
/// `Alt+Enter` for newline insertion instead.
/// bare `Enter`, so the UI should advertise `Alt+Enter` (delivered as
/// `ESC`+`CR`) for newline insertion instead.
///
/// Distinguishing `Shift+Enter` requires the Kitty keyboard protocol
/// (KKP) to be negotiated. This returns `true` for the environments
/// where the pager cannot rely on KKP for a usable `Shift+Enter`:
/// Follows [`Self::kitty_skip_reason`] rather than a second brand list:
/// when the protocol is not negotiated the modifier is lost, and the two
/// drifted once already (VTE advertised a chord that never arrived — its
/// `Return` keymap encodes only Alt, through 0.82.0 at least, and no VTE
/// release carries the protocol). Two skips still deliver it — Apple
/// Terminal drops the flags but `route_enter` polls CoreGraphics, and
/// Windows console key records carry SHIFT with no protocol at all.
///
/// 1. **Legacy VTE** (GNOME Terminal, Ptyxis, kgx, Tilix, etc.) whose
/// `VTE_VERSION` is below `8200` (VTE 0.82.0, the first release with
/// KKP, merged in
/// [MR !14](https://gitlab.gnome.org/GNOME/vte/-/merge_requests/14)).
/// Also true when the brand is detected as VTE but `VTE_VERSION` is
/// missing or unparseable — we conservatively assume old.
/// 2. **VS Code's integrated terminal (xterm.js) and VS Code-family /
/// xterm.js IDE forks**. xterm.js only partially implements KKP —
/// it mis-encodes shifted printable keys — so the pager deliberately
/// never negotiates KKP for them (see [`Self::kitty_skip_reason`]
/// `== "vscode"` and [xterm.js#5823](https://github.com/xtermjs/xterm.js/issues/5823)).
/// Without KKP, xterm.js sends a bare `CR` for `Shift+Enter`,
/// byte-for-byte identical to `Enter`.
/// 3. **Unidentified terminals with no multiplexer**, where the pager
/// also skips KKP (no positive evidence of support — typically
/// VS Code's xterm.js reached over SSH, where `TERM_PROGRAM` isn't
/// forwarded and the brand falls back to `Unknown`).
///
/// In every case `Alt+Enter` (delivered as `ESC`+`CR`) is the reliable
/// newline chord and is what the UI advertises.
/// KNOWN GAP: WezTerm inside screen or an old tmux gets Alt+Enter as the
/// advice while its own default binding spends that chord on
/// ToggleFullScreen. Neither chord reaches kigi there; the multiplexer
/// warning carries the real fix, and `\`+Enter still inserts a newline.
pub fn shift_enter_unavailable(&self) -> bool {
// WHY: central helper + version gating
let is_vte = self.is_vte_based();
if is_vte {
return match self
.vte_version
.as_deref()
.and_then(|v| v.parse::<u32>().ok())
{
Some(ver) => ver < 8200,
// Brand=Vte but no parseable version — conservative: assume old.
None => true,
};
match self.kitty_skip_reason() {
None | Some("apple_terminal") => false,
// A bare ConHost is only optimistically refined to Windows
// Terminal; it still advertises Alt+Enter.
Some("windows_terminal") => self.env_brand.is_capability_unclassified(),
Some(_) => true,
}
// VS Code / xterm.js and its forks: KKP is never negotiated, so
// Shift+Enter arrives as a bare CR indistinguishable from Enter.
if matches!(
self.brand,
TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed
) {
return true;
}
// Unidentified / unclassified brand with no multiplexer: KKP is
// skipped (see `kitty_skip_reason`). This is the common
// VS Code-over-SSH shape (brand falls back to Unknown). On native
// Windows the effective `brand` is refined to WindowsTerminal, so
// consult `env_brand` — a bare ConHost is still env-Unknown and
// must advertise Alt+Enter even though we optimistically treat it
// as WT for capabilities.
if self.env_brand.is_capability_unclassified()
&& self.multiplexer == MultiplexerKind::Undetected
{
return true;
}
false
}
/// True when `Ctrl+.` cannot be delivered reliably as a shortcuts primary.
@@ -1443,7 +1443,7 @@ fn kitty_skip_vscode_over_tmux() {
#[test]
fn kitty_skip_vte_version() {
// VTE does not support Kitty keyboard protocol and crossterm's probe
// No VTE release has the Kitty keyboard protocol and crossterm's probe
// can false-positive on it. https://gitlab.gnome.org/GNOME/vte/-/issues/2601
let ctx = TerminalContext {
vte_version: Some("7402".to_owned()),
@@ -1461,73 +1461,68 @@ fn kitty_skip_vte_brand() {
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
}
// shift_enter_unavailable: VTE version gating for Shift+Enter
// shift_enter_unavailable: VTE never delivers Shift+Enter
//
// VTE 0.82.0 (= VTE_VERSION 8200) is the first release containing the
// Kitty keyboard protocol; earlier versions cannot distinguish
// Shift+Enter from bare Enter, so the UI should advertise Alt+Enter
// for newline insertion instead.
// No VTE release carries the Kitty keyboard protocol: at 0.82.0 the word
// "kitty" appears nowhere in the parser or widget, and
// `_vte_keymap_GDK_Return` encodes only Alt (`ESC CR`), so Shift+Enter is
// the same bare CR as Enter. The UI advertises Alt+Enter instead.
#[test]
fn shift_enter_unavailable_legacy_vte_version() {
// VTE 0.64.2 (real user report) — well below the KKP cutoff.
let ctx = TerminalContext {
vte_version: Some("6402".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
fn shift_enter_unavailable_on_every_vte() {
for version in [Some("6402"), Some("8200"), Some("8401"), None] {
let ctx = TerminalContext {
brand: TerminalName::Vte,
env_brand: TerminalName::Vte,
vte_version: version.map(str::to_owned),
..Default::default()
};
assert_eq!(ctx.kitty_skip_reason(), Some("vte"), "VTE {version:?}");
assert!(ctx.shift_enter_unavailable(), "VTE {version:?}");
}
}
#[test]
fn shift_enter_unavailable_just_below_cutoff() {
// VTE 0.81.99 — anything below 8200 must return true.
let ctx = TerminalContext {
vte_version: Some("8199".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_available_modern_vte() {
// VTE 0.82.0 — first release with KKP.
let ctx = TerminalContext {
vte_version: Some("8200".to_owned()),
..Default::default()
};
assert!(!ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_available_future_vte() {
// VTE 0.84.1 — well above the cutoff.
let ctx = TerminalContext {
vte_version: Some("8401".to_owned()),
..Default::default()
};
assert!(!ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_unavailable_vte_brand_no_version() {
// Brand detected as VTE but VTE_VERSION missing — conservative: old.
let ctx = TerminalContext {
brand: TerminalName::Vte,
vte_version: None,
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_unavailable_unparseable_version() {
// Garbage in VTE_VERSION — conservative: old.
let ctx = TerminalContext {
brand: TerminalName::Vte,
vte_version: Some("not-a-number".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
fn shift_enter_unavailable_follows_every_kitty_skip() {
// A skipped protocol means the modifier never arrives, whatever the
// reason — JediTerm, screen and a multiplexer that eats extended keys
// included. Apple Terminal and Windows Terminal are the two skips with
// another way in.
for ctx in [
TerminalContext {
brand: TerminalName::JetBrains,
env_brand: TerminalName::JetBrains,
..Default::default()
},
TerminalContext {
brand: TerminalName::Kitty,
env_brand: TerminalName::Kitty,
multiplexer: MultiplexerKind::Screen,
..Default::default()
},
TerminalContext {
brand: TerminalName::Kitty,
env_brand: TerminalName::Kitty,
multiplexer: MultiplexerKind::Tmux,
tmux_version: Some("3.2".to_owned()),
..Default::default()
},
// A VTE carrying a recent VTE_VERSION is no different.
TerminalContext {
brand: TerminalName::Vte,
env_brand: TerminalName::Vte,
vte_version: Some("8200".to_owned()),
multiplexer: MultiplexerKind::Screen,
..Default::default()
},
] {
assert!(ctx.kitty_skip_reason().is_some());
assert!(
ctx.shift_enter_unavailable(),
"{:?} skips the protocol, so Shift+Enter cannot arrive",
ctx.brand
);
}
}
#[test]
@@ -267,6 +267,13 @@ struct WrapCache {
lines: Vec<Range<usize>>,
}
/// Whether a wrapped row's exclusive end is a cursor position on that row.
#[derive(Debug, Clone, Copy)]
enum RowEnd {
HardBreak,
SoftWrap,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TextAreaState {
/// Index into wrapped lines of the first visible line.
@@ -382,27 +389,13 @@ impl TextArea {
}
}
/// Clamp a buffer position so it stays within a wrapped line's range
/// `[line_start, line_end)`. Without this, `display_col_to_buffer_pos`
/// can return `line_end` when the column exceeds the line's display
/// width — and `line_end` equals the *next* wrapped line's start,
/// which confuses `effective_scroll` into thinking the cursor hasn't
/// actually moved to the target line.
///
/// Uses `self.text` to find the last valid char boundary inside the line
/// so we never land in the middle of a multi-byte character.
/// Clamp `pos` to the start of the last grapheme in `[line_start, line_end)`.
fn clamp_to_line(&self, pos: usize, line_start: usize, line_end: usize) -> usize {
if line_end > line_start {
// Find the start of the last character in the line.
let last_char_start = self.text[line_start..line_end]
.char_indices()
.next_back()
.map(|(i, _)| line_start + i)
.unwrap_or(line_start);
pos.min(last_char_start)
} else {
line_start
}
let last_grapheme_start = self.text[line_start..line_end]
.grapheme_indices(true)
.next_back()
.map_or(line_start, |(i, _)| line_start + i);
pos.min(last_grapheme_start)
}
pub fn new() -> Self {
@@ -1699,6 +1692,35 @@ impl TextArea {
if idx == 0 { None } else { Some(idx - 1) }
}
/// Soft-wrapped rows are byte-adjacent; a hard row leaves its newline between it and the next.
fn row_with_end(lines: &[Range<usize>], idx: usize) -> Option<(Range<usize>, RowEnd)> {
let row = lines.get(idx)?;
let end_kind = match lines.get(idx + 1) {
Some(next) if next.start == row.end => RowEnd::SoftWrap,
_ => RowEnd::HardBreak,
};
Some((row.clone(), end_kind))
}
fn clamp_to_row(&self, pos: usize, row: &Range<usize>, end_kind: RowEnd) -> usize {
match end_kind {
RowEnd::HardBreak => pos,
RowEnd::SoftWrap => {
let last_grapheme_start = self.clamp_to_line(pos, row.start, row.end);
let Some(idx) = self.find_element_containing(last_grapheme_start) else {
return last_grapheme_start;
};
let element_start = self.elements[idx].range.start;
// A display-less element wraps as text; one that began on an earlier row keeps `pos`.
if element_start < row.start {
pos
} else {
element_start
}
}
}
}
/// Map a display column to a buffer byte position on a given wrapped line.
///
/// Pure query — does not mutate any state. Handles elements (snapping to
@@ -1791,13 +1813,12 @@ impl TextArea {
fn move_to_display_col_on_line(
&mut self,
line_start: usize,
line_end: usize,
row: Range<usize>,
target_col: usize,
end_kind: RowEnd,
) {
self.cursor_pos = self
.display_col_to_buffer_pos(line_start, line_end, target_col)
.0;
let (pos, _hit_element) = self.display_col_to_buffer_pos(row.start, row.end, target_col);
self.cursor_pos = self.clamp_to_row(pos, &row, end_kind);
}
fn beginning_of_line(&self, pos: usize) -> usize {
@@ -2652,14 +2673,10 @@ impl TextArea {
let target_col = self.preferred_col.unwrap_or_else(|| {
self.display_width_of_range(cur_range.start, self.cursor_pos)
});
if idx > 0 {
let prev = &lines[idx - 1];
let line_start = prev.start;
let line_end = prev.end;
Some((target_col, Some((line_start, line_end))))
} else {
Some((target_col, None))
}
let prev = idx
.checked_sub(1)
.and_then(|prev_idx| Self::row_with_end(lines, prev_idx));
Some((target_col, prev))
} else {
None
}
@@ -2669,11 +2686,11 @@ impl TextArea {
} {
// We had wrapping info. Apply movement accordingly.
match maybe_line {
Some((line_start, line_end)) => {
Some((prev, end_kind)) => {
if self.preferred_col.is_none() {
self.preferred_col = Some(target_col);
}
self.move_to_display_col_on_line(line_start, line_end, target_col);
self.move_to_display_col_on_line(prev, target_col, end_kind);
return;
}
None => {
@@ -2697,7 +2714,11 @@ impl TextArea {
};
let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0);
let prev_line_end = prev_nl;
self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col);
self.move_to_display_col_on_line(
prev_line_start..prev_line_end,
target_col,
RowEnd::HardBreak,
);
} else {
self.cursor_pos = 0;
self.preferred_col = None;
@@ -2716,14 +2737,7 @@ impl TextArea {
let target_col = self.preferred_col.unwrap_or_else(|| {
self.display_width_of_range(cur_range.start, self.cursor_pos)
});
if idx + 1 < lines.len() {
let next = &lines[idx + 1];
let line_start = next.start;
let line_end = next.end;
Some((target_col, Some((line_start, line_end))))
} else {
Some((target_col, None))
}
Some((target_col, Self::row_with_end(lines, idx + 1)))
} else {
None
}
@@ -2732,11 +2746,11 @@ impl TextArea {
}
} {
match move_to_last {
Some((line_start, line_end)) => {
Some((next, end_kind)) => {
if self.preferred_col.is_none() {
self.preferred_col = Some(target_col);
}
self.move_to_display_col_on_line(line_start, line_end, target_col);
self.move_to_display_col_on_line(next, target_col, end_kind);
return;
}
None => {
@@ -2766,7 +2780,11 @@ impl TextArea {
.find('\n')
.map(|i| i + next_line_start)
.unwrap_or(self.text.len());
self.move_to_display_col_on_line(next_line_start, next_line_end, target_col);
self.move_to_display_col_on_line(
next_line_start..next_line_end,
target_col,
RowEnd::HardBreak,
);
} else {
self.cursor_pos = self.text.len();
self.preferred_col = None;
@@ -3899,6 +3917,9 @@ fn truncate_line_display(line: &Line<'static>, max_width: usize) -> Line<'static
Line::from(new_spans)
}
#[cfg(test)]
mod soft_wrap_nav_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -6293,6 +6314,7 @@ mod tests {
assert_eq!(t.cursor(), "👍👍".len());
}
/// Each ZWJ cluster fills a 4-col row; Up from the end lands on row 1's cluster start.
#[test]
fn wrapped_navigation_with_zwj_graphemes() {
let grapheme = "👩\u{200D}💻";
@@ -6306,7 +6328,7 @@ mod tests {
assert!(pos_after_down >= grapheme.len() * 2);
t.move_cursor_up();
assert_eq!(t.cursor(), grapheme.len() * 2);
assert_eq!(t.cursor(), grapheme.len());
}
#[test]
@@ -0,0 +1,121 @@
//! Up/Down across soft-wrapped rows: a soft row's exclusive end is the next row's start.
use rand::prelude::*;
use ratatui::layout::Rect;
use ratatui::text::Line;
use super::{ElementKind, TextArea};
fn ta_with(text: &str) -> TextArea {
let mut t = TextArea::new();
t.insert_str(text);
t
}
fn cursor_row(t: &TextArea, width: u16) -> usize {
TextArea::wrapped_line_index_by_start(&t.wrapped_lines(width), t.cursor()).unwrap()
}
#[test]
fn up_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
let width = 8;
let mut t = ta_with("aaa bb ccccccc");
assert_eq!(vec![0..7, 7..14], *t.wrapped_lines(width));
t.move_cursor_up();
assert_eq!(6, t.cursor());
assert_eq!(0, cursor_row(&t, width));
assert_eq!(Some((6, 0)), t.cursor_pos(Rect::new(0, 0, width, 5)));
t.move_cursor_down();
assert_eq!(14, t.cursor());
t.move_cursor_up();
assert_eq!(6, t.cursor());
t.move_cursor_up();
assert_eq!(0, t.cursor());
}
#[test]
fn up_onto_newline_terminated_row_lands_on_the_newline() {
let mut t = ta_with("ab\ncdef");
assert_eq!(vec![0..2, 3..7], *t.wrapped_lines(8));
t.move_cursor_up();
assert_eq!(2, t.cursor());
assert_eq!(0, cursor_row(&t, 8));
}
#[test]
fn down_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
let mut t = ta_with("ccccccc aaa bb ddddddd");
assert_eq!(vec![0..8, 8..15, 15..22], *t.wrapped_lines(8));
t.set_cursor(7);
t.move_cursor_down();
assert_eq!(14, t.cursor());
assert_eq!(1, cursor_row(&t, 8));
t.move_cursor_down();
assert_eq!(22, t.cursor());
}
#[test]
fn up_onto_soft_row_closed_by_element_lands_before_the_element() {
let mut t = TextArea::new();
t.insert_element("xyz", ElementKind(0), Some(Line::from("[ELEM]")));
t.insert_str("longword");
assert_eq!(vec![0..3, 3..11], *t.wrapped_lines(8));
t.move_cursor_up();
assert_eq!(0, t.cursor());
assert_eq!(0, cursor_row(&t, 8));
}
#[test]
fn down_through_element_spanning_rows_reaches_the_end() {
let mut t = TextArea::new();
t.insert_element("abcdefgh", ElementKind(0), None);
t.insert_str("z");
assert_eq!(vec![0..3, 3..6, 6..9], *t.wrapped_lines(3));
t.set_cursor(0);
t.move_cursor_down();
assert_eq!(8, t.cursor());
}
#[test]
fn up_and_down_move_one_row_at_a_time() {
let mut rng = StdRng::seed_from_u64(7);
for _ in 0..500 {
let words = rng.random_range(1..12);
let text = (0..words)
.map(|_| "x".repeat(rng.random_range(1..9)))
.collect::<Vec<_>>()
.join(" ");
let width = rng.random_range(3..12);
let mut t = ta_with(&text);
let rows = t.wrapped_lines(width).len();
t.set_cursor(rng.random_range(0..=text.len()));
let mut row = cursor_row(&t, width);
while row + 1 < rows {
t.move_cursor_down();
assert_eq!(
row + 1,
cursor_row(&t, width),
"down in {text:?} at width {width}"
);
row += 1;
}
while row > 0 {
t.move_cursor_up();
assert_eq!(
row - 1,
cursor_row(&t, width),
"up in {text:?} at width {width}"
);
row -= 1;
}
}
}
@@ -897,7 +897,7 @@ impl CompactionsRemaining {
}
/// Reasoning effort level. `None`/`Minimal` are omitted on the Anthropic Messages API.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningEffort {
None,
@@ -982,6 +982,16 @@ impl std::str::FromStr for ReasoningEffort {
}
}
impl<'de> serde::Deserialize<'de> for ReasoningEffort {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
/// Canonical wire parse; remapped menu ids need a model catalog.
pub fn parse_canonical_effort_token(token: &str) -> Option<ReasoningEffort> {
token.parse().ok()
@@ -1849,6 +1859,24 @@ mod tests {
assert!(serde_json::from_str::<ReasoningEffort>("\"BOGUS\"").is_err());
}
#[test]
fn reasoning_effort_deserializes_mixed_case() {
for (token, want) in [
("\"High\"", ReasoningEffort::High),
("\"XHIGH\"", ReasoningEffort::Xhigh),
("\"mAx\"", ReasoningEffort::Max),
] {
let got: ReasoningEffort = serde_json::from_str(token).unwrap();
assert_eq!(got, want, "deserialize {token}");
}
let err = serde_json::from_str::<ReasoningEffort>("\"Highest\"").unwrap_err();
assert!(
err.to_string().contains("invalid reasoning effort"),
"{err}"
);
assert!(serde_json::from_str::<ReasoningEffort>("7").is_err());
}
#[test]
fn reasoning_effort_from_str_max_and_xhigh_are_distinct() {
assert_eq!(
+12 -175
View File
@@ -544,13 +544,16 @@ mod attachments_protocol {
}
}
#[cfg(any(target_os = "macos", test))]
mod osascript_probe;
// macOS: subprocess-based clipboard (no AppKit linkage)
#[cfg(target_os = "macos")]
mod platform {
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
use super::osascript_probe::{ProbeTemps, osascript_attachments, read_probe_raster};
use super::{ClipboardAttachments, ImageData};
// Fast pasteboard probes (NSPasteboard via lazy dlopen)
@@ -759,153 +762,15 @@ mod platform {
Ok(output.stdout)
}
fn attachments_probe_temp_paths() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf)
{
let temp_dir = std::env::temp_dir();
(
temp_dir.join("kigi-clipboard-probe.png"),
temp_dir.join("kigi-clipboard-probe.tiff"),
temp_dir.join("kigi-clipboard-probe.jpg"),
)
}
fn read_clipboard_image_from_class(
class: &str,
path_png: &std::path::Path,
path_tiff: &std::path::Path,
path_jpg: &std::path::Path,
) -> anyhow::Result<Option<ImageData>> {
let (temp_path, mime) = match class {
"PNGf" => (path_png, "image/png"),
"TIFF" => (path_tiff, "image/tiff"),
"JPEG" => (path_jpg, "image/jpeg"),
_ => return Ok(None),
};
let data = match std::fs::read(temp_path) {
Ok(bytes) if !bytes.is_empty() => bytes,
Ok(_) => {
let _ = std::fs::remove_file(temp_path);
return Ok(None);
}
Err(e) => {
let _ = std::fs::remove_file(temp_path);
return Err(anyhow::anyhow!("failed to read clipboard temp file: {e}"));
}
};
let _ = std::fs::remove_file(temp_path);
Ok(Some(ImageData {
data,
mime_type: mime.to_owned(),
}))
}
fn remove_attachment_probe_temps(
path_png: &std::path::Path,
path_tiff: &std::path::Path,
path_jpg: &std::path::Path,
) {
let _ = std::fs::remove_file(path_png);
let _ = std::fs::remove_file(path_tiff);
let _ = std::fs::remove_file(path_jpg);
}
fn attachments_osascript(
path_png: &std::path::Path,
path_tiff: &std::path::Path,
path_jpg: &std::path::Path,
) -> String {
format!(
"set furlOut to \"none\"\n\
try\n\
set urlList to the clipboard as list\n\
set out to \"\"\n\
repeat with u in urlList\n\
try\n\
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
set out to out & POSIX path of itemRef & \"\\n\"\n\
end try\n\
end repeat\n\
if out is not \"\" then\n\
set furlOut to out\n\
else\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end if\n\
on error\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end try\n\
set imageOut to \"NONE\"\n\
if furlOut is \"none\" then\n\
try\n\
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
set filePath to POSIX file \"{png}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"PNGf\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
set filePath to POSIX file \"{tiff}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"TIFF\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
set filePath to POSIX file \"{jpg}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"JPEG\"\n\
on error\n\
end try\n\
end try\n\
end try\n\
end if\n\
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
png = path_png.display(),
tiff = path_tiff.display(),
jpg = path_jpg.display(),
furl_marker = FURL_MARKER,
image_marker = IMAGE_MARKER,
)
}
fn run_attachments_osascript() -> anyhow::Result<String> {
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
let script = attachments_osascript(&path_png, &path_tiff, &path_jpg);
fn run_osascript(script: &str) -> anyhow::Result<Vec<u8>> {
let mut cmd = Command::new("osascript");
cmd.arg("-e")
.arg(&script)
.arg(script)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
kigi_tools::util::detach_std_command(&mut cmd);
let stdout = match checked_command_stdout("osascript", cmd.output()) {
Ok(stdout) => stdout,
Err(error) => {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
return Err(error);
}
};
Ok(String::from_utf8_lossy(&stdout).into_owned())
checked_command_stdout("osascript", cmd.output())
}
/// Unified furl-then-image pasteboard probe.
@@ -923,28 +788,7 @@ mod platform {
image: Some(image),
});
}
let raw = run_attachments_osascript()?;
if raw.trim().is_empty() {
return Ok(ClipboardAttachments::default());
}
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
let (file_urls, image_class) = parse_attachments_output(&raw);
let image = if file_urls.is_some() {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
None
} else {
match image_class {
Some(class) => {
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)?
}
None => {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
None
}
}
};
Ok(ClipboardAttachments { file_urls, image })
osascript_attachments(ProbeTemps::new(), run_osascript)
}
/// Read text via `pbpaste -Prefer txt`.
@@ -1020,8 +864,8 @@ mod platform {
return Ok(Some(image));
}
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
let temps = ProbeTemps::new()?;
let (path_png, path_tiff, path_jpg) = temps.paths();
// Image-only AppleScript (ImageOnly paste route). Unicode guillemets
// (\u{AB}/\u{BB}) are required for `«class …»` in `osascript -e`.
@@ -1069,20 +913,13 @@ mod platform {
.stdout(Stdio::piped())
.stderr(Stdio::null());
kigi_tools::util::detach_std_command(&mut cmd);
let stdout = match checked_command_stdout("osascript", cmd.output()) {
Ok(stdout) => stdout,
Err(error) => {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
return Err(error);
}
};
let stdout = checked_command_stdout("osascript", cmd.output())?;
let class = String::from_utf8_lossy(&stdout);
let class = class.trim();
if class == "none" {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
return Ok(None);
}
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)
read_probe_raster(class, &temps)
}
/// Read file URLs from the macOS pasteboard via `osascript`.
@@ -0,0 +1,246 @@
//! The macOS `osascript` attachment probe: scratch files, script text, and the read flow.
use std::path::PathBuf;
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
use super::{ClipboardAttachments, ImageData};
/// Private 0700 dir, removed on drop: a shared path let a concurrent kigi swap the raster.
pub(crate) struct ProbeTemps(tempfile::TempDir);
impl ProbeTemps {
pub(crate) fn new() -> anyhow::Result<Self> {
let mut builder = tempfile::Builder::new();
builder.prefix("kigi-clipboard-probe-");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
builder.permissions(std::fs::Permissions::from_mode(0o700));
}
builder
.tempdir()
.map(Self)
.map_err(|e| anyhow::anyhow!("failed to create clipboard probe dir: {e}"))
}
/// PNG, TIFF, JPEG: the AppleScript writes the first class that coerces.
pub(crate) fn paths(&self) -> (PathBuf, PathBuf, PathBuf) {
(
self.0.path().join("probe.png"),
self.0.path().join("probe.tiff"),
self.0.path().join("probe.jpg"),
)
}
}
/// File URLs first, a raster only when none are present; without `temps` the raster half is skipped.
pub(crate) fn attachments_osascript(temps: Option<&ProbeTemps>) -> String {
let image_probe = temps.map_or_else(String::new, raster_half);
format!(
"set furlOut to \"none\"\n\
try\n\
set urlList to the clipboard as list\n\
set out to \"\"\n\
repeat with u in urlList\n\
try\n\
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
set out to out & POSIX path of itemRef & \"\\n\"\n\
end try\n\
end repeat\n\
if out is not \"\" then\n\
set furlOut to out\n\
else\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end if\n\
on error\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end try\n\
set imageOut to \"NONE\"\n\
{image_probe}\
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
furl_marker = FURL_MARKER,
image_marker = IMAGE_MARKER,
)
}
fn raster_half(temps: &ProbeTemps) -> String {
let (path_png, path_tiff, path_jpg) = temps.paths();
format!(
"if furlOut is \"none\" then\n\
try\n\
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
set filePath to POSIX file \"{png}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"PNGf\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
set filePath to POSIX file \"{tiff}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"TIFF\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
set filePath to POSIX file \"{jpg}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"JPEG\"\n\
on error\n\
end try\n\
end try\n\
end try\n\
end if\n",
png = path_png.display(),
tiff = path_tiff.display(),
jpg = path_jpg.display(),
)
}
/// The class on stdout picks the file and MIME; an empty file means no raster.
pub(crate) fn read_probe_raster(
class: &str,
temps: &ProbeTemps,
) -> anyhow::Result<Option<ImageData>> {
let (path_png, path_tiff, path_jpg) = temps.paths();
let (temp_path, mime) = match class {
"PNGf" => (path_png, "image/png"),
"TIFF" => (path_tiff, "image/tiff"),
"JPEG" => (path_jpg, "image/jpeg"),
_ => return Ok(None),
};
let data = std::fs::read(&temp_path)
.map_err(|e| anyhow::anyhow!("failed to read clipboard temp file: {e}"))?;
if data.is_empty() {
return Ok(None);
}
Ok(Some(ImageData {
data,
mime_type: mime.to_owned(),
}))
}
/// `run` executes a script and returns its stdout; a file paste needs no scratch dir.
pub(crate) fn osascript_attachments(
temps: anyhow::Result<ProbeTemps>,
run: impl FnOnce(&str) -> anyhow::Result<Vec<u8>>,
) -> anyhow::Result<ClipboardAttachments> {
if let Err(error) = &temps {
tracing::warn!(%error, "clipboard probe reads file URLs only");
}
let stdout = run(&attachments_osascript(temps.as_ref().ok()))?;
let (file_urls, image_class) = parse_attachments_output(&String::from_utf8_lossy(&stdout));
if file_urls.is_some() {
return Ok(ClipboardAttachments {
file_urls,
image: None,
});
}
let temps = temps?;
let image = match image_class {
Some(class) => read_probe_raster(class, &temps)?,
None => None,
};
Ok(ClipboardAttachments {
file_urls: None,
image,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn attachments_stdout(furl: &str, image: &str) -> Vec<u8> {
format!("{FURL_MARKER}\n{furl}\n{IMAGE_MARKER}\nIMAGE:{image}").into_bytes()
}
#[test]
fn two_probes_never_share_a_path() {
let first = ProbeTemps::new().unwrap();
let second = ProbeTemps::new().unwrap();
assert_ne!(first.paths().0, second.paths().0);
}
#[test]
fn scratch_dir_goes_away_with_the_probe() {
let temps = ProbeTemps::new().unwrap();
let (png, tiff, jpg) = temps.paths();
let dir = png.parent().unwrap().to_path_buf();
assert_eq!(tiff.parent().unwrap(), dir);
assert_eq!(jpg.parent().unwrap(), dir);
std::fs::write(&png, b"raster").unwrap();
drop(temps);
assert!(!dir.exists(), "{} must be removed", dir.display());
}
#[cfg(unix)]
#[test]
fn scratch_dir_is_private() {
use std::os::unix::fs::PermissionsExt;
let temps = ProbeTemps::new().unwrap();
let dir = temps.paths().0.parent().unwrap().to_path_buf();
let mode = std::fs::metadata(dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
}
#[test]
fn reported_class_is_read_from_this_probes_file() {
let temps = ProbeTemps::new().unwrap();
let (_png, tiff, _jpg) = temps.paths();
std::fs::write(&tiff, b"MM\x00\x2a").unwrap();
let script_names_the_file = tiff.display().to_string();
let read = osascript_attachments(Ok(temps), |script| {
assert!(script.contains(&script_names_the_file), "{script}");
Ok(attachments_stdout("none", "TIFF"))
})
.unwrap();
let image = read.image.expect("raster attached");
assert_eq!(image.mime_type, "image/tiff");
assert_eq!(image.data, b"MM\x00\x2a".to_vec());
assert_eq!(read.file_urls, None);
}
#[test]
fn file_urls_survive_a_missing_scratch_dir() {
let read = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |script| {
assert!(
!script.contains("open for access"),
"raster half must be skipped"
);
Ok(attachments_stdout("/tmp/a.txt\n", "NONE"))
})
.unwrap();
assert_eq!(read.file_urls.as_deref(), Some("/tmp/a.txt"));
assert_eq!(read.image, None);
}
#[test]
fn no_scratch_dir_and_no_file_url_is_an_error_not_an_empty_board() {
let err = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |_| {
Ok(attachments_stdout("none", "NONE"))
})
.unwrap_err();
assert!(err.to_string().contains("no scratch dir"), "{err}");
}
}
@@ -6707,7 +6707,10 @@ reasoning_effort = "low"
assert_eq!(model.info.base_url, "https://inference.example.com/v1");
}
#[test]
#[serial]
fn e2e_default_model_with_session_routes_to_proxy() {
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
let (_, models) = resolve_models_from_toml("", None);
let model = models
.get(BUNDLED_DEFAULT_KEY)
@@ -6839,7 +6842,10 @@ reasoning_effort = "low"
);
}
#[test]
#[serial]
fn e2e_duplicate_model_field_both_entries_survive() {
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
let dm = crate::models::default_model();
let (_, models) = resolve_models_from_toml(
&format!(
@@ -367,7 +367,7 @@ mod tests {
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "http://127.0.0.1:1/api.json");
let catalog = load_enrichment_catalog_at(&path);
assert!(
kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some(),
kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some(),
"bundled snapshot must back a total refresh failure"
);
assert!(!path.exists(), "failed refresh must not write a cache");
@@ -385,7 +385,7 @@ mod tests {
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, token);
assert!(refresh_url().is_none(), "token {token:?} must disable");
let catalog = load_enrichment_catalog_at(&path);
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some());
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some());
}
}
@@ -1473,7 +1473,7 @@ mod tests {
/// openai-codex fetch: the catalog is HARDCODED, so the fetch path
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
/// the fetch returns exactly the 4 compiled-in models keyed
/// the fetch returns exactly the 5 compiled-in models keyed
/// `openai-codex/<slug>` on the Responses backend, ctx 272000, each exposing
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
@@ -1510,20 +1510,16 @@ mod tests {
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec![
"openai-codex/gpt-6-astra",
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
"openai-codex/gpt-5.5",
],
"exactly the 4 hardcoded models, keyed openai-codex/<slug>"
"exactly the 5 hardcoded models, keyed openai-codex/<slug>"
);
// Excluded models never appear.
for absent in [
"openai-codex/gpt-5.3-codex-spark",
"openai-codex/gpt-5.4",
"openai-codex/gpt-5.4-mini",
"openai-codex/codex-auto-review",
] {
for absent in ["openai-codex/gpt-reserve", "openai-codex/codex-auto-review"] {
assert!(
!result
.models
@@ -1532,7 +1528,14 @@ mod tests {
"{absent} must be absent from the hardcoded catalog"
);
}
let sol = &result.models[0];
let astra = &result.models[0];
assert_eq!(astra.name.as_deref(), Some("GPT-6-Astra"));
assert_eq!(astra.context_window.get(), 272_000);
let sol = result
.models
.iter()
.find(|m| m.id.as_deref() == Some("openai-codex/gpt-5.6-sol"))
.unwrap();
assert_eq!(
sol.api_backend,
crate::sampling::ApiBackend::Responses,
@@ -638,6 +638,7 @@ fn write_import_marker(config_path: &Path) -> anyhow::Result<()> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let config_path = &crate::util::config::config_write_dest(config_path)?;
let tmp = config_path.with_extension("toml.tmp");
// Best-effort cleanup of the .tmp file if either write or rename fails so
// a failed marker write doesn't leave a stale artefact next to the real
@@ -820,6 +821,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
if count > 0 {
let toml_str = toml::to_string_pretty(&root)?;
let config_path = &crate::util::config::config_write_dest(config_path)?;
let tmp = config_path.with_extension("toml.tmp");
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
@@ -513,6 +513,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
if applied.total_added() > 0 {
// Atomic write: tmp + rename (same pattern as save_mcp_server_config).
let toml_str = toml::to_string_pretty(&root)?;
let config_path = crate::util::config::config_write_dest(&config_path)?;
let tmp = config_path.with_extension("toml.tmp");
if let Err(e) = std::fs::write(&tmp, &toml_str) {
let _ = std::fs::remove_file(&tmp);
@@ -1020,6 +1020,7 @@ pub(super) async fn build_skill_information_for_refs(
slash_skills: &[SkillInfo],
session_id: &str,
) -> Option<String> {
use kigi_tools::implementations::skills::body_cap::cap_skill_body;
use kigi_tools::implementations::skills::skill::{
SkillRef, SubstitutionContext, apply_substitutions, build_skill_block,
build_skill_information, load_skill_content,
@@ -1034,6 +1035,13 @@ pub(super) async fn build_skill_information_for_refs(
};
match load_skill_content(info).await {
Ok(mut content) => {
if cap_skill_body(&mut content) {
tracing::info!(
skill = %sk.name,
path = %info.path,
"skill body truncated at read cap"
);
}
let skill_dir = std::path::Path::new(&info.path)
.parent()
.and_then(|p| p.to_str());
@@ -1465,6 +1473,31 @@ mod tests {
);
}
/// A 220 KB skill body stops at the read cap; the tail stays on disk.
#[tokio::test]
async fn build_skill_information_for_refs_caps_an_oversized_body() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("SKILL.md");
let body = (1..=1100)
.map(|n| format!("{n:05} {}", "x".repeat(194)))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, &body).unwrap();
let mut skill = make_skill("big", true);
skill.path = path.to_string_lossy().to_string();
let skills = vec![skill];
let parsed =
parse_skill_references("/big", &skills, all_gated()).expect("known skill must parse");
let info = build_skill_information_for_refs(&parsed, &skills, "sid-1")
.await
.expect("skill body must load");
assert!(info.contains("00001 "), "head must survive");
assert!(!info.contains("01100 "), "tail must be cut");
assert!(info.contains("Skill body truncated at the 25000-token cap"));
}
#[test]
fn resolve_loop_annotates_block_with_compact_display_text() {
let outcome = resolve(
@@ -359,6 +359,7 @@ pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(&path)?;
let tmp = path.with_extension("toml.tmp");
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
@@ -413,6 +414,7 @@ pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(&path)?;
let tmp = path.with_extension("toml.tmp");
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
@@ -472,6 +474,7 @@ pub async fn save_mcp_server_config_at(
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(path)?;
let tmp = path.with_extension("toml.tmp");
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
@@ -552,6 +555,7 @@ pub async fn delete_mcp_server_config_at(
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(path)?;
let tmp = path.with_extension("toml.tmp");
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
@@ -10,6 +10,7 @@ mod resolve;
mod settings_writes;
mod tips;
mod worktree;
mod write_dest;
pub use campaigns::{
load_effective_config, load_effective_config_disk_only, persist_models_default,
@@ -30,3 +31,4 @@ pub use resolve::*;
pub use settings_writes::*;
pub use tips::*;
pub use worktree::*;
pub(crate) use write_dest::config_write_dest;
@@ -52,6 +52,7 @@ pub async fn save_config(config: &Config) -> Result<()> {
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(&path)?;
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
@@ -0,0 +1,110 @@
//! Where a config write lands when the path is a symlink.
use std::io;
use std::path::{Path, PathBuf};
/// The user's own config.toml keeps its symlink; every other path comes back unchanged.
pub(crate) fn config_write_dest(path: &Path) -> io::Result<PathBuf> {
// With no home, `kigi_home()` is `./.kigi`: the project's own tree, never trusted.
let user_config = kigi_config::user_kigi_home().map(|home| home.join("config.toml"));
write_dest(path, user_config.as_deref())
}
/// A project config is repo-controlled: following its symlink would overwrite any file it names.
fn write_dest(path: &Path, user_config: Option<&Path>) -> io::Result<PathBuf> {
if user_config != Some(path) {
return Ok(path.to_path_buf());
}
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => dunce::canonicalize(path).map_err(|e| {
let context = format!("config symlink {} does not resolve: {e}", path.display());
io::Error::new(e.kind(), context)
}),
Ok(_) => Ok(path.to_path_buf()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(path.to_path_buf()),
Err(e) => Err(e),
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::util::config::{McpServerConfig, save_mcp_server_config_at};
fn symlinked_config(dir: &Path, referent_content: &str) -> (PathBuf, PathBuf) {
let referent = dir.join("dotfiles.toml");
std::fs::write(&referent, referent_content).unwrap();
let link = dir.join("config.toml");
std::os::unix::fs::symlink(&referent, &link).unwrap();
(link, referent)
}
#[test]
fn user_config_symlink_resolves_to_its_referent() {
let dir = tempfile::tempdir().unwrap();
let (link, referent) = symlinked_config(dir.path(), "");
let dest = write_dest(&link, Some(&link)).unwrap();
assert_eq!(dest, dunce::canonicalize(&referent).unwrap());
}
#[test]
fn regular_and_missing_user_config_keep_their_path() {
let dir = tempfile::tempdir().unwrap();
let regular = dir.path().join("config.toml");
std::fs::write(&regular, "").unwrap();
let missing = dir.path().join("absent.toml");
assert_eq!(write_dest(&regular, Some(&regular)).unwrap(), regular);
assert_eq!(write_dest(&missing, Some(&missing)).unwrap(), missing);
}
#[test]
fn dangling_user_config_symlink_is_an_error_not_a_clobber() {
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("config.toml");
std::os::unix::fs::symlink(dir.path().join("gone.toml"), &link).unwrap();
let err = write_dest(&link, Some(&link)).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert!(err.to_string().contains("does not resolve"), "{err}");
}
#[test]
fn project_config_symlink_is_never_followed() {
let dir = tempfile::tempdir().unwrap();
let (link, _referent) = symlinked_config(dir.path(), "");
let user_config = dir.path().join("home").join("config.toml");
assert_eq!(write_dest(&link, Some(&user_config)).unwrap(), link);
}
#[test]
fn nothing_is_followed_when_no_user_home_resolves() {
let dir = tempfile::tempdir().unwrap();
let (link, _referent) = symlinked_config(dir.path(), "");
assert_eq!(write_dest(&link, None).unwrap(), link);
}
#[tokio::test]
async fn project_scope_save_replaces_the_link_and_spares_its_target() {
let dir = tempfile::tempdir().unwrap();
let (link, target) = symlinked_config(dir.path(), "victim = true\n");
let server: McpServerConfig =
toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
save_mcp_server_config_at(&link, "demo", &server)
.await
.unwrap();
let meta = std::fs::symlink_metadata(&link).unwrap();
assert!(
meta.file_type().is_file(),
"the link is replaced, not followed"
);
assert_eq!(std::fs::read_to_string(&target).unwrap(), "victim = true\n");
let saved = std::fs::read_to_string(&link).unwrap();
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
}
}
@@ -0,0 +1,35 @@
//! A symlinked `~/.kigi/config.toml` survives a save: the write lands on the referent.
#![cfg(unix)]
use kigi_shell::util::config::{McpServerConfig, save_mcp_server_config};
#[tokio::test]
async fn user_config_symlink_survives_a_save() {
let home = tempfile::tempdir().unwrap();
let dotfiles = tempfile::tempdir().unwrap();
// SAFETY: the only test in this binary; set before `kigi_home()` first runs.
unsafe { std::env::set_var("KIGI_SHARE_DIR", home.path()) };
let referent = dotfiles.path().join("kigi.toml");
std::fs::write(&referent, "[ui]\ntheme = \"dark\"\n").unwrap();
let link = home.path().join("config.toml");
std::os::unix::fs::symlink(&referent, &link).unwrap();
let server: McpServerConfig = toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
save_mcp_server_config("demo", &server).await.unwrap();
let meta = std::fs::symlink_metadata(&link).unwrap();
assert!(meta.file_type().is_symlink(), "the link must survive");
let saved = std::fs::read_to_string(&referent).unwrap();
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
assert!(saved.contains("theme = \"dark\""), "{saved}");
let leftovers: Vec<_> = std::fs::read_dir(home.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.filter(|name| name != "config.toml")
.collect();
assert!(
leftovers.is_empty(),
"no tmp beside the link: {leftovers:?}"
);
}
@@ -46,6 +46,12 @@ impl ReadFileVersion {
}
}
pub(crate) const MAX_NUM_TOKENS: usize = 25_000;
pub(crate) const MAX_NUM_BYTES: usize =
MAX_NUM_TOKENS * kigi_token_estimation::BYTES_PER_TOKEN as usize;
/// True when `text` is over the per-call token cap.
pub(crate) fn exceeds_read_cap(text: &str) -> bool {
crate::util::truncate::estimate_tokens(text) > MAX_NUM_TOKENS
}
pub const MAX_LINES_READ: usize = 1_000;
pub use crate::implementations::read_file::{
FileMetadata, PDF_MAX_PAGES_PER_READ, bytes_to_metadata, parse_page_range,
@@ -434,22 +440,25 @@ pub(crate) async fn run_read_file(
.map(|t| t.0.max_lines_read())
.unwrap_or_else(|| TruncationConfig::default().max_lines_read())
};
let (effective_offset, effective_limit) = if is_skill_file {
(None, None)
} else {
(
input.offset,
Some(input.limit.unwrap_or(usize::MAX).min(max_lines)),
)
// A skill file under the cap comes back whole; over it, it reads like any other file.
let skill_full = is_skill_file
.then(|| extract_file_content_lines(&file_content, None, None, total_lines))
.filter(|full| !exceeds_read_cap(&full.content));
let (extracted, stored_offset, stored_limit) = match skill_full {
Some(full) => (full, None, None),
None => (
extract_file_content_lines(
&file_content,
input.offset,
Some(input.limit.unwrap_or(usize::MAX).min(max_lines)),
total_lines,
),
stored_read_offset(input.offset),
input.limit,
),
};
let extracted = extract_file_content_lines(
&file_content,
effective_offset,
effective_limit,
total_lines,
);
let token_count = crate::util::truncate::estimate_tokens(&extracted.content);
if !is_skill_file && token_count > MAX_NUM_TOKENS {
if exceeds_read_cap(&extracted.content) {
let token_count = crate::util::truncate::estimate_tokens(&extracted.content);
let (grep_name, execute_name);
{
let res = resources.lock().await;
@@ -497,11 +506,6 @@ pub(crate) async fn run_read_file(
};
return Ok(ReadFileOutput::FileTooLarge(msg));
}
let (stored_offset, stored_limit) = if is_skill_file {
(None, None)
} else {
(stored_read_offset(input.offset), input.limit)
};
if let Some(flag) = streamable_out {
*flag = true;
}
@@ -1660,9 +1664,8 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
other => panic!("Expected FileContent, got {:?}", other),
}
}
#[tokio::test]
async fn skill_file_skips_token_limit() {
let tmp = TempDir::new().unwrap();
/// 1100 lines of 200 bytes: about 55k tokens, over the 25k cap.
fn write_oversized_skill(tmp: &TempDir) {
let line = "x".repeat(200);
let big_content = std::iter::repeat_n(line.as_str(), 1100)
.collect::<Vec<_>>()
@@ -1670,6 +1673,11 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
let skill_dir = tmp.path().join("skills");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), &big_content).unwrap();
}
#[tokio::test]
async fn skill_file_over_cap_returns_file_too_large() {
let tmp = TempDir::new().unwrap();
write_oversized_skill(&tmp);
let tool = ReadFileTool;
let mut resources = test_resources(tmp.path());
resources.insert(TemplateRenderer::new(
@@ -1687,11 +1695,36 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
.await
.unwrap();
assert!(
matches!(result, ReadFileOutput::FileContent(_)),
"SKILL.md should not be truncated, got {:?}",
matches!(result, ReadFileOutput::FileTooLarge(_)),
"got {:?}",
std::mem::discriminant(&result),
);
}
#[tokio::test]
async fn skill_file_over_cap_honors_offset_and_limit() {
let tmp = TempDir::new().unwrap();
write_oversized_skill(&tmp);
let tool = ReadFileTool;
let resources = test_resources(tmp.path());
let input = ReadFileInput {
path: "skills/SKILL.md".to_string(),
offset: Some(3),
limit: Some(2),
pages: None,
format: None,
};
let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
.await
.unwrap();
match result {
ReadFileOutput::FileContent(fc) => {
assert_eq!((Some(3), Some(2)), (fc.offset, fc.limit));
let line = "x".repeat(200);
assert_eq!(format!("3→{line}\n{line}"), fc.content);
}
other => panic!("Expected FileContent, got {:?}", other),
}
}
#[test]
fn parse_single_page() {
assert_eq!(parse_page_range("3", 10).unwrap(), vec![2]);
@@ -6,6 +6,7 @@
use std::path::Path;
use crate::implementations::skills::body_cap::cap_skill_body;
use crate::implementations::skills::skill::{SkillOutput, extract_skill_body, format_skill_name};
use crate::implementations::skills::types::SkillInfo;
use crate::types::requirements::{Expr, ToolRequirement};
@@ -279,7 +280,7 @@ impl kigi_tool_runtime::Tool for SkillTool {
}
};
let content = match load_skill_content(&skill).await {
let mut content = match load_skill_content(&skill).await {
Ok(c) => c,
Err(e) => {
return Ok(SkillOutput {
@@ -292,6 +293,14 @@ impl kigi_tool_runtime::Tool for SkillTool {
}
};
if cap_skill_body(&mut content) {
tracing::info!(
skill = %skill.name,
path = %skill.path,
"skill body truncated at read cap"
);
}
let files = list_skill_files(&skill, 10).await;
let output = format_output(&skill, &content, &files);
@@ -0,0 +1,59 @@
//! Cap an injected skill body at the file-read token cap.
use crate::implementations::kigi::read_file::{MAX_NUM_BYTES, MAX_NUM_TOKENS, exceeds_read_cap};
use crate::util::truncate::floor_char_boundary;
/// Cuts `body` on a line boundary under the read cap and appends a note; true when it cut.
pub fn cap_skill_body(body: &mut String) -> bool {
if !exceeds_read_cap(body) {
return false;
}
let note = format!(
"[Skill body truncated at the {MAX_NUM_TOKENS}-token cap. Read the rest of the \
skill file with the file read tool using a line offset and limit.]"
);
let budget = MAX_NUM_BYTES.saturating_sub(note.len() + 2);
let end = floor_char_boundary(body, budget);
let cut = body[..end].rfind('\n').filter(|&i| i > 0).unwrap_or(end);
body.truncate(cut);
body.push_str("\n\n");
body.push_str(&note);
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leaves_body_under_cap_untouched() {
let mut body = "# Small skill\n\nDo the thing.".to_owned();
assert!(!cap_skill_body(&mut body));
assert_eq!("# Small skill\n\nDo the thing.", body);
}
#[test]
fn cuts_on_line_boundary_under_cap() {
let mut body = (1..=1100)
.map(|n| format!("{n:05} {}", "x".repeat(194)))
.collect::<Vec<_>>()
.join("\n");
assert!(cap_skill_body(&mut body));
assert!(!exceeds_read_cap(&body));
assert!(!body.contains("01100 "));
assert!(body.contains("offset"));
let (head, _note) = body.rsplit_once("\n\n").expect("note separator");
let last_line = head.rsplit('\n').next().expect("head has a line");
assert_eq!(200, last_line.len());
}
#[test]
fn single_long_line_falls_back_to_char_cut() {
let mut body = "é".repeat(60_000);
assert!(cap_skill_body(&mut body));
assert!(!exceeds_read_cap(&body));
assert!(body.starts_with("éé"));
}
}
@@ -1,3 +1,4 @@
pub mod body_cap;
pub mod discovery;
pub mod skill;
pub mod types;
@@ -8,8 +8,8 @@ const MAX_LINES_READ_DEFAULT: usize = 1_000;
/// There is deliberately no per-line cap: clipping long lines silently
/// corrupts single-line files (minified JSON, data dumps) with no way for
/// the model to recover the clipped bytes. Non-skill reads are bounded by
/// the whole-read `MAX_NUM_TOKENS` cap instead (skill files are exempt from
/// all read limits by design). Other agent CLIs likewise apply no
/// the whole-read `MAX_NUM_TOKENS` cap instead (a skill file under that cap
/// comes back whole, offset and limit ignored). Other agent CLIs likewise apply no
/// per-line cap. The wire field (`TruncationConfig.max_chars_per_line` in
/// kigi-tools.proto) is deprecated and ignored.
#[derive(Debug, Clone, Default)]
@@ -96,16 +96,15 @@ impl SlashCommand for TerminalSetupCommand {
// Some terminals can't distinguish Shift+Enter from bare Enter at
// the byte level because the Kitty keyboard protocol isn't
// negotiated (VTE < 0.82, or VS Code's xterm.js which mis-encodes
// shifted keys). Point users at Alt+Enter, which is reliably
// delivered as ESC+CR. Suppressed when the WezTerm warning fired:
// stock WezTerm binds Alt+Enter to ToggleFullScreen, so advertising
// it would contradict that warning's `\`+Enter guidance.
if ctx.shift_enter_unavailable() && !wezterm_kkp_off {
// negotiated (VTE, VS Code's xterm.js, a multiplexer that eats
// extended keys). Point users at Alt+Enter, which is reliably
// delivered as ESC+CR. Never for WezTerm: it binds Alt+Enter to
// ToggleFullScreen, so its own warning's `\`+Enter guidance stands.
if ctx.shift_enter_unavailable() && !wezterm_kkp_off && ctx.brand != TerminalName::WezTerm {
let detail = if ctx.vte_version.is_some() || ctx.brand == TerminalName::Vte {
match ctx.vte_version.as_deref() {
Some(v) => format!("VTE {v}; need >= 8200 for Shift+Enter"),
None => "legacy VTE; need VTE >= 0.82 for Shift+Enter".to_owned(),
Some(v) => format!("VTE {v}: no Kitty keyboard protocol"),
None => "VTE: no Kitty keyboard protocol".to_owned(),
}
} else if matches!(
ctx.brand,
+1 -1
View File
@@ -24,7 +24,7 @@ TARGETS = [
"anthropic", "azure", "openai", "deepseek", "nvidia", "google",
"amazon-bedrock", "mistral", "groq", "cerebras", "cloudflare-ai-gateway",
"xai", "togetherai", "fireworks-ai", "opencode", "opencode-go",
"kimi-for-coding", "moonshotai", "moonshotai-cn", "minimax", "minimax-cn",
"kimi-code-plan-cn", "moonshotai", "moonshotai-cn", "minimax", "minimax-cn",
"alibaba-token-plan", "alibaba-token-plan-cn", "xiaomi",
"xiaomi-token-plan-cn", "zai-coding-plan", "zhipuai-coding-plan",
"github-copilot", "vercel",