Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781fb44659 | ||
|
|
a640816c38 | ||
|
|
256ff2528e | ||
|
|
2820dd5f2b | ||
|
|
11b5a47ef0 | ||
|
|
bc93b540cd | ||
|
|
0189009822 | ||
|
|
206711bed5 | ||
|
|
443de79e2f | ||
|
|
be0983237e | ||
|
|
3ccb6e5e91 |
No files matched your search
@@ -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
|
||||
@@ -355,8 +382,7 @@ 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` &&
|
||||
@@ -505,10 +531,10 @@ 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;
|
||||
`ultra` was dropped the same month, no backend accepts it; 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
@@ -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.16"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent-lifecycle"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"tokio",
|
||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-auth"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http 1.4.2",
|
||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-bin"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-chat-state"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"kigi-compaction",
|
||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-codebase-graph"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"clap",
|
||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-compaction"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blake3",
|
||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"indexmap",
|
||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-crash-handler"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"libc",
|
||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-env"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fast-worktree"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-file-utils"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-config",
|
||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fsnotify"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"dunce",
|
||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-gix-status"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"gix",
|
||||
"kigi-test-utils",
|
||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"kigi-config",
|
||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks-plugins-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-http"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"kigi-auth",
|
||||
"kigi-log",
|
||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hunk-tracker"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-interjection-core"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-log"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-lossy",
|
||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown-core"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"pulldown-cmark",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mcp"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-memory"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mermaid"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"fontdb",
|
||||
"image",
|
||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-models"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"kigi-env",
|
||||
"serde",
|
||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-minimal"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossterm",
|
||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-pty-harness"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-render"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anstyle",
|
||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-paths"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"serde",
|
||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-prompt-queue"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-proto-build"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pbjson-build",
|
||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-inline"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
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.16"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampler"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"async-openai",
|
||||
"async-stream",
|
||||
@@ -6155,7 +6155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampling-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"assert_matches",
|
||||
"async-openai",
|
||||
@@ -6172,7 +6172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sandbox"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6194,7 +6194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-secrets"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
@@ -6232,7 +6232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6370,7 +6370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell-base"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6395,7 +6395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sqlite-journal"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rusqlite",
|
||||
@@ -6406,7 +6406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-subagent-resolution"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"kigi-sampling-types",
|
||||
"kigi-tool-types",
|
||||
@@ -6421,7 +6421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-system-power"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"zbus",
|
||||
@@ -6429,7 +6429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-support"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6451,7 +6451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-utils"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"runfiles",
|
||||
"tracing",
|
||||
@@ -6460,11 +6460,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-token-estimation"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-protocol"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"kigi-tool-types",
|
||||
"serde",
|
||||
@@ -6475,7 +6475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-runtime"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -6493,7 +6493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"minijinja",
|
||||
"schemars 1.2.1",
|
||||
@@ -6503,7 +6503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -6580,7 +6580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools-api"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"kigi-proto-build",
|
||||
"kigi-tool-protocol",
|
||||
@@ -6593,11 +6593,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tracing-macros"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tty-utils"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"nix 0.30.1",
|
||||
@@ -6607,7 +6607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tui"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"ansi-to-tui",
|
||||
@@ -6694,7 +6694,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-update"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -6723,14 +6723,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-version"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6809,7 +6809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
@@ -8843,7 +8843,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -8861,7 +8861,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl-cli"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
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
@@ -76,7 +76,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
|
||||
+5028
-5029
File diff suppressed because it is too large.
Load diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(®ular, "").unwrap();
|
||||
let missing = dir.path().join("absent.toml");
|
||||
|
||||
assert_eq!(write_dest(®ular, Some(®ular)).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(¬e);
|
||||
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)]
|
||||
|
||||
Reference in new issue
Block a user